PM-3349 PM-3350 Added the partial MAUI Community Toolkit implementation for TouchEffect. This is a temporary solution until they finalize this and add it to their nuget package.

This allows implementing the LongPressCommand in AccountSwitchingOverlay and also have the "Ripple effect" animation when touching an item in Android
This commit is contained in:
Dinis Vieira 2023-11-13 09:59:54 +00:00
parent a31f15559f
commit 27306fe353
No known key found for this signature in database
GPG Key ID: 9389160FF6C295F3
23 changed files with 3331 additions and 11 deletions

View File

@ -0,0 +1,72 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using ViewExtensions = Microsoft.Maui.Controls.ViewExtensions;
namespace CommunityToolkit.Maui.Extensions;
static class VisualElementExtensions
{
internal static bool TryFindParentElementWithParentOfType<T>(this VisualElement? element, out VisualElement? result, out T? parent) where T : VisualElement
{
result = null;
parent = null;
while (element?.Parent is not null)
{
if (element.Parent is not T parentElement)
{
element = element.Parent as VisualElement;
continue;
}
result = element;
parent = parentElement;
return true;
}
return false;
}
public static Task<bool> ColorTo(this VisualElement element, Color color, uint length = 250u, Easing? easing = null)
{
_ = element ?? throw new ArgumentNullException(nameof(element));
var animationCompletionSource = new TaskCompletionSource<bool>();
if (element.BackgroundColor is null)
{
return Task.FromResult(false);
}
new Animation
{
{ 0, 1, new Animation(v => element.BackgroundColor = new Color((float)v, element.BackgroundColor.Green, element.BackgroundColor.Blue, element.BackgroundColor.Alpha), element.BackgroundColor.Red, color.Red) },
{ 0, 1, new Animation(v => element.BackgroundColor = new Color(element.BackgroundColor.Red, (float)v, element.BackgroundColor.Blue, element.BackgroundColor.Alpha), element.BackgroundColor.Green, color.Green) },
{ 0, 1, new Animation(v => element.BackgroundColor = new Color(element.BackgroundColor.Red, element.BackgroundColor.Green, (float)v, element.BackgroundColor.Alpha), element.BackgroundColor.Blue, color.Blue) },
{ 0, 1, new Animation(v => element.BackgroundColor = new Color(element.BackgroundColor.Red, element.BackgroundColor.Green, element.BackgroundColor.Blue, (float)v), element.BackgroundColor.Alpha, color.Alpha) },
}.Commit(element, nameof(ColorTo), 16, length, easing, (d, b) => animationCompletionSource.SetResult(true));
return animationCompletionSource.Task;
}
public static void AbortAnimations(this VisualElement element, params string[] otherAnimationNames)
{
_ = element ?? throw new ArgumentNullException(nameof(element));
ViewExtensions.CancelAnimations(element);
element.AbortAnimation(nameof(ColorTo));
if (otherAnimationNames is null)
{
return;
}
foreach (var name in otherAnimationNames)
{
element.AbortAnimation(name);
}
}
}

View File

@ -0,0 +1,789 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using CommunityToolkit.Maui.Extensions;
using static System.Math;
namespace CommunityToolkit.Maui.Behaviors;
sealed class GestureManager : IDisposable
{
const int animationProgressDelay = 10;
Color? defaultBackgroundColor;
CancellationTokenSource? longPressTokenSource;
CancellationTokenSource? animationTokenSource;
Func<TouchBehavior, TouchState, HoverState, int, Easing?, CancellationToken, Task>? animationTaskFactory;
double? durationMultiplier;
double animationProgress;
TouchState animationState;
internal void HandleTouch(TouchBehavior sender, TouchStatus status)
{
if (sender.IsDisabled)
{
return;
}
var canExecuteAction = sender.CanExecute;
if (status != TouchStatus.Started || canExecuteAction)
{
if (!canExecuteAction)
{
status = TouchStatus.Canceled;
}
var state = status == TouchStatus.Started
? TouchState.Pressed
: TouchState.Normal;
if (status == TouchStatus.Started)
{
animationProgress = 0;
animationState = state;
}
var isToggled = sender.IsToggled;
if (isToggled.HasValue)
{
if (status != TouchStatus.Started)
{
durationMultiplier = (animationState == TouchState.Pressed && !isToggled.Value) ||
(animationState == TouchState.Normal && isToggled.Value)
? 1 - animationProgress
: animationProgress;
UpdateStatusAndState(sender, status, state);
if (status == TouchStatus.Canceled)
{
sender.ForceUpdateState(false);
return;
}
OnTapped(sender);
sender.IsToggled = !isToggled;
return;
}
state = isToggled.Value
? TouchState.Normal
: TouchState.Pressed;
}
UpdateStatusAndState(sender, status, state);
}
if (status == TouchStatus.Completed)
{
OnTapped(sender);
}
}
internal void HandleUserInteraction(TouchBehavior sender, TouchInteractionStatus interactionStatus)
{
if (sender.InteractionStatus != interactionStatus)
{
sender.InteractionStatus = interactionStatus;
sender.RaiseInteractionStatusChanged();
}
}
internal void HandleHover(TouchBehavior sender, HoverStatus status)
{
if (!sender.Element?.IsEnabled ?? true)
{
return;
}
var hoverState = status == HoverStatus.Entered
? HoverState.Hovered
: HoverState.Normal;
if (sender.HoverState != hoverState)
{
sender.HoverState = hoverState;
sender.RaiseHoverStateChanged();
}
if (sender.HoverStatus != status)
{
sender.HoverStatus = status;
sender.RaiseHoverStatusChanged();
}
}
internal async Task ChangeStateAsync(TouchBehavior sender, bool animated)
{
var status = sender.Status;
var state = sender.State;
var hoverState = sender.HoverState;
AbortAnimations(sender);
animationTokenSource = new CancellationTokenSource();
var token = animationTokenSource.Token;
var isToggled = sender.IsToggled;
if (sender.Element is not null)
{
UpdateVisualState(sender.Element, state, hoverState);
}
if (!animated)
{
if (isToggled.HasValue)
{
state = isToggled.Value
? TouchState.Pressed
: TouchState.Normal;
}
var durationMultiplier = this.durationMultiplier;
this.durationMultiplier = null;
await RunAnimationTask(sender, state, hoverState, animationTokenSource.Token, durationMultiplier.GetValueOrDefault()).ConfigureAwait(false);
return;
}
var pulseCount = sender.PulseCount;
if (pulseCount == 0 || (state == TouchState.Normal && !isToggled.HasValue))
{
if (isToggled.HasValue)
{
Console.WriteLine($"Touch state: {status}");
var r = (status == TouchStatus.Started && isToggled.Value) ||
(status != TouchStatus.Started && !isToggled.Value);
state = r
? TouchState.Normal
: TouchState.Pressed;
}
await RunAnimationTask(sender, state, hoverState, animationTokenSource.Token).ConfigureAwait(false);
return;
}
do
{
var rippleState = isToggled.HasValue && isToggled.Value
? TouchState.Normal
: TouchState.Pressed;
await RunAnimationTask(sender, rippleState, hoverState, animationTokenSource.Token);
if (token.IsCancellationRequested)
{
return;
}
rippleState = isToggled.HasValue && isToggled.Value
? TouchState.Pressed
: TouchState.Normal;
await RunAnimationTask(sender, rippleState, hoverState, animationTokenSource.Token);
if (token.IsCancellationRequested)
{
return;
}
}
while (--pulseCount != 0);
}
internal void HandleLongPress(TouchBehavior sender)
{
if (sender.State == TouchState.Normal)
{
longPressTokenSource?.Cancel();
longPressTokenSource?.Dispose();
longPressTokenSource = null;
return;
}
if (sender.LongPressCommand is null || sender.InteractionStatus == TouchInteractionStatus.Completed)
{
return;
}
longPressTokenSource = new CancellationTokenSource();
Task.Delay(sender.LongPressDuration, longPressTokenSource.Token).ContinueWith(t =>
{
if (t.IsFaulted && t.Exception != null)
{
throw t.Exception;
}
if (t.IsCanceled)
{
return;
}
var longPressAction = new Action(() =>
{
sender.HandleUserInteraction(TouchInteractionStatus.Completed);
sender.RaiseLongPressCompleted();
});
if (sender.Dispatcher.IsDispatchRequired)
{
sender.Dispatcher.Dispatch(longPressAction);
}
else
{
longPressAction.Invoke();
}
});
}
internal void SetCustomAnimationTask(Func<TouchBehavior, TouchState, HoverState, int, Easing?, CancellationToken, Task>? animationTaskFactory)
=> this.animationTaskFactory = animationTaskFactory;
internal void Reset()
{
SetCustomAnimationTask(null);
defaultBackgroundColor = default;
}
internal void OnTapped(TouchBehavior sender)
{
if (!sender.CanExecute || (sender.LongPressCommand != null && sender.InteractionStatus == TouchInteractionStatus.Completed))
{
return;
}
if (DeviceInfo.Platform == DevicePlatform.Android)
{
HandleCollectionViewSelection(sender);
}
if (sender.Element is IButtonController button)
{
button.SendClicked();
}
sender.RaiseCompleted();
}
void HandleCollectionViewSelection(TouchBehavior sender)
{
CollectionView? parent = null;
VisualElement? result = null;
if (!sender.Element?.TryFindParentElementWithParentOfType(out result, out parent) ?? true)
{
return;
}
var collectionView = parent ?? throw new NullReferenceException();
var item = result?.BindingContext ?? result ?? throw new NullReferenceException();
switch (collectionView.SelectionMode)
{
case SelectionMode.Single:
collectionView.SelectedItem = item;
break;
case SelectionMode.Multiple:
var selectedItems = collectionView.SelectedItems?.ToList() ?? new List<object>();
if (selectedItems.Contains(item))
{
selectedItems.Remove(item);
}
else
{
selectedItems.Add(item);
}
collectionView.UpdateSelectedItems(selectedItems);
break;
}
}
internal void AbortAnimations(TouchBehavior sender)
{
animationTokenSource?.Cancel();
animationTokenSource?.Dispose();
animationTokenSource = null;
var element = sender.Element;
if (element == null)
{
return;
}
element.AbortAnimations();
}
void UpdateStatusAndState(TouchBehavior sender, TouchStatus status, TouchState state)
{
sender.Status = status;
sender.RaiseStatusChanged();
if (sender.State != state || status != TouchStatus.Canceled)
{
sender.State = state;
sender.RaiseStateChanged();
}
}
void UpdateVisualState(VisualElement visualElement, TouchState touchState, HoverState hoverState)
{
var state = touchState == TouchState.Pressed
? TouchBehavior.PressedVisualState
: hoverState == HoverState.Hovered
? TouchBehavior.HoveredVisualState
: TouchBehavior.UnpressedVisualState;
VisualStateManager.GoToState(visualElement, state);
}
async Task SetBackgroundImageAsync(TouchBehavior sender, TouchState touchState, HoverState hoverState, int duration, CancellationToken token)
{
var normalBackgroundImageSource = sender.NormalBackgroundImageSource;
var pressedBackgroundImageSource = sender.PressedBackgroundImageSource;
var hoveredBackgroundImageSource = sender.HoveredBackgroundImageSource;
if (normalBackgroundImageSource is null &&
pressedBackgroundImageSource is null &&
hoveredBackgroundImageSource is null)
{
return;
}
var aspect = sender.BackgroundImageAspect;
var source = normalBackgroundImageSource;
if (touchState == TouchState.Pressed)
{
if (sender.IsSet(TouchBehavior.PressedBackgroundImageAspectProperty))
{
aspect = sender.PressedBackgroundImageAspect;
}
source = pressedBackgroundImageSource;
}
else if (hoverState == HoverState.Hovered)
{
if (sender.IsSet(TouchBehavior.HoveredBackgroundImageAspectProperty))
{
aspect = sender.HoveredBackgroundImageAspect;
}
if (sender.IsSet(TouchBehavior.HoveredBackgroundImageSourceProperty))
{
source = hoveredBackgroundImageSource;
}
}
else
{
if (sender.IsSet(TouchBehavior.NormalBackgroundImageAspectProperty))
{
aspect = sender.NormalBackgroundImageAspect;
}
}
try
{
if (sender.ShouldSetImageOnAnimationEnd && duration > 0)
{
await Task.Delay(duration, token);
}
}
catch (TaskCanceledException)
{
return;
}
if (sender.Element is Image image)
{
using (image.Batch())
{
image.Aspect = aspect;
image.Source = source;
}
}
}
Task<bool> SetBackgroundColor(TouchBehavior sender, TouchState touchState, HoverState hoverState, int duration, Easing? easing)
{
var normalBackgroundColor = sender.NormalBackgroundColor;
var pressedBackgroundColor = sender.PressedBackgroundColor;
var hoveredBackgroundColor = sender.HoveredBackgroundColor;
if (sender.Element == null
|| (normalBackgroundColor is null
&& pressedBackgroundColor is null
&& hoveredBackgroundColor is null))
{
return Task.FromResult(false);
}
var element = sender.Element;
if (defaultBackgroundColor == default)
{
defaultBackgroundColor = element.BackgroundColor;
}
var color = GetBackgroundColor(normalBackgroundColor);
if (touchState == TouchState.Pressed)
{
color = GetBackgroundColor(pressedBackgroundColor);
}
else if (hoverState == HoverState.Hovered && sender.IsSet(TouchBehavior.HoveredBackgroundColorProperty))
{
color = GetBackgroundColor(hoveredBackgroundColor);
}
if (duration <= 0)
{
element.AbortAnimations();
element.BackgroundColor = color;
return Task.FromResult(true);
}
return element.ColorTo(color ?? Colors.Transparent, (uint)duration, easing);
}
Task<bool> SetOpacity(TouchBehavior sender, TouchState touchState, HoverState hoverState, int duration, Easing? easing)
{
var normalOpacity = sender.NormalOpacity;
var pressedOpacity = sender.PressedOpacity;
var hoveredOpacity = sender.HoveredOpacity;
if (Abs(normalOpacity - 1) <= double.Epsilon &&
Abs(pressedOpacity - 1) <= double.Epsilon &&
Abs(hoveredOpacity - 1) <= double.Epsilon)
{
return Task.FromResult(false);
}
var opacity = normalOpacity;
if (touchState == TouchState.Pressed)
{
opacity = pressedOpacity;
}
else if (hoverState == HoverState.Hovered && sender.IsSet(TouchBehavior.HoveredOpacityProperty))
{
opacity = hoveredOpacity;
}
var element = sender.Element;
if (duration <= 0 && element is not null)
{
element.AbortAnimations();
element.Opacity = opacity;
return Task.FromResult(true);
}
return element is null ?
Task.FromResult(false) :
element.FadeTo(opacity, (uint)Abs(duration), easing);
}
Task SetScale(TouchBehavior sender, TouchState touchState, HoverState hoverState, int duration, Easing? easing)
{
var normalScale = sender.NormalScale;
var pressedScale = sender.PressedScale;
var hoveredScale = sender.HoveredScale;
if (Abs(normalScale - 1) <= double.Epsilon &&
Abs(pressedScale - 1) <= double.Epsilon &&
Abs(hoveredScale - 1) <= double.Epsilon)
{
return Task.FromResult(false);
}
var scale = normalScale;
if (touchState == TouchState.Pressed)
{
scale = pressedScale;
}
else if (hoverState == HoverState.Hovered && sender.IsSet(TouchBehavior.HoveredScaleProperty))
{
scale = hoveredScale;
}
var element = sender.Element;
if (element is null)
{
return Task.FromResult(false);
}
if (duration <= 0)
{
element.AbortAnimations(nameof(SetScale));
element.Scale = scale;
return Task.FromResult(true);
}
var animationCompletionSource = new TaskCompletionSource<bool>();
element.Animate(nameof(SetScale), v =>
{
if (double.IsNaN(v))
{
return;
}
element.Scale = v;
}, element.Scale, scale, 16, (uint)Abs(duration), easing, (v, b) => animationCompletionSource.SetResult(b));
return animationCompletionSource.Task;
}
Task SetTranslation(TouchBehavior sender, TouchState touchState, HoverState hoverState, int duration, Easing? easing)
{
var normalTranslationX = sender.NormalTranslationX;
var pressedTranslationX = sender.PressedTranslationX;
var hoveredTranslationX = sender.HoveredTranslationX;
var normalTranslationY = sender.NormalTranslationY;
var pressedTranslationY = sender.PressedTranslationY;
var hoveredTranslationY = sender.HoveredTranslationY;
if (Abs(normalTranslationX) <= double.Epsilon
&& Abs(pressedTranslationX) <= double.Epsilon
&& Abs(hoveredTranslationX) <= double.Epsilon
&& Abs(normalTranslationY) <= double.Epsilon
&& Abs(pressedTranslationY) <= double.Epsilon
&& Abs(hoveredTranslationY) <= double.Epsilon)
{
return Task.FromResult(false);
}
var translationX = normalTranslationX;
var translationY = normalTranslationY;
if (touchState == TouchState.Pressed)
{
translationX = pressedTranslationX;
translationY = pressedTranslationY;
}
else if (hoverState == HoverState.Hovered)
{
if (sender.IsSet(TouchBehavior.HoveredTranslationXProperty))
{
translationX = hoveredTranslationX;
}
if (sender.IsSet(TouchBehavior.HoveredTranslationYProperty))
{
translationY = hoveredTranslationY;
}
}
var element = sender.Element;
if (duration <= 0 && element != null)
{
element.AbortAnimations();
element.TranslationX = translationX;
element.TranslationY = translationY;
return Task.FromResult(true);
}
return element?.TranslateTo(translationX, translationY, (uint)Abs(duration), easing) ?? Task.FromResult(false);
}
Task SetRotation(TouchBehavior sender, TouchState touchState, HoverState hoverState, int duration, Easing? easing)
{
var normalRotation = sender.NormalRotation;
var pressedRotation = sender.PressedRotation;
var hoveredRotation = sender.HoveredRotation;
if (Abs(normalRotation) <= double.Epsilon
&& Abs(pressedRotation) <= double.Epsilon
&& Abs(hoveredRotation) <= double.Epsilon)
{
return Task.FromResult(false);
}
var rotation = normalRotation;
if (touchState == TouchState.Pressed)
{
rotation = pressedRotation;
}
else if (hoverState == HoverState.Hovered && sender.IsSet(TouchBehavior.HoveredRotationProperty))
{
rotation = hoveredRotation;
}
var element = sender.Element;
if (duration <= 0 && element != null)
{
element.AbortAnimations();
element.Rotation = rotation;
return Task.FromResult(true);
}
return element?.RotateTo(rotation, (uint)Abs(duration), easing) ?? Task.FromResult(false);
}
Task SetRotationX(TouchBehavior sender, TouchState touchState, HoverState hoverState, int duration, Easing? easing)
{
var normalRotationX = sender.NormalRotationX;
var pressedRotationX = sender.PressedRotationX;
var hoveredRotationX = sender.HoveredRotationX;
if (Abs(normalRotationX) <= double.Epsilon &&
Abs(pressedRotationX) <= double.Epsilon &&
Abs(hoveredRotationX) <= double.Epsilon)
{
return Task.FromResult(false);
}
var rotationX = normalRotationX;
if (touchState == TouchState.Pressed)
{
rotationX = pressedRotationX;
}
else if (hoverState == HoverState.Hovered && sender.IsSet(TouchBehavior.HoveredRotationXProperty))
{
rotationX = hoveredRotationX;
}
var element = sender.Element;
if (duration <= 0 && element != null)
{
element.AbortAnimations();
element.RotationX = rotationX;
return Task.FromResult(true);
}
return element?.RotateXTo(rotationX, (uint)Abs(duration), easing) ?? Task.FromResult(false);
}
Task SetRotationY(TouchBehavior sender, TouchState touchState, HoverState hoverState, int duration, Easing? easing)
{
var normalRotationY = sender.NormalRotationY;
var pressedRotationY = sender.PressedRotationY;
var hoveredRotationY = sender.HoveredRotationY;
if (Abs(normalRotationY) <= double.Epsilon &&
Abs(pressedRotationY) <= double.Epsilon &&
Abs(hoveredRotationY) <= double.Epsilon)
{
return Task.FromResult(false);
}
var rotationY = normalRotationY;
if (touchState == TouchState.Pressed)
{
rotationY = pressedRotationY;
}
else if (hoverState == HoverState.Hovered && sender.IsSet(TouchBehavior.HoveredRotationYProperty))
{
rotationY = hoveredRotationY;
}
var element = sender.Element;
if (duration <= 0 && element != null)
{
element.AbortAnimations();
element.RotationY = rotationY;
return Task.FromResult(true);
}
return element?.RotateYTo(rotationY, (uint)Abs(duration), easing) ?? Task.FromResult(false);
}
Color? GetBackgroundColor(Color? color)
=> color is not null
? color
: defaultBackgroundColor;
Task RunAnimationTask(TouchBehavior sender, TouchState touchState, HoverState hoverState, CancellationToken token, double? durationMultiplier = null)
{
if (sender.Element == null)
{
return Task.FromResult(false);
}
var duration = sender.AnimationDuration;
var easing = sender.AnimationEasing;
if (touchState == TouchState.Pressed)
{
if (sender.IsSet(TouchBehavior.PressedAnimationDurationProperty))
{
duration = sender.PressedAnimationDuration;
}
if (sender.IsSet(TouchBehavior.PressedAnimationEasingProperty))
{
easing = sender.PressedAnimationEasing;
}
}
else if (hoverState == HoverState.Hovered)
{
if (sender.IsSet(TouchBehavior.HoveredAnimationDurationProperty))
{
duration = sender.HoveredAnimationDuration;
}
if (sender.IsSet(TouchBehavior.HoveredAnimationEasingProperty))
{
easing = sender.HoveredAnimationEasing;
}
}
else
{
if (sender.IsSet(TouchBehavior.NormalAnimationDurationProperty))
{
duration = sender.NormalAnimationDuration;
}
if (sender.IsSet(TouchBehavior.NormalAnimationEasingProperty))
{
easing = sender.NormalAnimationEasing;
}
}
if (durationMultiplier.HasValue)
{
duration = (int)durationMultiplier.Value * duration;
}
duration = Max(duration, 0);
return Task.WhenAll(
animationTaskFactory?.Invoke(sender, touchState, hoverState, duration, easing, token) ?? Task.FromResult(true),
SetBackgroundImageAsync(sender, touchState, hoverState, duration, token),
SetBackgroundColor(sender, touchState, hoverState, duration, easing),
SetOpacity(sender, touchState, hoverState, duration, easing),
SetScale(sender, touchState, hoverState, duration, easing),
SetTranslation(sender, touchState, hoverState, duration, easing),
SetRotation(sender, touchState, hoverState, duration, easing),
SetRotationX(sender, touchState, hoverState, duration, easing),
SetRotationY(sender, touchState, hoverState, duration, easing),
Task.Run(async () =>
{
animationProgress = 0;
animationState = touchState;
for (var progress = animationProgressDelay; progress < duration; progress += animationProgressDelay)
{
await Task.Delay(animationProgressDelay).ConfigureAwait(false);
if (token.IsCancellationRequested)
{
return;
}
animationProgress = (double)progress / duration;
}
animationProgress = 1;
}));
}
public void Dispose()
{
throw new NotImplementedException();
}
}

View File

@ -0,0 +1,18 @@
namespace CommunityToolkit.Maui.Behaviors;
/// <summary>
/// Provides data for the <see cref="TouchBehavior.HoverStateChanged"/> event.
/// </summary>
///
public enum HoverState
{
/// <summary>
/// The pointer is not over the element.
/// </summary>
Normal,
/// <summary>
/// The pointer is over the element.
/// </summary>
Hovered
}

View File

@ -0,0 +1,15 @@
namespace CommunityToolkit.Maui.Behaviors;
/// <summary>
/// Provides data for the <see cref="TouchBehavior.HoverStateChanged"/> event.
/// </summary>
public class HoverStateChangedEventArgs : EventArgs
{
internal HoverStateChangedEventArgs(HoverState state)
=> State = state;
/// <summary>
/// Gets the new <see cref="HoverState"/> of the element.
/// </summary>
public HoverState State { get; }
}

View File

@ -0,0 +1,16 @@
namespace CommunityToolkit.Maui.Behaviors;
/// <summary>
/// Provides data for the <see cref="TouchBehavior.HoverStatusChanged"/> event.
/// </summary>
public enum HoverStatus
{
/// <summary>
/// The pointer has entered the element.
/// </summary>
Entered,
/// <summary>
/// The pointer has exited the element.
/// </summary>
Exited
}

View File

@ -0,0 +1,15 @@
namespace CommunityToolkit.Maui.Behaviors;
/// <summary>
/// Provides data for the <see cref="TouchBehavior.HoverStatusChanged"/> event.
/// </summary>
public class HoverStatusChangedEventArgs : EventArgs
{
internal HoverStatusChangedEventArgs(HoverStatus status)
=> Status = status;
/// <summary>
/// Gets the new <see cref="HoverStatus"/> of the element.
/// </summary>
public HoverStatus Status { get; }
}

View File

@ -0,0 +1,15 @@
namespace CommunityToolkit.Maui.Behaviors;
/// <summary>
/// Provides data for the <see cref="TouchBehavior.LongPressCompleted"/> event.
/// </summary>
public class LongPressCompletedEventArgs : EventArgs
{
internal LongPressCompletedEventArgs(object? parameter)
=> Parameter = parameter;
/// <summary>
/// Gets the parameter of the <see cref="TouchBehavior.LongPressCompleted"/> event.
/// </summary>
public object? Parameter { get; }
}

View File

@ -0,0 +1,19 @@
namespace CommunityToolkit.Maui.Behaviors;
/// <summary>
/// Provides data for the <see cref="TouchBehavior.Completed"/> event.
/// </summary>
public class TouchCompletedEventArgs : EventArgs
{
/// <summary>
/// Initializes a new instance of the <see cref="TouchCompletedEventArgs"/> class.
/// </summary>
internal TouchCompletedEventArgs(object? parameter)
=> Parameter = parameter;
/// <summary>
/// Gets the parameter associated with the touch event.
/// </summary>
public object? Parameter { get; }
}

View File

@ -0,0 +1,17 @@
namespace CommunityToolkit.Maui.Behaviors;
/// <summary>
/// Provides data for the <see cref="TouchBehavior.Completed"/> event.
/// </summary>
public enum TouchInteractionStatus
{
/// <summary>
/// The touch interaction has started.
/// </summary>
Started,
/// <summary>
/// The touch interaction has completed.
/// </summary>
Completed
}

View File

@ -0,0 +1,15 @@
namespace CommunityToolkit.Maui.Behaviors;
/// <summary>
/// Provides data for the <see cref="TouchBehavior.InteractionStatusChanged"/> event.
/// </summary>
public class TouchInteractionStatusChangedEventArgs : EventArgs
{
internal TouchInteractionStatusChangedEventArgs(TouchInteractionStatus touchInteractionStatus)
=> TouchInteractionStatus = touchInteractionStatus;
/// <summary>
/// Gets the current touch interaction status.
/// </summary>
public TouchInteractionStatus TouchInteractionStatus { get; }
}

View File

@ -0,0 +1,17 @@
namespace CommunityToolkit.Maui.Behaviors;
/// <summary>
/// Provides data for the <see cref="TouchBehavior.StatusChanged"/> event.
/// </summary>
public enum TouchState
{
/// <summary>
/// The pointer is not over the element.
/// </summary>
Normal,
/// <summary>
/// The pointer is over the element.
/// </summary>
Pressed
}

View File

@ -0,0 +1,15 @@
namespace CommunityToolkit.Maui.Behaviors;
/// <summary>
/// Provides data for the <see cref="TouchBehavior.StateChanged"/> event.
/// </summary>
public class TouchStateChangedEventArgs : EventArgs
{
internal TouchStateChangedEventArgs(TouchState state)
=> State = state;
/// <summary>
/// Gets the current state of the touch event.
/// </summary>
public TouchState State { get; }
}

View File

@ -0,0 +1,22 @@
namespace CommunityToolkit.Maui.Behaviors;
/// <summary>
/// Provides data for the <see cref="TouchBehavior.StatusChanged"/> event.
/// </summary>
public enum TouchStatus
{
/// <summary>
/// The touch interaction has started.
/// </summary>
Started,
/// <summary>
/// The touch interaction has completed.
/// </summary>
Completed,
/// <summary>
/// The touch interaction has been canceled.
/// </summary>
Canceled
}

View File

@ -0,0 +1,15 @@
namespace CommunityToolkit.Maui.Behaviors;
/// <summary>
/// Provides data for the <see cref="TouchBehavior.StatusChanged"/> event.
/// </summary>
public class TouchStatusChangedEventArgs : EventArgs
{
internal TouchStatusChangedEventArgs(TouchStatus status)
=> Status = status;
/// <summary>
/// Gets the current touch status.
/// </summary>
public TouchStatus Status { get; }
}

View File

@ -0,0 +1,162 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;
namespace CommunityToolkit.Maui.Behaviors;
public partial class TouchBehavior : IDisposable
{
readonly NullReferenceException nre = new(nameof(Element));
internal void RaiseInteractionStatusChanged()
=> weakEventManager.HandleEvent(Element ?? throw nre, new TouchInteractionStatusChangedEventArgs(InteractionStatus), nameof(InteractionStatusChanged));
internal void RaiseStatusChanged()
=> weakEventManager.HandleEvent(Element ?? throw nre, new TouchStatusChangedEventArgs(Status), nameof(StatusChanged));
internal void RaiseHoverStateChanged()
{
weakEventManager.HandleEvent(Element ?? throw nre, new HoverStateChangedEventArgs(HoverState), nameof(HoverStateChanged));
}
internal void RaiseHoverStatusChanged()
=> weakEventManager.HandleEvent(Element ?? throw nre, new HoverStatusChangedEventArgs(HoverStatus), nameof(HoverStatusChanged));
internal void RaiseCompleted()
{
var element = Element;
if (element is null)
{
return;
}
var parameter = CommandParameter;
Command?.Execute(parameter);
weakEventManager.HandleEvent(element, new TouchCompletedEventArgs(parameter), nameof(Completed));
}
internal void RaiseLongPressCompleted()
{
var element = Element;
if (element is null)
{
return;
}
var parameter = LongPressCommandParameter ?? CommandParameter;
LongPressCommand?.Execute(parameter);
weakEventManager.HandleEvent(element, new LongPressCompletedEventArgs(parameter), nameof(LongPressCompleted));
}
internal void ForceUpdateState(bool animated = true)
{
if (element is null)
{
return;
}
gestureManager.ChangeStateAsync(this, animated).ContinueWith(t =>
{
if (t.Exception is null)
{
return;
}
Console.WriteLine($"Failed to force update state, with the {t.Exception} exception and the {t.Exception.Message} message.");
}, TaskContinuationOptions.OnlyOnFaulted);
}
internal void HandleTouch(TouchStatus status)
=> gestureManager.HandleTouch(this, status);
internal void HandleUserInteraction(TouchInteractionStatus interactionStatus)
=> gestureManager.HandleUserInteraction(this, interactionStatus);
internal void HandleHover(HoverStatus status)
=> gestureManager.HandleHover(this, status);
internal void RaiseStateChanged()
{
ForceUpdateState();
HandleLongPress();
weakEventManager.HandleEvent(Element ?? throw nre, new TouchStateChangedEventArgs(State), nameof(StateChanged));
}
internal void HandleLongPress()
{
if (Element is null)
{
return;
}
gestureManager.HandleLongPress(this);
}
void SetChildrenInputTransparent(bool value)
{
if (Element is not Layout layout)
{
return;
}
layout.ChildAdded -= OnLayoutChildAdded;
if (!value)
{
return;
}
layout.InputTransparent = false;
foreach (var view in layout.Children)
{
OnLayoutChildAdded(layout, new ElementEventArgs((View)view));
}
layout.ChildAdded += OnLayoutChildAdded;
}
void OnLayoutChildAdded(object? sender, ElementEventArgs e)
{
if (e.Element is not View view)
{
return;
}
if (!ShouldMakeChildrenInputTransparent)
{
view.InputTransparent = false;
return;
}
view.InputTransparent = IsAvailable;
}
/// <inheritdoc/>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
bool isDisposed;
/// <summary>
/// Dispose the object.
/// </summary>
protected virtual void Dispose(bool disposing)
{
if (isDisposed)
{
return;
}
if (disposing)
{
// free managed resources
gestureManager.Dispose();
}
isDisposed = true;
}
}

View File

@ -0,0 +1,555 @@
#if ANDROID
using Android.Content;
using Android.Content.Res;
using Android.Graphics.Drawables;
using Android.OS;
using Android.Views;
using Android.Views.Accessibility;
using Android.Widget;
using Microsoft.Maui.Controls.Compatibility.Platform.Android;
using static System.OperatingSystem;
using AView = Android.Views.View;
using Color = Android.Graphics.Color;
using MColor = Microsoft.Maui.Graphics.Color;
using MView = Microsoft.Maui.Controls.View;
using PlatformView = Android.Views.View;
using ParentView = Android.Views.IViewParent;
namespace CommunityToolkit.Maui.Behaviors;
public partial class TouchBehavior
{
private static readonly MColor defaultNativeAnimationColor = MColor.FromRgba(128, 128, 128, 64);
private bool isHoverSupported;
private RippleDrawable? ripple;
private AView? rippleView;
private float startX;
private float startY;
private MColor? rippleColor;
private int rippleRadius = -1;
private AView? view = null;
private ViewGroup? viewGroup;
private AccessibilityManager? accessibilityManager;
private AccessibilityListener? accessibilityListener;
private bool IsAccessibilityMode => accessibilityManager is not null
&& accessibilityManager.IsEnabled
&& accessibilityManager.IsTouchExplorationEnabled;
private readonly bool isAtLeastM = IsAndroidVersionAtLeast((int) BuildVersionCodes.M);
internal bool IsCanceled { get; set; }
private bool IsForegroundRippleWithTapGestureRecognizer =>
ripple is not null &&
this.view is not null &&
/*
ripple.IsAlive() &&
this.view.IsAlive() &&
*/
(isAtLeastM ? this.view.Foreground : this.view.Background) == ripple &&
element is MView view &&
view.GestureRecognizers.Any(gesture => gesture is TapGestureRecognizer);
/// <summary>
/// Attaches the behavior to the platform view.
/// </summary>
/// <param name="bindable">Maui Visual Element</param>
/// <param name="platformView">Native View</param>
protected override void OnAttachedTo(VisualElement bindable, AView platformView)
{
Element = bindable;
view = platformView;
//viewGroup = Microsoft.Maui.Platform.ViewExtensions.GetParentOfType<ViewGroup>(platformView);
if (IsDisabled)
{
return;
}
platformView.Touch += OnTouch;
UpdateClickHandler();
accessibilityManager = platformView.Context?.GetSystemService(Context.AccessibilityService) as AccessibilityManager;
if (accessibilityManager is not null)
{
accessibilityListener = new AccessibilityListener(this);
accessibilityManager.AddAccessibilityStateChangeListener(accessibilityListener);
accessibilityManager.AddTouchExplorationStateChangeListener(accessibilityListener);
}
if (!IsAndroidVersionAtLeast((int) BuildVersionCodes.Lollipop) || !NativeAnimation)
{
return;
}
platformView.Clickable = true;
platformView.LongClickable = true;
CreateRipple();
ApplyRipple();
platformView.LayoutChange += OnLayoutChange;
}
/// <summary>
/// Detaches the behavior from the platform view.
/// </summary>
/// <param name="bindable">Maui Visual Element</param>
/// <param name="platformView">Native View</param>
protected override void OnDetachedFrom(VisualElement bindable, AView platformView)
{
element = bindable;
view = platformView;
if (element is null)
{
return;
}
try
{
if (accessibilityManager is not null && accessibilityListener is not null)
{
accessibilityManager.RemoveAccessibilityStateChangeListener(accessibilityListener);
accessibilityManager.RemoveTouchExplorationStateChangeListener(accessibilityListener);
accessibilityListener.Dispose();
accessibilityManager = null;
accessibilityListener = null;
}
RemoveRipple();
if (view is not null)
{
view.LayoutChange -= OnLayoutChange;
view.Touch -= OnTouch;
view.Click -= OnClick;
}
if (rippleView is not null)
{
rippleView.Pressed = false;
viewGroup?.RemoveView(rippleView);
rippleView.Dispose();
rippleView = null;
}
}
catch (ObjectDisposedException)
{
// Suppress exception
}
isHoverSupported = false;
}
private void OnLayoutChange(object? sender, AView.LayoutChangeEventArgs e)
{
if (sender is not AView view || rippleView is null)
{
return;
}
rippleView.Right = view.Width;
rippleView.Bottom = view.Height;
}
private void CreateRipple()
{
RemoveRipple();
var drawable = isAtLeastM && viewGroup is null
? view?.Foreground
: view?.Background;
var isBorderLess = NativeAnimationBorderless;
var isEmptyDrawable = Element is Layout || drawable is null;
var color = NativeAnimationColor;
if (drawable is RippleDrawable rippleDrawable && rippleDrawable.GetConstantState() is Drawable.ConstantState constantState)
{
ripple = (RippleDrawable) constantState.NewDrawable();
}
else
{
var content = isEmptyDrawable || isBorderLess ? null : drawable;
var mask = isEmptyDrawable && !isBorderLess ? new ColorDrawable(Color.White) : null;
ripple = new RippleDrawable(GetColorStateList(color), content, mask);
}
UpdateRipple(color);
}
private void RemoveRipple()
{
if (ripple is null)
{
return;
}
if (view is not null)
{
if (isAtLeastM && view.Foreground == ripple)
{
view.Foreground = null;
}
else if (view.Background == ripple)
{
view.Background = null;
}
}
if (rippleView is not null)
{
rippleView.Foreground = null;
rippleView.Background = null;
}
ripple.Dispose();
ripple = null;
}
private void UpdateRipple(MColor color)
{
if (IsDisabled || (color == rippleColor && NativeAnimationRadius == rippleRadius))
{
return;
}
rippleColor = color;
rippleRadius = NativeAnimationRadius;
ripple?.SetColor(GetColorStateList(color));
if (isAtLeastM && ripple is not null)
{
ripple.Radius = (int) (view?.Context?.Resources?.DisplayMetrics?.Density * NativeAnimationRadius ?? throw new NullReferenceException());
}
}
private ColorStateList GetColorStateList(MColor? color)
{
var animationColor = color;
animationColor ??= defaultNativeAnimationColor;
return new ColorStateList(
new[] {Array.Empty<int>()},
new[] {(int) animationColor.ToAndroid()});
}
private void UpdateClickHandler()
{
if (view is null /* || !view.IsAlive()*/)
{
return;
}
view.Click -= OnClick;
if (IsAccessibilityMode || (IsAvailable && (element?.IsEnabled ?? false)))
{
view.Click += OnClick;
return;
}
}
private void ApplyRipple()
{
if (ripple is null)
{
return;
}
var isBorderless = NativeAnimationBorderless;
if (viewGroup is null && view is not null)
{
if (IsAndroidVersionAtLeast((int) BuildVersionCodes.M))
{
view.Foreground = ripple;
}
else
{
view.Background = ripple;
}
return;
}
if (rippleView is null)
{
rippleView = new FrameLayout(viewGroup?.Context ?? view?.Context ?? throw new NullReferenceException())
{
LayoutParameters = new ViewGroup.LayoutParams(-1, -1),
Clickable = false,
Focusable = false,
Enabled = false
};
viewGroup?.AddView(rippleView);
rippleView.BringToFront();
}
viewGroup?.SetClipChildren(!isBorderless);
if (isBorderless)
{
rippleView.Background = null;
rippleView.Foreground = ripple;
}
else
{
rippleView.Foreground = null;
rippleView.Background = ripple;
}
}
private void OnClick(object? sender, EventArgs args)
{
if (IsDisabled)
{
return;
}
if (!IsAccessibilityMode)
{
return;
}
IsCanceled = false;
HandleEnd(TouchStatus.Completed);
}
private void HandleEnd(TouchStatus status)
{
if (IsCanceled)
{
return;
}
IsCanceled = true;
if (DisallowTouchThreshold > 0)
{
viewGroup?.Parent?.RequestDisallowInterceptTouchEvent(false);
}
HandleTouch(status);
HandleUserInteraction(TouchInteractionStatus.Completed);
EndRipple();
}
private void EndRipple()
{
if (IsDisabled)
{
return;
}
if (rippleView != null)
{
if (rippleView.Pressed)
{
rippleView.Pressed = false;
rippleView.Enabled = false;
}
}
else if (IsForegroundRippleWithTapGestureRecognizer)
{
if (view?.Pressed ?? false)
{
view.Pressed = false;
}
}
}
private void OnTouch(object? sender, AView.TouchEventArgs e)
{
e.Handled = false;
if (IsDisabled)
{
return;
}
if (IsAccessibilityMode)
{
return;
}
switch (e.Event?.ActionMasked)
{
case MotionEventActions.Down:
OnTouchDown(e);
break;
case MotionEventActions.Up:
OnTouchUp();
break;
case MotionEventActions.Cancel:
OnTouchCancel();
break;
case MotionEventActions.Move:
OnTouchMove(sender, e);
break;
case MotionEventActions.HoverEnter:
OnHoverEnter();
break;
case MotionEventActions.HoverExit:
OnHoverExit();
break;
}
}
private void OnTouchDown(AView.TouchEventArgs e)
{
_ = e.Event ?? throw new NullReferenceException();
IsCanceled = false;
startX = e.Event.GetX();
startY = e.Event.GetY();
HandleUserInteraction(TouchInteractionStatus.Started);
HandleTouch(TouchStatus.Started);
StartRipple(e.Event.GetX(), e.Event.GetY());
if (DisallowTouchThreshold > 0)
{
viewGroup?.Parent?.RequestDisallowInterceptTouchEvent(true);
}
}
private void OnTouchUp()
{
HandleEnd(Status == TouchStatus.Started ? TouchStatus.Completed : TouchStatus.Canceled);
}
private void OnTouchCancel()
{
HandleEnd(TouchStatus.Canceled);
}
private void OnTouchMove(object? sender, AView.TouchEventArgs e)
{
if (IsCanceled || e.Event == null)
{
return;
}
var diffX = Math.Abs(e.Event.GetX() - startX) / this.view?.Context?.Resources?.DisplayMetrics?.Density ?? throw new NullReferenceException();
var diffY = Math.Abs(e.Event.GetY() - startY) / this.view?.Context?.Resources?.DisplayMetrics?.Density ?? throw new NullReferenceException();
var maxDiff = Math.Max(diffX, diffY);
var disallowTouchThreshold = DisallowTouchThreshold;
if (disallowTouchThreshold > 0 && maxDiff > disallowTouchThreshold)
{
HandleEnd(TouchStatus.Canceled);
return;
}
if (sender is not AView view)
{
return;
}
var screenPointerCoords = new Point(view.Left + e.Event.GetX(), view.Top + e.Event.GetY());
var viewRect = new Rect(view.Left, view.Top, view.Right - view.Left, view.Bottom - view.Top);
var status = viewRect.Contains(screenPointerCoords) ? TouchStatus.Started : TouchStatus.Canceled;
if (isHoverSupported && ((status == TouchStatus.Canceled && HoverStatus == HoverStatus.Entered)
|| (status == TouchStatus.Started && HoverStatus == HoverStatus.Exited)))
{
HandleHover(status == TouchStatus.Started ? HoverStatus.Entered : HoverStatus.Exited);
}
if (Status != status)
{
HandleTouch(status);
if (status == TouchStatus.Started)
{
StartRipple(e.Event.GetX(), e.Event.GetY());
}
if (status == TouchStatus.Canceled)
{
EndRipple();
}
}
}
private void OnHoverEnter()
{
isHoverSupported = true;
HandleHover(HoverStatus.Entered);
}
private void OnHoverExit()
{
isHoverSupported = true;
HandleHover(HoverStatus.Exited);
}
private void StartRipple(float x, float y)
{
if (IsDisabled || !NativeAnimation)
{
return;
}
if (CanExecute)
{
UpdateRipple(NativeAnimationColor);
if (rippleView is not null)
{
rippleView.Enabled = true;
rippleView.BringToFront();
ripple?.SetHotspot(x, y);
rippleView.Pressed = true;
}
else if (IsForegroundRippleWithTapGestureRecognizer && view is not null)
{
ripple?.SetHotspot(x, y);
view.Pressed = true;
}
}
else if (rippleView is null)
{
UpdateRipple(Colors.Transparent);
}
}
private sealed class AccessibilityListener : Java.Lang.Object,
AccessibilityManager.IAccessibilityStateChangeListener,
AccessibilityManager.ITouchExplorationStateChangeListener
{
private TouchBehavior? platformTouchEffect;
internal AccessibilityListener(TouchBehavior platformTouchEffect)
{
this.platformTouchEffect = platformTouchEffect;
}
public void OnAccessibilityStateChanged(bool enabled)
{
platformTouchEffect?.UpdateClickHandler();
}
public void OnTouchExplorationStateChanged(bool enabled)
{
platformTouchEffect?.UpdateClickHandler();
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
platformTouchEffect = null;
}
base.Dispose(disposing);
}
}
}
#endif

View File

@ -0,0 +1,308 @@
#if IOS
using AsyncAwaitBestPractices;
using CoreGraphics;
using Foundation;
using Microsoft.Maui.Controls.Compatibility.Platform.iOS;
using Microsoft.Maui.Platform;
using UIKit;
namespace CommunityToolkit.Maui.Behaviors;
public partial class TouchBehavior
{
private UIGestureRecognizer? touchGesture;
private UIGestureRecognizer? hoverGesture;
/// <summary>
/// Attaches the behavior to the platform view.
/// </summary>
/// <param name="bindable">Maui Visual Element</param>
/// <param name="platformView">Native View</param>
protected override void OnAttachedTo(VisualElement bindable, UIView platformView)
{
Element = bindable;
touchGesture = new TouchUITapGestureRecognizer(this);
if (((platformView as IVisualNativeElementRenderer)?.Control ?? platformView) is UIButton button)
{
button.AllTouchEvents += PreventButtonHighlight;
((TouchUITapGestureRecognizer) touchGesture).IsButton = true;
}
platformView.AddGestureRecognizer(touchGesture);
if (UIDevice.CurrentDevice.CheckSystemVersion(13, 0))
{
hoverGesture = new UIHoverGestureRecognizer(OnHover);
platformView.AddGestureRecognizer(hoverGesture);
}
platformView.UserInteractionEnabled = true;
}
/// <summary>
/// Detaches the behavior from the platform view.
/// </summary>
/// <param name="bindable">Maui Visual Element</param>
/// <param name="platformView">Native View</param>
protected override void OnDetachedFrom(VisualElement bindable, UIView platformView)
{
if (((platformView as IVisualNativeElementRenderer)?.Control ?? platformView) is UIButton button)
{
button.AllTouchEvents -= PreventButtonHighlight;
}
if (touchGesture != null)
{
platformView?.RemoveGestureRecognizer(touchGesture);
touchGesture?.Dispose();
touchGesture = null;
}
if (hoverGesture != null)
{
platformView?.RemoveGestureRecognizer(hoverGesture);
hoverGesture?.Dispose();
hoverGesture = null;
}
Element = null;
}
private void OnHover()
{
if (IsDisabled)
{
return;
}
switch (hoverGesture?.State)
{
case UIGestureRecognizerState.Began:
case UIGestureRecognizerState.Changed:
HandleHover(HoverStatus.Entered);
break;
case UIGestureRecognizerState.Ended:
HandleHover(HoverStatus.Exited);
break;
}
}
private void PreventButtonHighlight(object? sender, EventArgs args)
{
if (sender is not UIButton button)
{
throw new ArgumentException($"{nameof(sender)} must be Type {nameof(UIButton)}", nameof(sender));
}
button.Highlighted = false;
}
}
internal sealed class TouchUITapGestureRecognizer : UIGestureRecognizer
{
private TouchBehavior behavior;
private float? defaultRadius;
private float? defaultShadowRadius;
private float? defaultShadowOpacity;
private CGPoint? startPoint;
public TouchUITapGestureRecognizer(TouchBehavior behavior)
{
this.behavior = behavior;
CancelsTouchesInView = false;
Delegate = new TouchUITapGestureRecognizerDelegate();
}
public bool IsCanceled { get; set; } = true;
public bool IsButton { get; set; }
public override void TouchesBegan(NSSet touches, UIEvent evt)
{
if (behavior?.IsDisabled ?? true)
{
return;
}
IsCanceled = false;
startPoint = GetTouchPoint(touches);
HandleTouch(TouchStatus.Started, TouchInteractionStatus.Started).SafeFireAndForget();
base.TouchesBegan(touches, evt);
}
public override void TouchesEnded(NSSet touches, UIEvent evt)
{
if (behavior?.IsDisabled ?? true)
{
return;
}
HandleTouch(behavior?.Status == TouchStatus.Started ? TouchStatus.Completed : TouchStatus.Canceled, TouchInteractionStatus.Completed).SafeFireAndForget();
IsCanceled = true;
base.TouchesEnded(touches, evt);
}
public override void TouchesCancelled(NSSet touches, UIEvent evt)
{
if (behavior?.IsDisabled ?? true)
{
return;
}
HandleTouch(TouchStatus.Canceled, TouchInteractionStatus.Completed).SafeFireAndForget();
IsCanceled = true;
base.TouchesCancelled(touches, evt);
}
public override void TouchesMoved(NSSet touches, UIEvent evt)
{
if (behavior?.IsDisabled ?? true)
{
return;
}
var disallowTouchThreshold = behavior.DisallowTouchThreshold;
var point = GetTouchPoint(touches);
if (point != null && startPoint != null && disallowTouchThreshold > 0)
{
var diffX = Math.Abs(point.Value.X - startPoint.Value.X);
var diffY = Math.Abs(point.Value.Y - startPoint.Value.Y);
var maxDiff = Math.Max(diffX, diffY);
if (maxDiff > disallowTouchThreshold)
{
HandleTouch(TouchStatus.Canceled, TouchInteractionStatus.Completed).SafeFireAndForget();
IsCanceled = true;
base.TouchesMoved(touches, evt);
return;
}
}
var status = point != null && View?.Bounds.Contains(point.Value) is true
? TouchStatus.Started
: TouchStatus.Canceled;
if (behavior?.Status != status)
{
HandleTouch(status).SafeFireAndForget();
}
if (status == TouchStatus.Canceled)
{
IsCanceled = true;
}
base.TouchesMoved(touches, evt);
}
public async Task HandleTouch(TouchStatus status, TouchInteractionStatus? interactionStatus = null)
{
if (IsCanceled || behavior == null)
{
return;
}
if (behavior?.IsDisabled ?? true)
{
return;
}
var canExecuteAction = behavior.CanExecute;
if (interactionStatus == TouchInteractionStatus.Started)
{
behavior?.HandleUserInteraction(TouchInteractionStatus.Started);
interactionStatus = null;
}
behavior?.HandleTouch(status);
if (interactionStatus.HasValue)
{
behavior?.HandleUserInteraction(interactionStatus.Value);
}
if (behavior == null || behavior.Element is null || (!behavior.NativeAnimation && !IsButton) || (!canExecuteAction && status == TouchStatus.Started))
{
return;
}
var color = behavior.NativeAnimationColor;
var radius = behavior.NativeAnimationRadius;
var shadowRadius = behavior.NativeAnimationShadowRadius;
var isStarted = status == TouchStatus.Started;
defaultRadius = (float?) (defaultRadius ?? View.Layer.CornerRadius);
defaultShadowRadius = (float?) (defaultShadowRadius ?? View.Layer.ShadowRadius);
defaultShadowOpacity ??= View.Layer.ShadowOpacity;
var tcs = new TaskCompletionSource<UIViewAnimatingPosition>();
UIViewPropertyAnimator.CreateRunningPropertyAnimator(.2, 0, UIViewAnimationOptions.AllowUserInteraction,
() =>
{
if (color == default(Color))
{
View.Layer.Opacity = isStarted ? 0.5f : (float) behavior.Element.Opacity;
}
else
{
View.Layer.BackgroundColor = (isStarted ? color : behavior.Element.BackgroundColor).ToCGColor();
}
View.Layer.CornerRadius = isStarted ? radius : defaultRadius.GetValueOrDefault();
if (shadowRadius >= 0)
{
View.Layer.ShadowRadius = isStarted ? shadowRadius : defaultShadowRadius.GetValueOrDefault();
View.Layer.ShadowOpacity = isStarted ? 0.7f : defaultShadowOpacity.GetValueOrDefault();
}
}, endPos => tcs.SetResult(endPos));
await tcs.Task;
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
Delegate.Dispose();
}
base.Dispose(disposing);
}
private CGPoint? GetTouchPoint(NSSet touches)
{
return (touches?.AnyObject as UITouch)?.LocationInView(View);
}
private class TouchUITapGestureRecognizerDelegate : UIGestureRecognizerDelegate
{
public override bool ShouldRecognizeSimultaneously(UIGestureRecognizer gestureRecognizer, UIGestureRecognizer otherGestureRecognizer)
{
if (gestureRecognizer is TouchUITapGestureRecognizer touchGesture && otherGestureRecognizer is UIPanGestureRecognizer &&
otherGestureRecognizer.State == UIGestureRecognizerState.Began)
{
touchGesture.HandleTouch(TouchStatus.Canceled, TouchInteractionStatus.Completed).SafeFireAndForget();
touchGesture.IsCanceled = true;
}
return true;
}
public override bool ShouldReceiveTouch(UIGestureRecognizer recognizer, UITouch touch)
{
if (recognizer.View.IsDescendantOfView(touch.View))
{
return true;
}
return recognizer.View.Subviews.Any(view => view == touch.View);
}
}
}
#endif

File diff suppressed because it is too large Load Diff

View File

@ -172,7 +172,7 @@ namespace Bit.App.Controls
private async Task LongPressAccountAsync(AccountViewCellViewModel item)
{
if (!LongPressAccountEnabled || !item.IsAccount)
if (!LongPressAccountEnabled || item == null || !item.IsAccount)
{
return;
}

View File

@ -1,18 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<ViewCell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:xct="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
x:Class="Bit.App.Controls.AccountViewCell"
xmlns:controls="clr-namespace:Bit.App.Controls"
xmlns:u="clr-namespace:Bit.App.Utilities"
xmlns:core="clr-namespace:Bit.Core"
xmlns:core="clr-namespace:Bit.Core"
xmlns:behaviors="clr-namespace:CommunityToolkit.Maui.Behaviors"
x:Name="_accountView"
x:DataType="controls:AccountViewCellViewModel">
<!--TODO: [MAUI-Migration] add long press ( https://github.com/CommunityToolkit/Maui/issues/86 )
xct:TouchEffect.LongPressCommand="{Binding LongPressAccountCommand, Source={x:Reference _accountView}}"
xct:TouchEffect.LongPressCommandParameter="{Binding .}"-->
<Grid RowSpacing="0"
ColumnSpacing="0">
<Grid.Behaviors>
<!--TODO: [MAUI-Migration] Currently using a "copied" implementation from the github issue in the link until they add this to the Community Toolkit ( https://github.com/CommunityToolkit/Maui/issues/86 ) -->
<behaviors:TouchBehavior NativeAnimation="True"
LongPressCommand="{Binding LongPressAccountCommand, Source={x:Reference _accountView}}"
LongPressCommandParameter="{Binding .BindingContext, Source={x:Reference _accountView}}" />
</Grid.Behaviors>
<Grid.GestureRecognizers>
<TapGestureRecognizer Command="{Binding SelectAccountCommand, Source={x:Reference _accountView}}" CommandParameter="{Binding .}" />
</Grid.GestureRecognizers>

View File

@ -1,9 +1,21 @@
using Microsoft.Maui.Controls;
using Microsoft.Maui;
using CommunityToolkit.Maui.Behaviors;
namespace Bit.App.Controls
{
public class ExtendedGrid : Grid
{
public ExtendedGrid()
{
// Add Android Ripple effect. Eventually we should be able to replace this with the Maui Community Toolkit implementation. (https://github.com/CommunityToolkit/Maui/issues/86)
// [MAUI-Migration] When this TouchBehavior is replaced we can delete the existing TouchBehavior support files (which is all the files and folders inside "Core.Behaviors.PlatformBehaviors.MCTTouch.*")
if (DeviceInfo.Platform == DevicePlatform.Android)
{
var touchBehavior = new TouchBehavior()
{
NativeAnimation = true
};
Behaviors.Add(touchBehavior);
}
}
}
}

View File

@ -1,9 +1,21 @@
using Microsoft.Maui.Controls;
using Microsoft.Maui;
using CommunityToolkit.Maui.Behaviors;
namespace Bit.App.Controls
{
public class ExtendedStackLayout : StackLayout
{
public ExtendedStackLayout()
{
// Add Android Ripple effect. Eventually we should be able to replace this with the Maui Community Toolkit implementation. (https://github.com/CommunityToolkit/Maui/issues/86)
// [MAUI-Migration] When this TouchBehavior is replaced we can delete the existing TouchBehavior support files (which is all the files and folders inside "Core.Behaviors.PlatformBehaviors.MCTTouch.*")
if (DeviceInfo.Platform == DevicePlatform.Android)
{
var touchBehavior = new TouchBehavior()
{
NativeAnimation = true
};
Behaviors.Add(touchBehavior);
}
}
}
}

View File

@ -248,7 +248,7 @@
AutomationId="SendHideTextByDefaultToggle" />
</StackLayout>
</StackLayout>
<!--TODO: [MAUI-Migration] xct:TouchEffect.Command="{Binding ToggleOptionsCommand}" for below ( https://github.com/CommunityToolkit/Maui/issues/86 ) -->
<!--TODO: [MAUI-Migration] xct:TouchEffect.Command="{Binding ToggleOptionsCommand}" for below ( https://github.com/CommunityToolkit/Maui/issues/86 ) -->
<StackLayout
Orientation="Horizontal"
Spacing="0"