diff --git a/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Extensions/VisualElementExtensions.cs b/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Extensions/VisualElementExtensions.cs new file mode 100644 index 000000000..2532aa844 --- /dev/null +++ b/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Extensions/VisualElementExtensions.cs @@ -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(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 ColorTo(this VisualElement element, Color color, uint length = 250u, Easing? easing = null) + { + _ = element ?? throw new ArgumentNullException(nameof(element)); + + var animationCompletionSource = new TaskCompletionSource(); + + 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); + } + } +} diff --git a/src/Core/Behaviors/PlatformBehaviors/MCTTouch/GestureManager.cs b/src/Core/Behaviors/PlatformBehaviors/MCTTouch/GestureManager.cs new file mode 100644 index 000000000..1d02e85d7 --- /dev/null +++ b/src/Core/Behaviors/PlatformBehaviors/MCTTouch/GestureManager.cs @@ -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? 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? 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(); + + 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 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 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(); + 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(); + } +} diff --git a/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Primitives/HoverState.shared.cs b/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Primitives/HoverState.shared.cs new file mode 100644 index 000000000..66cc64a87 --- /dev/null +++ b/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Primitives/HoverState.shared.cs @@ -0,0 +1,18 @@ +namespace CommunityToolkit.Maui.Behaviors; + +/// +/// Provides data for the event. +/// +/// +public enum HoverState +{ + /// + /// The pointer is not over the element. + /// + Normal, + /// + /// The pointer is over the element. + /// + Hovered +} + diff --git a/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Primitives/HoverStateChangedEventArgs.shared.cs b/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Primitives/HoverStateChangedEventArgs.shared.cs new file mode 100644 index 000000000..9cd4f94f7 --- /dev/null +++ b/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Primitives/HoverStateChangedEventArgs.shared.cs @@ -0,0 +1,15 @@ +namespace CommunityToolkit.Maui.Behaviors; + +/// +/// Provides data for the event. +/// +public class HoverStateChangedEventArgs : EventArgs +{ + internal HoverStateChangedEventArgs(HoverState state) + => State = state; + + /// + /// Gets the new of the element. + /// + public HoverState State { get; } +} \ No newline at end of file diff --git a/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Primitives/HoverStatus.shared.cs b/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Primitives/HoverStatus.shared.cs new file mode 100644 index 000000000..4d6ee4a3d --- /dev/null +++ b/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Primitives/HoverStatus.shared.cs @@ -0,0 +1,16 @@ +namespace CommunityToolkit.Maui.Behaviors; + +/// +/// Provides data for the event. +/// +public enum HoverStatus +{ + /// + /// The pointer has entered the element. + /// + Entered, + /// + /// The pointer has exited the element. + /// + Exited +} \ No newline at end of file diff --git a/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Primitives/HoverStatusChangedEventArgs.shared.cs b/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Primitives/HoverStatusChangedEventArgs.shared.cs new file mode 100644 index 000000000..8ea7c02be --- /dev/null +++ b/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Primitives/HoverStatusChangedEventArgs.shared.cs @@ -0,0 +1,15 @@ +namespace CommunityToolkit.Maui.Behaviors; + +/// +/// Provides data for the event. +/// +public class HoverStatusChangedEventArgs : EventArgs +{ + internal HoverStatusChangedEventArgs(HoverStatus status) + => Status = status; + + /// + /// Gets the new of the element. + /// + public HoverStatus Status { get; } +} \ No newline at end of file diff --git a/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Primitives/LongPressCompletedEventArgs.shared.cs b/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Primitives/LongPressCompletedEventArgs.shared.cs new file mode 100644 index 000000000..9d65c4734 --- /dev/null +++ b/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Primitives/LongPressCompletedEventArgs.shared.cs @@ -0,0 +1,15 @@ +namespace CommunityToolkit.Maui.Behaviors; + +/// +/// Provides data for the event. +/// +public class LongPressCompletedEventArgs : EventArgs +{ + internal LongPressCompletedEventArgs(object? parameter) + => Parameter = parameter; + + /// + /// Gets the parameter of the event. + /// + public object? Parameter { get; } +} \ No newline at end of file diff --git a/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Primitives/TouchCompletedEventArgs.shared.cs b/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Primitives/TouchCompletedEventArgs.shared.cs new file mode 100644 index 000000000..eb3533bf6 --- /dev/null +++ b/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Primitives/TouchCompletedEventArgs.shared.cs @@ -0,0 +1,19 @@ +namespace CommunityToolkit.Maui.Behaviors; + +/// +/// Provides data for the event. +/// +public class TouchCompletedEventArgs : EventArgs + +{ + /// + /// Initializes a new instance of the class. + /// + internal TouchCompletedEventArgs(object? parameter) + => Parameter = parameter; + + /// + /// Gets the parameter associated with the touch event. + /// + public object? Parameter { get; } +} \ No newline at end of file diff --git a/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Primitives/TouchInteractionStatus.shared.cs b/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Primitives/TouchInteractionStatus.shared.cs new file mode 100644 index 000000000..bbf9e1eca --- /dev/null +++ b/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Primitives/TouchInteractionStatus.shared.cs @@ -0,0 +1,17 @@ +namespace CommunityToolkit.Maui.Behaviors; + +/// +/// Provides data for the event. +/// +public enum TouchInteractionStatus +{ + /// + /// The touch interaction has started. + /// + Started, + + /// + /// The touch interaction has completed. + /// + Completed +} \ No newline at end of file diff --git a/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Primitives/TouchInteractionStatusChangedEventArgs.shared.cs b/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Primitives/TouchInteractionStatusChangedEventArgs.shared.cs new file mode 100644 index 000000000..952762c49 --- /dev/null +++ b/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Primitives/TouchInteractionStatusChangedEventArgs.shared.cs @@ -0,0 +1,15 @@ +namespace CommunityToolkit.Maui.Behaviors; + +/// +/// Provides data for the event. +/// +public class TouchInteractionStatusChangedEventArgs : EventArgs +{ + internal TouchInteractionStatusChangedEventArgs(TouchInteractionStatus touchInteractionStatus) + => TouchInteractionStatus = touchInteractionStatus; + + /// + /// Gets the current touch interaction status. + /// + public TouchInteractionStatus TouchInteractionStatus { get; } +} \ No newline at end of file diff --git a/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Primitives/TouchState.shared.cs b/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Primitives/TouchState.shared.cs new file mode 100644 index 000000000..e3a25ab22 --- /dev/null +++ b/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Primitives/TouchState.shared.cs @@ -0,0 +1,17 @@ +namespace CommunityToolkit.Maui.Behaviors; + +/// +/// Provides data for the event. +/// +public enum TouchState +{ + /// + /// The pointer is not over the element. + /// + Normal, + + /// + /// The pointer is over the element. + /// + Pressed +} \ No newline at end of file diff --git a/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Primitives/TouchStateChangedEventArgs.shared.cs b/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Primitives/TouchStateChangedEventArgs.shared.cs new file mode 100644 index 000000000..28366ef03 --- /dev/null +++ b/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Primitives/TouchStateChangedEventArgs.shared.cs @@ -0,0 +1,15 @@ +namespace CommunityToolkit.Maui.Behaviors; + +/// +/// Provides data for the event. +/// +public class TouchStateChangedEventArgs : EventArgs +{ + internal TouchStateChangedEventArgs(TouchState state) + => State = state; + + /// + /// Gets the current state of the touch event. + /// + public TouchState State { get; } +} \ No newline at end of file diff --git a/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Primitives/TouchStatus.shared.cs b/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Primitives/TouchStatus.shared.cs new file mode 100644 index 000000000..3d1d637c7 --- /dev/null +++ b/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Primitives/TouchStatus.shared.cs @@ -0,0 +1,22 @@ +namespace CommunityToolkit.Maui.Behaviors; + +/// +/// Provides data for the event. +/// +public enum TouchStatus +{ + /// + /// The touch interaction has started. + /// + Started, + + /// + /// The touch interaction has completed. + /// + Completed, + + /// + /// The touch interaction has been canceled. + /// + Canceled +} \ No newline at end of file diff --git a/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Primitives/TouchStatusChangedEventArgs.shared.cs b/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Primitives/TouchStatusChangedEventArgs.shared.cs new file mode 100644 index 000000000..19f2bc031 --- /dev/null +++ b/src/Core/Behaviors/PlatformBehaviors/MCTTouch/Primitives/TouchStatusChangedEventArgs.shared.cs @@ -0,0 +1,15 @@ +namespace CommunityToolkit.Maui.Behaviors; + +/// +/// Provides data for the event. +/// +public class TouchStatusChangedEventArgs : EventArgs +{ + internal TouchStatusChangedEventArgs(TouchStatus status) + => Status = status; + + /// + /// Gets the current touch status. + /// + public TouchStatus Status { get; } +} \ No newline at end of file diff --git a/src/Core/Behaviors/PlatformBehaviors/MCTTouch/TouchBehavior.Methods.shared.cs b/src/Core/Behaviors/PlatformBehaviors/MCTTouch/TouchBehavior.Methods.shared.cs new file mode 100644 index 000000000..2215c0cc5 --- /dev/null +++ b/src/Core/Behaviors/PlatformBehaviors/MCTTouch/TouchBehavior.Methods.shared.cs @@ -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; + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + bool isDisposed; + + /// + /// Dispose the object. + /// + protected virtual void Dispose(bool disposing) + { + if (isDisposed) + { + return; + } + + if (disposing) + { + // free managed resources + gestureManager.Dispose(); + } + + isDisposed = true; + } +} diff --git a/src/Core/Behaviors/PlatformBehaviors/MCTTouch/TouchBehavior.android.cs b/src/Core/Behaviors/PlatformBehaviors/MCTTouch/TouchBehavior.android.cs new file mode 100644 index 000000000..99b7285f8 --- /dev/null +++ b/src/Core/Behaviors/PlatformBehaviors/MCTTouch/TouchBehavior.android.cs @@ -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); + + /// + /// Attaches the behavior to the platform view. + /// + /// Maui Visual Element + /// Native View + protected override void OnAttachedTo(VisualElement bindable, AView platformView) + { + Element = bindable; + view = platformView; + //viewGroup = Microsoft.Maui.Platform.ViewExtensions.GetParentOfType(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; + } + + /// + /// Detaches the behavior from the platform view. + /// + /// Maui Visual Element + /// Native View + 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()}, + 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 diff --git a/src/Core/Behaviors/PlatformBehaviors/MCTTouch/TouchBehavior.macios.cs b/src/Core/Behaviors/PlatformBehaviors/MCTTouch/TouchBehavior.macios.cs new file mode 100644 index 000000000..f96615af4 --- /dev/null +++ b/src/Core/Behaviors/PlatformBehaviors/MCTTouch/TouchBehavior.macios.cs @@ -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; + + /// + /// Attaches the behavior to the platform view. + /// + /// Maui Visual Element + /// Native View + 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; + } + + /// + /// Detaches the behavior from the platform view. + /// + /// Maui Visual Element + /// Native View + 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(); + 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 diff --git a/src/Core/Behaviors/PlatformBehaviors/MCTTouch/TouchBehavior.shared.cs b/src/Core/Behaviors/PlatformBehaviors/MCTTouch/TouchBehavior.shared.cs new file mode 100644 index 000000000..c6e5bf092 --- /dev/null +++ b/src/Core/Behaviors/PlatformBehaviors/MCTTouch/TouchBehavior.shared.cs @@ -0,0 +1,1208 @@ +using System.Windows.Input; + +namespace CommunityToolkit.Maui.Behaviors; + +/// +/// +/// +public partial class TouchBehavior : PlatformBehavior +{ + /// + /// The visual state for when the touch is unpressed. + /// + public const string UnpressedVisualState = "Unpressed"; + + /// + /// The visual state for when the touch is pressed. + /// + public const string PressedVisualState = "Pressed"; + + /// + /// The visual state for when the touch is hovered. + /// + public const string HoveredVisualState = "Hovered"; + + readonly GestureManager gestureManager = new(); + + VisualElement? element; + + internal VisualElement? Element + { + get => element; + set + { + if (element != null) + { + IsUsed = false; + gestureManager.Reset(); + SetChildrenInputTransparent(false); + } + gestureManager.AbortAnimations(this); + element = value; + if (value != null) + { + SetChildrenInputTransparent(ShouldMakeChildrenInputTransparent); + IsUsed = true; + ForceUpdateState(); + } + } + } + + readonly WeakEventManager weakEventManager = new(); + + internal bool IsDisabled { get; set; } + + internal bool IsUsed { get; set; } + + internal bool IsAutoGenerated { get; set; } + + + /// + /// Occurs when the touch status changes. + /// + public event EventHandler StatusChanged + { + add => weakEventManager.AddEventHandler(value); + remove => weakEventManager.RemoveEventHandler(value); + } + + /// + /// Occurs when the touch state changes. + /// + public event EventHandler StateChanged + { + add => weakEventManager.AddEventHandler(value); + remove => weakEventManager.RemoveEventHandler(value); + } + + /// + /// Occurs when the touch interaction status changes. + /// + public event EventHandler InteractionStatusChanged + { + add => weakEventManager.AddEventHandler(value); + remove => weakEventManager.RemoveEventHandler(value); + } + + /// + /// Occurs when the hover status changes. + /// + public event EventHandler HoverStatusChanged + { + add => weakEventManager.AddEventHandler(value); + remove => weakEventManager.RemoveEventHandler(value); + } + + /// + /// Occurs when the hover state changes. + /// + public event EventHandler HoverStateChanged + { + add => weakEventManager.AddEventHandler(value); + remove => weakEventManager.RemoveEventHandler(value); + } + + /// + /// Occurs when a touch gesture is completed. + /// + public event EventHandler Completed + { + add => weakEventManager.AddEventHandler(value); + remove => weakEventManager.RemoveEventHandler(value); + } + + /// + /// Occurs when a long press gesture is completed. + /// + public event EventHandler LongPressCompleted + { + add => weakEventManager.AddEventHandler(value); + remove => weakEventManager.RemoveEventHandler(value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty IsAvailableProperty = BindableProperty.Create( + nameof(IsAvailable), + typeof(bool), + typeof(TouchBehavior), + true); + + /// + /// Gets or sets a value indicating whether the touch is available. + /// + public bool IsAvailable + { + get => (bool)GetValue(IsAvailableProperty); + set => SetValue(IsAvailableProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty ShouldMakeChildrenInputTransparentProperty = BindableProperty.Create( + nameof(ShouldMakeChildrenInputTransparent), + typeof(bool), + typeof(TouchBehavior), + true); + + /// + /// Gets or sets a value indicating whether the children of the element should be made input transparent. + /// + public bool ShouldMakeChildrenInputTransparent + { + get => (bool)GetValue(ShouldMakeChildrenInputTransparentProperty); + set => SetValue(ShouldMakeChildrenInputTransparentProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty CommandProperty = BindableProperty.Create( + nameof(Command), + typeof(ICommand), + typeof(TouchBehavior), + default(ICommand)); + + /// + /// Gets or sets the command to invoke when the user has completed a touch gesture. + /// + public ICommand Command + { + get => (ICommand)GetValue(CommandProperty); + set => SetValue(CommandProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty LongPressCommandProperty = BindableProperty.Create( + nameof(LongPressCommand), + typeof(ICommand), + typeof(TouchBehavior), + default(ICommand)); + + /// + /// Gets or sets the command to invoke when the user has completed a long press. + /// + public ICommand LongPressCommand + { + get => (ICommand)GetValue(LongPressCommandProperty); + set => SetValue(LongPressCommandProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty CommandParameterProperty = BindableProperty.Create( + nameof(CommandParameter), + typeof(object), + typeof(TouchBehavior), + default); + + /// + /// Gets or sets the parameter to pass to the property. + /// + public object CommandParameter + { + get => GetValue(CommandParameterProperty); + set => SetValue(CommandParameterProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty LongPressCommandParameterProperty = BindableProperty.Create( + nameof(LongPressCommandParameter), + typeof(object), + typeof(TouchBehavior), + default); + + /// + /// Gets or sets the parameter to pass to the property. + /// + public object LongPressCommandParameter + { + get => GetValue(LongPressCommandParameterProperty); + set => SetValue(LongPressCommandParameterProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty LongPressDurationProperty = BindableProperty.Create( + nameof(LongPressDuration), + typeof(int), + typeof(TouchBehavior), + 500); + + /// + /// Gets or sets the duration of the long press in milliseconds. + /// + public int LongPressDuration + { + get => (int)GetValue(LongPressDurationProperty); + set => SetValue(LongPressDurationProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty StatusProperty = BindableProperty.Create( + nameof(Status), + typeof(TouchStatus), + typeof(TouchBehavior), + TouchStatus.Completed, + BindingMode.OneWayToSource); + + /// + /// Gets the current status of the touch. + /// + public TouchStatus Status + { + get => (TouchStatus)GetValue(StatusProperty); + set => SetValue(StatusProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty StateProperty = BindableProperty.Create( + nameof(State), + typeof(TouchState), + typeof(TouchBehavior), + TouchState.Normal, + BindingMode.OneWayToSource); + + /// + /// Gets the current state of the touch. + /// + public TouchState State + { + get => (TouchState)GetValue(StateProperty); + set => SetValue(StateProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty InteractionStatusProperty = BindableProperty.Create( + nameof(InteractionStatus), + typeof(TouchInteractionStatus), + typeof(TouchBehavior), + TouchInteractionStatus.Completed, + BindingMode.OneWayToSource); + + /// + /// Gets the current interaction status of the touch. + /// + public TouchInteractionStatus InteractionStatus + { + get => (TouchInteractionStatus)GetValue(InteractionStatusProperty); + set => SetValue(InteractionStatusProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty HoverStatusProperty = BindableProperty.Create( + nameof(Behaviors.HoverStatus), + typeof(HoverStatus), + typeof(TouchBehavior), + HoverStatus.Exited, + BindingMode.OneWayToSource); + + /// + /// Gets the current hover status of the touch. + /// + public HoverStatus HoverStatus + { + get => (HoverStatus)GetValue(HoverStatusProperty); + set => SetValue(HoverStatusProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty HoverStateProperty = BindableProperty.Create( + nameof(Behaviors.HoverState), + typeof(HoverState), + typeof(TouchBehavior), + HoverState.Normal, + BindingMode.OneWayToSource); + + /// + /// Gets the current hover state of the touch. + /// + public HoverState HoverState + { + get => (HoverState)GetValue(HoverStateProperty); + set => SetValue(HoverStateProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty NormalBackgroundColorProperty = BindableProperty.Create( + nameof(NormalBackgroundColor), + typeof(Color), + typeof(TouchBehavior), + null); + + /// + /// Gets or sets the background color of the element when the touch is in the normal state. + /// + public Color NormalBackgroundColor + { + get => (Color)GetValue(NormalBackgroundColorProperty); + set => SetValue(NormalBackgroundColorProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty HoveredBackgroundColorProperty = BindableProperty.Create( + nameof(HoveredBackgroundColor), + typeof(Color), + typeof(TouchBehavior), + null); + + /// + /// Gets or sets the background color of the element when the touch is in the hovered state. + /// + public Color HoveredBackgroundColor + { + get => (Color)GetValue(HoveredBackgroundColorProperty); + set => SetValue(HoveredBackgroundColorProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty PressedBackgroundColorProperty = BindableProperty.Create( + nameof(PressedBackgroundColor), + typeof(Color), + typeof(TouchBehavior), + null); + + /// + /// Gets or sets the background color of the element when the touch is in the pressed state. + /// + public Color PressedBackgroundColor + { + get => (Color)GetValue(PressedBackgroundColorProperty); + set => SetValue(PressedBackgroundColorProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty NormalOpacityProperty = BindableProperty.Create( + nameof(NormalOpacity), + typeof(double), + typeof(TouchBehavior), + 1.0); + + + /// + /// Gets or sets the opacity of the element when the touch is in the normal state. + /// + public double NormalOpacity + { + get => (double)GetValue(NormalOpacityProperty); + set => SetValue(NormalOpacityProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty HoveredOpacityProperty = BindableProperty.Create( + nameof(HoveredOpacity), + typeof(double), + typeof(TouchBehavior), + 1.0); + + /// + /// Gets or sets the opacity of the element when the touch is in the hovered state. + /// + public double HoveredOpacity + { + get => (double)GetValue(HoveredOpacityProperty); + set => SetValue(HoveredOpacityProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty PressedOpacityProperty = BindableProperty.Create( + nameof(PressedOpacity), + typeof(double), + typeof(TouchBehavior), + 1.0); + + /// + /// Gets or sets the opacity of the element when the touch is in the pressed state. + /// + public double PressedOpacity + { + get => (double)GetValue(PressedOpacityProperty); + set => SetValue(PressedOpacityProperty, value); + } + + internal bool CanExecute => IsAvailable + && (element?.IsEnabled ?? false) + && (Command?.CanExecute(CommandParameter) ?? true); + + /// + /// Bindable property for + /// + public static readonly BindableProperty NormalScaleProperty = BindableProperty.Create( + nameof(NormalScale), + typeof(double), + typeof(TouchBehavior), + 1.0); + + /// + /// Gets or sets the scale of the element when the touch is in the normal state. + /// + public double NormalScale + { + get => (double)GetValue(NormalScaleProperty); + set => SetValue(NormalScaleProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty HoveredScaleProperty = BindableProperty.Create( + nameof(HoveredScale), + typeof(double), + typeof(TouchBehavior), + 1.0); + + /// + /// Gets or sets the scale of the element when the touch is in the hovered state. + /// + public double HoveredScale + { + get => (double)GetValue(HoveredScaleProperty); + set => SetValue(HoveredScaleProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty PressedScaleProperty = BindableProperty.Create( + nameof(PressedScale), + typeof(double), + typeof(TouchBehavior), + 1.0); + + /// + /// Gets or sets the scale of the element when the touch is in the pressed state. + /// + public double PressedScale + { + get => (double)GetValue(PressedScaleProperty); + set => SetValue(PressedScaleProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty NormalTranslationXProperty = BindableProperty.Create( + nameof(NormalTranslationX), + typeof(double), + typeof(TouchBehavior), + 0.0); + + /// + /// Gets or sets the translation X of the element when the touch is in the normal state. + /// + public double NormalTranslationX + { + get => (double)GetValue(NormalTranslationXProperty); + set => SetValue(NormalTranslationXProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty HoveredTranslationXProperty = BindableProperty.Create( + nameof(HoveredTranslationX), + typeof(double), + typeof(TouchBehavior), + 0.0); + + /// + /// Gets or sets the translation X of the element when the touch is in the hovered state. + /// + public double HoveredTranslationX + { + get => (double)GetValue(HoveredTranslationXProperty); set => SetValue(HoveredTranslationXProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty PressedTranslationXProperty = BindableProperty.Create( + nameof(PressedTranslationX), + typeof(double), + typeof(TouchBehavior), + 0.0); + + /// + /// Gets or sets the translation X of the element when the touch is in the pressed state. + /// + public double PressedTranslationX + { + get => (double)GetValue(PressedTranslationXProperty); + set => SetValue(PressedTranslationXProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty NormalTranslationYProperty = BindableProperty.Create( + nameof(NormalTranslationY), + typeof(double), + typeof(TouchBehavior), + 0.0); + + /// + /// Gets or sets the translation Y of the element when the touch is in the normal state. + /// + public double NormalTranslationY + { + get => (double)GetValue(NormalTranslationYProperty); set => SetValue(NormalTranslationYProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty HoveredTranslationYProperty = BindableProperty.Create( + nameof(HoveredTranslationY), + typeof(double), + typeof(TouchBehavior), + 0.0); + + /// + /// Gets or sets the translation Y of the element when the touch is in the hovered state. + /// + public double HoveredTranslationY + { + get => (double)GetValue(HoveredTranslationYProperty); set => SetValue(HoveredTranslationYProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty PressedTranslationYProperty = BindableProperty.Create( + nameof(PressedTranslationY), + typeof(double), + typeof(TouchBehavior), + 0.0); + + /// + /// Gets or sets the translation Y of the element when the touch is in the pressed state. + /// + public double PressedTranslationY + { + get => (double)GetValue(PressedTranslationYProperty); + set => SetValue(PressedTranslationYProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty NormalRotationProperty = BindableProperty.Create( + nameof(NormalRotation), + typeof(double), + typeof(TouchBehavior), + 0.0); + + /// + /// Gets or sets the rotation of the element when the touch is in the normal state. + /// + public double NormalRotation + { + get => (double)GetValue(NormalRotationProperty); + set => SetValue(NormalRotationProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty HoveredRotationProperty = BindableProperty.Create( + nameof(HoveredRotation), + typeof(double), + typeof(TouchBehavior), + 0.0); + + /// + /// Gets or sets the rotation of the element when the touch is in the hovered state. + /// + public double HoveredRotation + { + get => (double)GetValue(HoveredRotationProperty); + set => SetValue(HoveredRotationProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty PressedRotationProperty = BindableProperty.Create( + nameof(PressedRotation), + typeof(double), + typeof(TouchBehavior), + 0.0); + + /// + /// Gets or sets the rotation of the element when the touch is in the pressed state. + /// + public double PressedRotation + { + get => (double)GetValue(PressedRotationProperty); set => SetValue(PressedRotationProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty NormalRotationXProperty = BindableProperty.Create( + nameof(NormalRotationX), + typeof(double), + typeof(TouchBehavior), + 0.0); + + /// + /// Gets or sets the rotation X of the element when the touch is in the normal state. + /// + public double NormalRotationX + { + get => (double)GetValue(NormalRotationXProperty); + set => SetValue(NormalRotationXProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty HoveredRotationXProperty = BindableProperty.Create( + nameof(HoveredRotationX), + typeof(double), + typeof(TouchBehavior), + 0.0); + + /// + /// Gets or sets the rotation X of the element when the touch is in the hovered state. + /// + public double HoveredRotationX + { + get => (double)GetValue(HoveredRotationXProperty); + set => SetValue(HoveredRotationXProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty PressedRotationXProperty = BindableProperty.Create( + nameof(PressedRotationX), + typeof(double), + typeof(TouchBehavior), + 0.0); + + /// + /// Gets or sets the rotation X of the element when the touch is in the pressed state. + /// + public double PressedRotationX + { + get => (double)GetValue(PressedRotationXProperty); set => SetValue(PressedRotationXProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty NormalRotationYProperty = BindableProperty.Create( + nameof(NormalRotationY), + typeof(double), + typeof(TouchBehavior), + 0.0); + + /// + /// Gets or sets the rotation Y of the element when the touch is in the normal state. + /// + public double NormalRotationY + { + get => (double)GetValue(NormalRotationYProperty); + set => SetValue(NormalRotationYProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty HoveredRotationYProperty = BindableProperty.Create( + nameof(HoveredRotationY), + typeof(double), + typeof(TouchBehavior), + 0.0); + + /// + /// Gets or sets the rotation Y of the element when the touch is in the hovered state. + /// + public double HoveredRotationY + { + get => (double)GetValue(HoveredRotationYProperty); + set => SetValue(HoveredRotationYProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty PressedRotationYProperty = BindableProperty.Create( + nameof(PressedRotationY), + typeof(double), + typeof(TouchBehavior), + 0.0); + + /// + /// Gets or sets the rotation Y of the element when the touch is in the pressed state. + /// + public double PressedRotationY + { + get => (double)GetValue(PressedRotationYProperty); + set => SetValue(PressedRotationYProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty AnimationDurationProperty = BindableProperty.Create( + nameof(AnimationDuration), + typeof(int), + typeof(TouchBehavior), + default(int)); + + /// + /// Gets or sets the duration of the animation. + /// + public int AnimationDuration + { + get => (int)GetValue(AnimationDurationProperty); + set => SetValue(AnimationDurationProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty AnimationEasingProperty = BindableProperty.Create( + nameof(AnimationEasing), + typeof(Easing), + typeof(TouchBehavior), + null); + + /// + /// Gets or sets the easing of the animation. + /// + public Easing AnimationEasing + { + get => (Easing)GetValue(AnimationEasingProperty); + set => SetValue(AnimationEasingProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty PressedAnimationDurationProperty = BindableProperty.Create( + nameof(PressedAnimationDuration), + typeof(int), + typeof(TouchBehavior), + default(int)); + + /// + /// Gets or sets the duration of the pressed animation. + /// + public int PressedAnimationDuration + { + get => (int)GetValue(PressedAnimationDurationProperty); + set => SetValue(PressedAnimationDurationProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty PressedAnimationEasingProperty = BindableProperty.Create( + nameof(PressedAnimationEasing), + typeof(Easing), + typeof(TouchBehavior), + null); + + /// + /// Gets or sets the easing of the pressed animation. + /// + public Easing PressedAnimationEasing + { + get => (Easing)GetValue(PressedAnimationEasingProperty); + set => SetValue(PressedAnimationEasingProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty NormalAnimationDurationProperty = BindableProperty.Create( + nameof(NormalAnimationDuration), + typeof(int), + typeof(TouchBehavior), + default(int)); + + /// + /// Gets or sets the duration of the normal animation. + /// + public int NormalAnimationDuration + { + get => (int)GetValue(NormalAnimationDurationProperty); + set => SetValue(NormalAnimationDurationProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty NormalAnimationEasingProperty = BindableProperty.Create( + nameof(NormalAnimationEasing), + typeof(Easing), + typeof(TouchBehavior), + null); + + /// + /// Gets or sets the easing of the normal animation. + /// + public Easing NormalAnimationEasing + { + get => (Easing)GetValue(NormalAnimationEasingProperty); + set => SetValue(NormalAnimationEasingProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty HoveredAnimationDurationProperty = BindableProperty.Create( + nameof(HoveredAnimationDuration), + typeof(int), + typeof(TouchBehavior), + default(int)); + + /// + /// Gets or sets the duration of the hovered animation. + /// + public int HoveredAnimationDuration + { + get => (int)GetValue(HoveredAnimationDurationProperty); + set => SetValue(HoveredAnimationDurationProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty HoveredAnimationEasingProperty = BindableProperty.Create( + nameof(HoveredAnimationEasing), + typeof(Easing), + typeof(TouchBehavior), + null); + + /// + /// Gets or sets the easing of the hovered animation. + /// + public Easing HoveredAnimationEasing + { + get => (Easing)GetValue(HoveredAnimationEasingProperty); + set => SetValue(HoveredAnimationEasingProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty PulseCountProperty = BindableProperty.Create( + nameof(PulseCount), + typeof(int), + typeof(TouchBehavior), + default(int)); + + /// + /// Gets or sets the number of times the element should pulse. + /// + public int PulseCount + { + get => (int)GetValue(PulseCountProperty); + set => SetValue(PulseCountProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty IsToggledProperty = BindableProperty.Create( + nameof(IsToggled), + typeof(bool?), + typeof(TouchBehavior), + default(bool?), + BindingMode.TwoWay); + + /// + /// Gets or sets a value indicating whether the element is toggled. + /// + public bool? IsToggled + { + get => (bool?)GetValue(IsToggledProperty); + set => SetValue(IsToggledProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty DisallowTouchThresholdProperty = BindableProperty.Create( + nameof(DisallowTouchThreshold), + typeof(int), + typeof(TouchBehavior), + default(int)); + + /// + /// Gets or sets the threshold for disallowing touch. + /// + public int DisallowTouchThreshold + { + get => (int)GetValue(DisallowTouchThresholdProperty); + set => SetValue(DisallowTouchThresholdProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty NativeAnimationProperty = BindableProperty.Create( + nameof(NativeAnimation), + typeof(bool), + typeof(TouchBehavior), + false); + + /// + /// Gets or sets a value indicating whether the animation should be native. + /// + public bool NativeAnimation + { + get => (bool)GetValue(NativeAnimationProperty); + set => SetValue(NativeAnimationProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty NativeAnimationColorProperty = BindableProperty.Create( + nameof(NativeAnimationColor), + typeof(Color), + typeof(TouchBehavior), + null); + + /// + /// Gets or sets the color of the native animation. + /// + public Color NativeAnimationColor + { + get => (Color)GetValue(NativeAnimationColorProperty); + set => SetValue(NativeAnimationColorProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty NativeAnimationRadiusProperty = BindableProperty.Create( + nameof(NativeAnimationRadius), + typeof(int), + typeof(TouchBehavior), + -1); + + /// + /// Gets or sets the radius of the native animation. + /// + public int NativeAnimationRadius + { + get => (int)GetValue(NativeAnimationRadiusProperty); + set => SetValue(NativeAnimationRadiusProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty NativeAnimationShadowRadiusProperty = BindableProperty.Create( + nameof(NativeAnimationShadowRadius), + typeof(int), + typeof(TouchBehavior), + -1); + + /// + /// Gets or sets the shadow radius of the native animation. + /// + public int NativeAnimationShadowRadius + { + get => (int)GetValue(NativeAnimationShadowRadiusProperty); + set => SetValue(NativeAnimationShadowRadiusProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty NativeAnimationBorderlessProperty = BindableProperty.Create( + nameof(NativeAnimationBorderless), + typeof(bool), + typeof(TouchBehavior), + false); + + /// + /// Gets or sets a value indicating whether the native animation should be borderless. + /// + public bool NativeAnimationBorderless + { + get => (bool)GetValue(NativeAnimationBorderlessProperty); + set => SetValue(NativeAnimationBorderlessProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty NormalBackgroundImageSourceProperty = BindableProperty.Create( + nameof(NormalBackgroundImageSource), + typeof(ImageSource), + typeof(TouchBehavior), + default(ImageSource)); + + /// + /// Gets or sets the normal background image source. + /// + public ImageSource NormalBackgroundImageSource + { + get => (ImageSource)GetValue(NormalBackgroundImageSourceProperty); + set => SetValue(NormalBackgroundImageSourceProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty HoveredBackgroundImageSourceProperty = BindableProperty.Create( + nameof(HoveredBackgroundImageSource), + typeof(ImageSource), + typeof(TouchBehavior), + default(ImageSource)); + + /// + /// Gets or sets the hovered background image source. + /// + public ImageSource HoveredBackgroundImageSource + { + get => (ImageSource)GetValue(HoveredBackgroundImageSourceProperty); + set => SetValue(HoveredBackgroundImageSourceProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty PressedBackgroundImageSourceProperty = BindableProperty.Create( + nameof(PressedBackgroundImageSource), + typeof(ImageSource), + typeof(TouchBehavior), + default(ImageSource)); + + /// + /// Gets or sets the pressed background image source. + /// + public ImageSource PressedBackgroundImageSource + { + get => (ImageSource)GetValue(PressedBackgroundImageSourceProperty); + set => SetValue(PressedBackgroundImageSourceProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty BackgroundImageAspectProperty = BindableProperty.Create( + nameof(BackgroundImageAspect), + typeof(Aspect), + typeof(TouchBehavior), + default(Aspect)); + + /// + /// Gets or sets the background image aspect. + /// + public Aspect BackgroundImageAspect + { + get => (Aspect)GetValue(BackgroundImageAspectProperty); + set => SetValue(BackgroundImageAspectProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty NormalBackgroundImageAspectProperty = BindableProperty.Create( + nameof(NormalBackgroundImageAspect), + typeof(Aspect), + typeof(TouchBehavior), + default(Aspect)); + + /// + /// Gets or sets the normal background image aspect. + /// + public Aspect NormalBackgroundImageAspect + { + get => (Aspect)GetValue(NormalBackgroundImageAspectProperty); + set => SetValue(NormalBackgroundImageAspectProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty HoveredBackgroundImageAspectProperty = BindableProperty.Create( + nameof(HoveredBackgroundImageAspect), + typeof(Aspect), + typeof(TouchBehavior), + default(Aspect)); + + /// + /// Gets or sets the hovered background image aspect. + /// + public Aspect HoveredBackgroundImageAspect + { + get => (Aspect)GetValue(HoveredBackgroundImageAspectProperty); + set => SetValue(HoveredBackgroundImageAspectProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty PressedBackgroundImageAspectProperty = BindableProperty.Create( + nameof(PressedBackgroundImageAspect), + typeof(Aspect), + typeof(TouchBehavior), + default(Aspect)); + + /// + /// Gets or sets the pressed background image aspect. + /// + public Aspect PressedBackgroundImageAspect + { + get => (Aspect)GetValue(PressedBackgroundImageAspectProperty); + set => SetValue(PressedBackgroundImageAspectProperty, value); + } + + /// + /// Bindable property for + /// + public static readonly BindableProperty ShouldSetImageOnAnimationEndProperty = BindableProperty.Create( + nameof(ShouldSetImageOnAnimationEnd), + typeof(bool), + typeof(TouchBehavior), + default(bool)); + + /// + /// Gets or sets a value indicating whether the image should be set on animation end. + /// + public bool ShouldSetImageOnAnimationEnd + { + get => (bool)GetValue(ShouldSetImageOnAnimationEndProperty); + set => SetValue(ShouldSetImageOnAnimationEndProperty, value); + } +} diff --git a/src/Core/Controls/AccountSwitchingOverlay/AccountSwitchingOverlayView.xaml.cs b/src/Core/Controls/AccountSwitchingOverlay/AccountSwitchingOverlayView.xaml.cs index 90e121d68..5a71e90b7 100644 --- a/src/Core/Controls/AccountSwitchingOverlay/AccountSwitchingOverlayView.xaml.cs +++ b/src/Core/Controls/AccountSwitchingOverlay/AccountSwitchingOverlayView.xaml.cs @@ -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; } diff --git a/src/Core/Controls/AccountViewCell/AccountViewCell.xaml b/src/Core/Controls/AccountViewCell/AccountViewCell.xaml index 34ce0ab3c..05071fb4b 100644 --- a/src/Core/Controls/AccountViewCell/AccountViewCell.xaml +++ b/src/Core/Controls/AccountViewCell/AccountViewCell.xaml @@ -1,18 +1,21 @@  - + + + + diff --git a/src/Core/Controls/ExtendedGrid.cs b/src/Core/Controls/ExtendedGrid.cs index 922973456..5e2208dec 100644 --- a/src/Core/Controls/ExtendedGrid.cs +++ b/src/Core/Controls/ExtendedGrid.cs @@ -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); + } + } } } diff --git a/src/Core/Controls/ExtendedStackLayout.cs b/src/Core/Controls/ExtendedStackLayout.cs index 5186fbb0a..5709e0a97 100644 --- a/src/Core/Controls/ExtendedStackLayout.cs +++ b/src/Core/Controls/ExtendedStackLayout.cs @@ -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); + } + } } } diff --git a/src/Core/Controls/MonoLabel.cs b/src/Core/Controls/MonoLabel.cs index 35a26fd02..665269d03 100644 --- a/src/Core/Controls/MonoLabel.cs +++ b/src/Core/Controls/MonoLabel.cs @@ -7,10 +7,6 @@ if (DeviceInfo.Platform == DevicePlatform.iOS) { FontFamily = "Menlo-Regular"; - - //[MAUI-Migration] Temporary Workaround for the Text to appear in iOS. - // A proper solution needs to be found to be able to have html text with different colors or alternatively use Label FormattedString Spans - TextColor = Colors.Black; } else if (DeviceInfo.Platform == DevicePlatform.Android) { diff --git a/src/Core/Pages/Send/SendAddEditPage.xaml b/src/Core/Pages/Send/SendAddEditPage.xaml index 5d235e287..5580b1d5f 100644 --- a/src/Core/Pages/Send/SendAddEditPage.xaml +++ b/src/Core/Pages/Send/SendAddEditPage.xaml @@ -248,7 +248,7 @@ AutomationId="SendHideTextByDefaultToggle" /> - + - diff --git a/src/Core/Utilities/GeneratedValueFormatter.cs b/src/Core/Utilities/GeneratedValueFormatter.cs index 20f2d12aa..e36557d4d 100644 --- a/src/Core/Utilities/GeneratedValueFormatter.cs +++ b/src/Core/Utilities/GeneratedValueFormatter.cs @@ -27,11 +27,9 @@ namespace Bit.App.Utilities return string.Empty; } - // First two digits of returned hex code contains the alpha, - // which is not supported in HTML color, so we need to cut those out. - var normalColor = $""; - var numberColor = $""; - var specialColor = $""; + var normalColor = $""; + var numberColor = $""; + var specialColor = $""; var result = string.Empty; // iOS won't hide the zero-width space char without these div attrs, but Android won't respect