DEV Community

Jefry Pozo
Jefry Pozo

Posted on

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")
        {
        }
}
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Remember to export the effect:

[assembly: ExportEffect(typeof(PlatformTouchRippleEffect), nameof(TouchRippleEffect))]
Enter fullscreen mode Exit fullscreen mode

If this is the first effect in your solution, then you must also add the ResolutionGroupName:

[assembly: ResolutionGroupName("companyName")]
Enter fullscreen mode Exit fullscreen mode

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;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

OnDetached

We simply unsubscribe from the events to prevent memory leaks:

protected override void OnDetached()
{
    if (IsDisposed) return;

    Container.Touch -= OnTouch;
    Container.LongClick -= ContainerOnLongClick;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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; }
    }
Enter fullscreen mode Exit fullscreen mode

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"));
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)