Ripple effect in Xamarin.Android

If you want your controls to have ripple effect in Xamarin.Android you can use the TouchEffect from the Xamarin Community Toolkit, for most use cases it should work well.
But I wanted to have ripple effect in a CollectionView and I need a Switch in the ItemTemplate to receive click events and according to this issue the TouchEffect doesn't work well on Android if your view has clickable children, so I had to roll my own.
Creating a ripple effect in Xamarin.Android
To achieve our goal, we're going to need to handle the touch events ourselves in order to fire the ripple effect. But first, let's create a RoutingEffect with some bindable properties to be used in the Android project.
Creating a RoutingEffect
We are going to add some bindable properties to our Effect:
  • Command
  • CommandParameter
  • LongPressCommand
  • LongPressCommandParameter
  • NormalColor
  • RippleColor
  • SelectedColor.
  • First, let's create a new class for our RoutingEffect.
    Always remember to add the company name to avoid clashes with other effects.
    public class TouchRippleEffect : RoutingEffect
    {
            public TouchRippleEffect() : base("companyName.TouchRippleEffect")
            {
            }
    }
    Now, let's add the bindable properties:
    public static readonly BindableProperty CommandProperty = BindableProperty.CreateAttached(
        "Command",
        typeof(ICommand),
        typeof(TouchRippleEffect),
        default(ICommand),
        propertyChanged: OnCommandChanged
    );
    
    public static readonly BindableProperty CommandParameterProperty = BindableProperty.CreateAttached(
        "CommandParameter",
        typeof(object),
        typeof(TouchRippleEffect),
        null,
        propertyChanged: OnCommandParameterChanged
    );
    
    public static readonly BindableProperty LongPressCommandProperty = BindableProperty.CreateAttached(
        "LongPressCommand",
        typeof(ICommand),
        typeof(TouchRippleEffect),
        default(ICommand),
        propertyChanged: OnLongPressCommandChanged
    );
    
    public static readonly BindableProperty LongPressCommandParameterProperty = BindableProperty.CreateAttached(
        "LongPressCommandParameter",
        typeof(object),
        typeof(TouchRippleEffect),
        null,
        propertyChanged: OnLongPressCommandParameterChanged
    );
    
    public static void SetCommand(BindableObject bindable, ICommand value)
    {
        bindable.SetValue(CommandProperty, value);
    }
    
    public static ICommand GetCommand(BindableObject bindable)
    {
        return (ICommand) bindable.GetValue(CommandProperty);
    }
    
    private static void OnCommandChanged(BindableObject bindable, object oldValue, object newValue)
    {
        AttachEffect(bindable);
    }
    
    public static void SetCommandParameter(BindableObject bindable, object value)
    {
        bindable.SetValue(CommandParameterProperty, value);
    }
    
    public static object GetCommandParameter(BindableObject bindable)
    {
        return bindable.GetValue(CommandParameterProperty);
    }
    
    private static void OnCommandParameterChanged(BindableObject bindable, object oldValue, object newValue)
    {
        AttachEffect(bindable);
    }
    
    public static void SetLongPressCommand(BindableObject bindable, ICommand value)
    {
        bindable.SetValue(LongPressCommandProperty, value);
    }
    
    public static ICommand GetLongPressCommand(BindableObject bindable)
    {
        return (ICommand) bindable.GetValue(LongPressCommandProperty);
    }
    
    private static void OnLongPressCommandChanged(BindableObject bindable, object oldValue, object newValue)
    {
        AttachEffect(bindable);
    }
    
    public static void SetLongPressCommandParameter(BindableObject bindable, object value)
    {
        bindable.SetValue(LongPressCommandParameterProperty, value);
    }
    
    public static object GetLongPressCommandParameter(BindableObject bindable)
    {
        return bindable.GetValue(LongPressCommandParameterProperty);
    }
    
    private static void OnLongPressCommandParameterChanged(BindableObject bindable, object oldValue,
        object newValue)
    {
        AttachEffect(bindable);
    }
    
    public static readonly BindableProperty NormalColorProperty = BindableProperty.CreateAttached(
        "NormalColor",
        typeof(Color),
        typeof(TouchRippleEffect),
        Color.White,
        propertyChanged: OnNormalColorChanged
    );
    
    public static void SetNormalColor(BindableObject bindable, Color value)
    {
        bindable.SetValue(NormalColorProperty, value);
    }
    
    public static Color GetNormalColor(BindableObject bindable)
    {
        return (Color) bindable.GetValue(NormalColorProperty);
    }
    
    static void OnNormalColorChanged(BindableObject bindable, object oldValue, object newValue)
    {
        AttachEffect(bindable);
    }
    
    public static readonly BindableProperty RippleColorProperty = BindableProperty.CreateAttached(
        "RippleColor",
        typeof(Color),
        typeof(TouchRippleEffect),
        Color.LightSlateGray,
        propertyChanged: OnRippleColorChanged
    );
    
    public static void SetRippleColor(BindableObject bindable, Color value)
    {
        bindable.SetValue(RippleColorProperty, value);
    }
    
    public static Color GetRippleColor(BindableObject bindable)
    {
        return (Color) bindable.GetValue(RippleColorProperty);
    }
    
    static void OnRippleColorChanged(BindableObject bindable, object oldValue, object newValue)
    {
        AttachEffect(bindable);
    }
    
    public static readonly BindableProperty SelectedColorProperty = BindableProperty.CreateAttached(
        "SelectedColor",
        typeof(Color),
        typeof(TouchRippleEffect),
        Color.LightGreen,
        propertyChanged: OnSelectedColorChanged
    );
    
    public static void SetSelectedColor(BindableObject bindable, Color value)
    {
        bindable.SetValue(SelectedColorProperty, value);
    }
    
    public static Color GetSelectedColor(BindableObject bindable)
    {
        return (Color) bindable.GetValue(SelectedColorProperty);
    }
    
    static void OnSelectedColorChanged(BindableObject bindable, object oldValue, object newValue)
    {
        AttachEffect(bindable);
    }
    
    static void AttachEffect(BindableObject bindable)
    {
        if (bindable is not VisualElement view || view.Effects.OfType<TouchRippleEffect>().Any())
            return;
    
        view.Effects.Add(new TouchRippleEffect());
    }
    Notice we're are attaching the Effect to the view if one of the properties change, this is so we can use the Effect directly on the control without having to add it to the collection of the control.
    Adding the PlatformEffect in the Android project
    Now that the Effect is ready, let's go to our Android project and add a PlatformEffect:
    public class PlatformTouchRippleEffect : PlatformEffect
    {
      public bool IsDisposed => (Container as IVisualElementRenderer)?.Element == null;
    }
    Remember to export the effect:
    [assembly: ExportEffect(typeof(PlatformTouchRippleEffect), nameof(TouchRippleEffect))]
    If this is the first effect in your solution, then you must also add the ResolutionGroupName:
    [assembly: ResolutionGroupName("companyName")]
    OnAttached logic
    When the effect is attached, we simply need to subscribe to the OnTouch and LongClick events so we can execute the respective commands:
    protected override void OnAttached()
    {
        if (Container != null)
        {
            SetBackgroundDrawables();
            Container.HapticFeedbackEnabled = false;
            var command = TouchRippleEffect.GetCommand(Element);
            if (command != null)
            {
                Container.Clickable = true;
                Container.Focusable = false;
                Container.Touch += OnTouch;
            }
    
            var longPressCommand = TouchRippleEffect.GetLongPressCommand(Element);
            if (longPressCommand != null)
            {
                Container.LongClickable = true;
                Container.LongClick += ContainerOnLongClick;
            }
        }
    }
    OnDetached
    We simply unsubscribe from the events to prevent memory leaks:
    protected override void OnDetached()
    {
        if (IsDisposed) return;
    
        Container.Touch -= OnTouch;
        Container.LongClick -= ContainerOnLongClick;
    }
    Adding background drawables
    In Android, in order to change the color applied when the view state changes, you must set the background (or foreground) with a drawable that will be used on certain state.
    We are going to add a drawable to the background for each of the states of our properties (Normal, Pressed (ripple) and Selected) with a StateListDrawable:
    private void SetBackgroundDrawables()
    {
        var normalColor = TouchRippleEffect.GetNormalColor(Element).ToAndroid();
        var rippleColor = TouchRippleEffect.GetRippleColor(Element).ToAndroid();
        var selectedColor = TouchRippleEffect.GetSelectedColor(Element).ToAndroid();
    
        var stateList = new StateListDrawable();
        var normalDrawable = new GradientDrawable(GradientDrawable.Orientation.LeftRight,
            new int[] {normalColor, normalColor});
        var rippleDrawable = new RippleDrawable(ColorStateList.ValueOf(rippleColor), _normalDrawable, null);
        var activatedDrawable = new GradientDrawable(GradientDrawable.Orientation.LeftRight,
            new int[] {selectedColor, selectedColor});
    
        stateList.AddState(new[] {Android.Resource.Attribute.Enabled}, normalDrawable);
        stateList.AddState(new[] {Android.Resource.Attribute.StatePressed}, rippleDrawable);
        stateList.AddState(new[] {Android.Resource.Attribute.StateActivated}, activatedDrawable);
    
        Container.SetBackground(stateList);
    }
    Handling user touch
    Since we are going to handle the touch interactions ourselves, let's add some flags to know the current state of the touch event:
    private float _prevY;
    private bool _hasMoved;
    private bool _isLongPress;
    Handling click event
    The first thing we need is to save the current action and position when the touch interaction starts:
    Touch start
    private void OnTouch(object sender, AView.TouchEventArgs e)
    {
        e.Handled = false;
        var currentY = e.Event.GetY();
    
        var action = e.Event?.Action;
        switch (action)
        {
            case MotionEventActions.Down:
                _prevY = e.Event.GetY();
                break;
        }
    }
    Touch finished
    Now, we need to handle each case when the user finishes the touch, moves the finger, or the touch event is cancelled. First let's see the finish and cancel cases:
    case MotionEventActions.Up:
    {
        // The TouchEvent is called again after a long click
        // here we ensure the ClickCommand is only called 
        // when not doing a long click
        if (!_isLongPress)
        {
            if (_hasMoved) return;
            if (Container.Activated) Container.Activated = false;
            var command = TouchRippleEffect.GetCommand(Element);
            var param = TouchRippleEffect.GetCommandParameter(Element);
            command?.Execute(param);
        }
    
        // If a long click was fired, set the flag to false
        // so we can correctly register a single click again
        _isLongPress = false;
        _hasMoved = false;
        break;
    }
    
    case MotionEventActions.Cancel:
    {
        _isLongPress = false;
        _hasMoved = false;
        break;
    }
    Touch move
    Now, the tricky part is the Move action.
    It happened that sometimes the current view would get the selected color if you touched it and then scrolled, and would lose the state if it was selected.
    case MotionEventActions.Move:
    {
        var diffY = currentY - _prevY;
        var absolute = Math.Abs(diffY);
        if (absolute > 8) _hasMoved = true;
        if (_hasMoved && Container.Background is StateListDrawable drawable)
        {
            // Keep the NormalColor on scroll
            if (!Container.Activated)
            {
                drawable.SetState(new[] {Android.Resource.Attribute.Enabled});
                drawable.JumpToCurrentState();
            }
            else
            {
                // Keep the SelectedColor when scrolling and the touched view is a selected item
                drawable.SetState(new[] {Android.Resource.Attribute.StateActivated});
                drawable.JumpToCurrentState();
            }
        }
    
        break;
    }
    I had to check the Container.Activated state to see if the item was selected to handle each case. This flag is set in the long click event.
    Handling long click
    In the long click event, we will set the _isLongPress flag to true, since the Touch event fires again after the long click. We will also notify the user by performing the haptic feedback.
    private void ContainerOnLongClick(object sender, AView.LongClickEventArgs e)
    {
        // Notify to the user that the item was selected
        (sender as AView)?.PerformHapticFeedback(FeedbackConstants.LongPress);
    
        // If item is currently selected, disable long click
        if (_hasMoved)
        {
            _hasMoved = false;
            return;
        }
    
        var command = TouchRippleEffect.GetLongPressCommand(Element);
        var param = TouchRippleEffect.GetLongPressCommandParameter(Element);
        command?.Execute(param);
        _isLongPress = true;
    
        // Set the Container.Activated and the selected color drawable
        // so we know the item is selected on a next touch
        if (Container.Background is StateListDrawable drawable)
        {
            drawable.SetState(new[] {Android.Resource.Attribute.StateActivated});
            Container.Activated = true;
        }
    
        // Bubble up the event to avoid touch errors 
        e.Handled = false;
        _hasMoved = false;
    }
    Using the ripple effect
    With all that in place, now it's time to put our effect to good use so let's create a simple app to see it in action.
    The model:
    public class TouchEffectModel
        {
            public TouchEffectModel(string labelText)
            {
                LabelText = labelText;
            }
    
            public string LabelText { get; set; }
        }
    The ViewModel is a simple ViewModel with two command and a collection, I'll omit the Commands for brevity:
    private ICollection<TouchEffectModel> _collection;
    public ICollection<TouchEffectModel> Collection
    {
        get => _collection;
        set
        {
            _collection = value;
            RaisePropertyChanged();
        }
    }
    
    void InitializeCollection()
    {
        if (_collection is null) _collection = new List<TouchEffectModel>();
        _collection.Add(new TouchEffectModel("I'm item 1"));
        _collection.Add(new TouchEffectModel("I'm item 2"));
        _collection.Add(new TouchEffectModel("I'm item 3"));
        _collection.Add(new TouchEffectModel("I'm item 4"));
        _collection.Add(new TouchEffectModel("I'm item 5"));
    }
    The View:
    <?xml version="1.0" encoding="utf-8"?>
    <ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
                 xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
                 xmlns:viewModels="clr-namespace:XamarinSamples.ViewModels;assembly=XamarinSamples"
                 xmlns:effects="clr-namespace:XamarinSamples.Effects;assembly=XamarinSamples"
                 x:Class="XamarinSamples.Views.TouchEffectView">
    
        <ContentPage.BindingContext>
            <viewModels:TouchEffectViewModel x:Key="ViewModel" />
        </ContentPage.BindingContext>
    
        <ContentPage.Content>        
            <CollectionView x:Name="CollectionView"
                            ItemsSource="{Binding Collection}">
                <CollectionView.ItemTemplate>
                    <DataTemplate>
                        <StackLayout Orientation="Horizontal" Margin="10"
                                     effects:TouchRippleEffect.Command="{Binding BindingContext.ClickCommand, Source={x:Reference CollectionView}}"
                                     effects:TouchRippleEffect.LongPressCommand="{Binding BindingContext.LongClickCommand, Source={x:Reference CollectionView}}">
                            <Label HorizontalOptions="CenterAndExpand" 
                                   VerticalOptions="Center"
                                   InputTransparent="True"
                                   Text="{Binding LabelText}" FontSize="16"
                                   Margin="{x:OnPlatform Android='5,0,0,-13', iOS='0,0,0,-15'}" />
                            <Switch InputTransparent="False"
                                    HorizontalOptions="End" VerticalOptions="Center"
                                    Margin="{x:OnPlatform Android='0,0,5,0', iOS='0,0,5,0'}" />                        
                        </StackLayout>
                    </DataTemplate>
                </CollectionView.ItemTemplate>
            </CollectionView>
        </ContentPage.Content>
    </ContentPage>
    Now we can see our effect in action:
    Ripple effect in action
    Repository
    The code for the article is at this repo.

    GitHub logo jpozo20 / xamarin-samples

    Xamarin code for posts published at dev.to/jefrypozo

    xamarin-samples

    Xamarin code for posts published at dev.to/jefrypozo

    Conclusions
    Though in general Xamarin works really well out of the box, more often than not I've found that if you want niceties in Xamarin.Forms you have to put your hands at the native platforms for certain features.
    In the next entry I'll be talking about adding a contextual menu for multiple selection in the toolbar when using a CollectionView, like the menu that Whatsapp or Telegram display when you select a chat or a message.

    31

    This website collects cookies to deliver better user experience

    Ripple effect in Xamarin.Android