Xamarin.Forms набирает обороты и, к сожалению, из коробки доступно совсем мало возможностей, все неоходимо добавлять через Dependency service или рендереры. На этой волне стало очень много различных библиотек, добавляющих зачастую базовый функционал.
Мое решение не исключение.
У меня стояла задача сделать небольшое расширение, позволяющее добавить эффект нажатия на почти любой элемент для iOS и Android.
Изначально у меня была мысль создать контейнер с эффектом нажатия и в него уже добавлять необходимые элементы. От этой идеи пришлось отказаться в виду дополнительной вложенности и некорректности выделения. То есть, положив в этот контейнер не прямоугольный элемент по типу CircleImage или Frame я получил бы выделение за пределами закругленной области.
Переписывать и расширять все контролы было бы глупо, поэтому я решил добавить статическое расширение.
Как это должно выглядеть
Для Android 5+ очевидно, надо использовать Ripple effect. Но для iOS и Android <5 это решение будет выглядеть неуместно. Для этих платформ я решил реализовать цветное анимированное выделение, срабатываемое при касании.
Реализация
PCL
Для начала в PCL проекте был создан статический класс TouchEffect с двумя BindableProperty:
- bool On
- Color Color
Первое отвечает за активность расширения для элемента, соответсвенно второе за цвет эффекта.
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.On="True"
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'ки.
Автор: mrxten