Добавляем эффект нажатия в Xamarin.Forms

    Xamarin.Forms набирает обороты и, к сожалению, из коробки доступно совсем мало возможностей, все неоходимо добавлять через Dependency service или рендереры. На этой волне стало очень много различных библиотек, добавляющих зачастую базовый функционал.
    Мое решение не исключение.


    У меня стояла задача сделать небольшое расширение, позволяющее добавить эффект нажатия на почти любой элемент для iOS и Android.


    Изначально у меня была мысль создать контейнер с эффектом нажатия и в него уже добавлять необходимые элементы. От этой идеи пришлось отказаться в виду дополнительной вложенности и некорректности выделения. То есть, положив в этот контейнер не прямоугольный элемент по типу CircleImage или Frame я получил бы выделение за пределами закругленной области.


    Переписывать и расширять все контролы было бы глупо, поэтому я решил добавить статическое расширение.


    Как это должно выглядеть


    Для Android 5+ очевидно, надо использовать Ripple effect. Но для iOS и Android <5 это решение будет выглядеть неуместно. Для этих платформ я решил реализовать цветное анимированное выделение, срабатываемое при касании.


    Реализация


    PCL


    Для начала в PCL проекте был создан статический класс TouchEffect с BindableProperty, отвечающее за цвет эффекта.


    Android


    Необходимо определить переменную, которая идентифицирует нужно использовать Ripple effect или нет в зависимости от версии Android:


    public bool EnableRipple => Build.VERSION.SdkInt >= BuildVersionCodes.Lollipop;

    Реализация стандартных волн для андроида довольно проста:


    private void AddRipple()
    {
        if (Element is Layout)
        {
            _rippleOverlay = new FrameLayout(Container.Context)
            {
                LayoutParameters = new ViewGroup.LayoutParams(-1, -1)
            };
    
            _rippleListener = new ContainerOnLayoutChangeListener(_rippleOverlay);
            _view.AddOnLayoutChangeListener(_rippleListener);
    
            ((ViewGroup)_view).AddView(_rippleOverlay);
    
            _rippleOverlay.BringToFront();
            _rippleOverlay.Foreground = CreateRipple(Color.Accent.ToAndroid());
        }
        else
        {
            _orgDrawable = _view.Background;
            _view.Background = CreateRipple(Color.Accent.ToAndroid());
        }
    
        _ripple.SetColor(GetPressedColorSelector(_color));
    }
    
    private void RemoveRipple()
    {
        if (Element is Layout)
        {
            var viewgrp = (ViewGroup)_view;
            viewgrp?.RemoveOnLayoutChangeListener(_rippleListener);
            viewgrp?.RemoveView(_rippleOverlay);
    
            _rippleListener?.Dispose();
            _rippleListener = null;
    
            _rippleOverlay?.Dispose();
            _rippleOverlay = null;
        }
        else
        {
            _view.Background = _orgDrawable;
            _orgDrawable?.Dispose();
            _orgDrawable = null;
        }
        _ripple?.Dispose();
        _ripple = null;
    }
    
    private RippleDrawable CreateRipple(Android.Graphics.Color color)
    {
        if (Element is Layout)
        {
            var mask = new ColorDrawable(Android.Graphics.Color.White);
            return _ripple = new RippleDrawable(GetPressedColorSelector(color), null, mask);
        }
    
        var back = _view.Background;
        if (back == null)
        {
            var mask = new ColorDrawable(Android.Graphics.Color.White);
            return _ripple = new RippleDrawable(GetPressedColorSelector(color), null, mask);
        }
        else if (back is RippleDrawable)
        {
            _ripple = (RippleDrawable) back.GetConstantState().NewDrawable();
            _ripple.SetColor(GetPressedColorSelector(color));
    
            return _ripple;
        }
        else
        {
            return _ripple = new RippleDrawable(GetPressedColorSelector(color), back, null);
        }
    }

    У контрола берется задний фон и на него добавляется эффект.


    Для более старых версий андроида я решил добавлять FrameLayout поверх элемента с анимацией Alpha канала заднего фона. К событию Touch элемента подписывается этот метод:


    private void OnTouch(object sender, View.TouchEventArgs args)
    {
        switch (args.Event.Action)
        {
            case MotionEventActions.Down:
                Container.RemoveView(_layer);
                Container.AddView(_layer);
                _layer.Top = 0;
                _layer.Left = 0;
                _layer.Right = _view.Width;
                _layer.Bottom = _view.Height;
                _layer.BringToFront();
                TapAnimation(250, 0, 65, false);
                break;
            case MotionEventActions.Up:
            case MotionEventActions.Cancel:
                TapAnimation(250, 65, 0);
                break;
        }
    }

    Который при нажатии добавляет в контейнер новый лэйаут с анимацией A-канала с 0 до 65, а при отпускании анимирует обратно от 65 до 0 и удаляет из контейнера.


    Потом, в методе OnAttached определяем, что делать, создавать Ripple effect или подписываться на Touch:


    if (EnableRipple)
        AddRipple();
    else
        _view.Touch += OnTouch;

    iOS


    Для iOS подход схож с предыдущим шагом, добавляется UIView поверх основного элемента при нажатии и так же анимируется A-канал. Для этого создаются UITapGestureRecognizer и UILongPressGestureRecognizer и добавляются к элементу:


    _tapGesture = new UITapGestureRecognizer(async (obj) => {
        await TapAnimation(0.3, _alpha, 0);
    });
    
    _longTapGesture = new UILongPressGestureRecognizer(async (obj) => {
        switch (obj.State)
        {
            case UIGestureRecognizerState.Began:
                await TapAnimation(0.5, 0, _alpha, false);
                break;
            case UIGestureRecognizerState.Ended:
            case UIGestureRecognizerState.Cancelled:
            case UIGestureRecognizerState.Failed:
                await TapAnimation(0.5, _alpha);
                break;
        }
    });
    
    _view.AddGestureRecognizer(_longTapGesture);
    _view.AddGestureRecognizer(_tapGesture);

    При долгом нажатии задается другое время анимации и, в отличие от простого нажатия, маска удаляется только после отпускания пальца.


    Собственно все.


    Использование


    XAML:


    <ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
                 xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
                 xmlns:local="clr-namespace:XamEffects.Sample"
                 xmlns:xe="clr-namespace:XamEffects;assembly=XamEffects"
                 x:Class="XamEffects.Sample.MainPage">
        <Grid HorizontalOptions="Center"
              VerticalOptions="Center"
              HeightRequest="100"
              WidthRequest="200"
              BackgroundColor="LightGray" 
              xe:TouchEffect.Color="Red">
            <Label Text="Test touch effect"
                   HorizontalOptions="Center"
                   VerticalOptions="Center"/>
        </Grid>
    </ContentPage>

    iOS Android API >=21 Android API < 21

    Итоги


    Я привел основую идею реализации эффекта касания, весь код, а так же Nuget пакеты доступны на GitHub.


    P.S.: Опыт у меня в нативной разработке небольшой, буду рад советам, что можно улучшить/доработать.
    P.P.S.: Habrastorage немного коряво преобразовал gif'ки.

    Share post

    Similar posts

    Comments 0

    Only users with full accounts can post comments. Log in, please.