From d56462af82f997da2f7137a8f73fc9cbe97562ea Mon Sep 17 00:00:00 2001 From: roubachof Date: Mon, 26 Sep 2022 14:14:20 +0200 Subject: [PATCH] add effects and update to latest (2019) touch effects commits --- Maui.Tabs/Effects/Commands.cs | 110 ++++++++ Maui.Tabs/Effects/EffectsConfig.cs | 55 ++++ Maui.Tabs/Effects/TouchEffect.cs | 55 ++++ Maui.Tabs/Maui.Tabs.csproj | 36 +-- Maui.Tabs/MauiAppBuilderExtensions.cs | 21 ++ .../Platforms/Android/CommandsPlatform.cs | 97 +++++++ .../Platforms/Android/TintableImageEffect.cs | 49 ++++ Maui.Tabs/Platforms/Android/TouchCollector.cs | 66 +++++ .../Platforms/Android/TouchEffectPlatform.cs | 237 ++++++++++++++++++ Maui.Tabs/Platforms/iOS/CommandsPlatform.cs | 105 ++++++++ .../Platforms/iOS/TintableImageEffect.cs | 104 ++++++++ .../Platforms/iOS/TouchEffectPlatform.cs | 109 ++++++++ .../Platforms/iOS/TouchGestureCollector.cs | 58 +++++ .../Platforms/iOS/TouchGestureRecognizer.cs | 132 ++++++++++ .../Shims/XamarinFormsInternalsNamespace.cs | 1 + .../XamarinFormsPlatformAndroidNamespace.cs | 1 + Tabs/Tabs/Effects/ImageEffect.cs | 4 + Tabs/Tabs/Effects/TapCommandEffect.cs | 1 - Tabs/Tabs/Effects/ViewEffect.cs | 158 +----------- Tabs/Tabs/TabHostView.cs | 9 + 20 files changed, 1224 insertions(+), 184 deletions(-) create mode 100644 Maui.Tabs/Effects/Commands.cs create mode 100644 Maui.Tabs/Effects/EffectsConfig.cs create mode 100644 Maui.Tabs/Effects/TouchEffect.cs create mode 100644 Maui.Tabs/MauiAppBuilderExtensions.cs create mode 100644 Maui.Tabs/Platforms/Android/CommandsPlatform.cs create mode 100644 Maui.Tabs/Platforms/Android/TintableImageEffect.cs create mode 100644 Maui.Tabs/Platforms/Android/TouchCollector.cs create mode 100644 Maui.Tabs/Platforms/Android/TouchEffectPlatform.cs create mode 100644 Maui.Tabs/Platforms/iOS/CommandsPlatform.cs create mode 100644 Maui.Tabs/Platforms/iOS/TintableImageEffect.cs create mode 100644 Maui.Tabs/Platforms/iOS/TouchEffectPlatform.cs create mode 100644 Maui.Tabs/Platforms/iOS/TouchGestureCollector.cs create mode 100644 Maui.Tabs/Platforms/iOS/TouchGestureRecognizer.cs create mode 100644 Maui.Tabs/Shims/XamarinFormsInternalsNamespace.cs create mode 100644 Maui.Tabs/Shims/XamarinFormsPlatformAndroidNamespace.cs diff --git a/Maui.Tabs/Effects/Commands.cs b/Maui.Tabs/Effects/Commands.cs new file mode 100644 index 0000000..89f38be --- /dev/null +++ b/Maui.Tabs/Effects/Commands.cs @@ -0,0 +1,110 @@ +using System; +using System.Linq; +using System.Windows.Input; +using Xamarin.Forms; + +namespace XamEffects { + public static class Commands { + [Obsolete("Not need with usual Linking")] + public static void Init() { + } + + public static readonly BindableProperty TapProperty = + BindableProperty.CreateAttached( + "Tap", + typeof(ICommand), + typeof(Commands), + default(ICommand), + propertyChanged: PropertyChanged + ); + + public static void SetTap(BindableObject view, ICommand value) { + view.SetValue(TapProperty, value); + } + + public static ICommand GetTap(BindableObject view) { + return (ICommand)view.GetValue(TapProperty); + } + + public static readonly BindableProperty TapParameterProperty = + BindableProperty.CreateAttached( + "TapParameter", + typeof(object), + typeof(Commands), + default(object), + propertyChanged: PropertyChanged + ); + + public static void SetTapParameter(BindableObject view, object value) { + view.SetValue(TapParameterProperty, value); + } + + public static object GetTapParameter(BindableObject view) { + return view.GetValue(TapParameterProperty); + } + + public static readonly BindableProperty LongTapProperty = + BindableProperty.CreateAttached( + "LongTap", + typeof(ICommand), + typeof(Commands), + default(ICommand), + propertyChanged: PropertyChanged + ); + + public static void SetLongTap(BindableObject view, ICommand value) { + view.SetValue(LongTapProperty, value); + } + + public static ICommand GetLongTap(BindableObject view) { + return (ICommand)view.GetValue(LongTapProperty); + } + + public static readonly BindableProperty LongTapParameterProperty = + BindableProperty.CreateAttached( + "LongTapParameter", + typeof(object), + typeof(Commands), + default(object) + ); + + public static void SetLongTapParameter(BindableObject view, object value) { + view.SetValue(LongTapParameterProperty, value); + } + + public static object GetLongTapParameter(BindableObject view) { + return view.GetValue(LongTapParameterProperty); + } + + static void PropertyChanged(BindableObject bindable, object oldValue, object newValue) { + if (!(bindable is View view)) + return; + + var eff = view.Effects.FirstOrDefault(e => e is CommandsRoutingEffect); + + if (GetTap(bindable) != null || GetLongTap(bindable) != null) { + view.InputTransparent = false; + + if (eff != null) return; + view.Effects.Add(new CommandsRoutingEffect()); + if (EffectsConfig.AutoChildrenInputTransparent && bindable is Layout && + !EffectsConfig.GetChildrenInputTransparent(view)) { + EffectsConfig.SetChildrenInputTransparent(view, true); + } + } + else { + if (eff == null || view.BindingContext == null) return; + view.Effects.Remove(eff); + if (EffectsConfig.AutoChildrenInputTransparent && bindable is Layout && + EffectsConfig.GetChildrenInputTransparent(view)) { + EffectsConfig.SetChildrenInputTransparent(view, false); + } + } + } + } + + public class CommandsRoutingEffect : RoutingEffect { + public CommandsRoutingEffect() : base("XamEffects." + nameof(Commands)) { + } + } +} \ No newline at end of file diff --git a/Maui.Tabs/Effects/EffectsConfig.cs b/Maui.Tabs/Effects/EffectsConfig.cs new file mode 100644 index 0000000..90f4a51 --- /dev/null +++ b/Maui.Tabs/Effects/EffectsConfig.cs @@ -0,0 +1,55 @@ +using System; +using Xamarin.Forms; + +namespace XamEffects { + public static class EffectsConfig { + [Obsolete("Not need with usual Linking")] + public static void Init() { + } + + public static bool AutoChildrenInputTransparent { get; set; } = true; + + public static readonly BindableProperty ChildrenInputTransparentProperty = + BindableProperty.CreateAttached( + "ChildrenInputTransparent", + typeof(bool), + typeof(EffectsConfig), + false, + propertyChanged: (bindable, oldValue, newValue) => { + ConfigureChildrenInputTransparent(bindable); + } + ); + + public static void SetChildrenInputTransparent(BindableObject view, bool value) { + view.SetValue(ChildrenInputTransparentProperty, value); + } + + public static bool GetChildrenInputTransparent(BindableObject view) { + return (bool)view.GetValue(ChildrenInputTransparentProperty); + } + + static void ConfigureChildrenInputTransparent(BindableObject bindable) { + if (!(bindable is Layout layout)) + return; + + if (GetChildrenInputTransparent(bindable)) { + foreach (View layoutChild in layout.Children) + AddInputTransparentToElement(layoutChild); + layout.ChildAdded += Layout_ChildAdded; + } + else { + layout.ChildAdded -= Layout_ChildAdded; + } + } + + static void Layout_ChildAdded(object sender, ElementEventArgs e) { + AddInputTransparentToElement(e.Element); + } + + static void AddInputTransparentToElement(BindableObject obj) { + if (obj is View view && TouchEffect.GetColor(view) == Colors.Transparent && Commands.GetTap(view) == null && Commands.GetLongTap(view) == null) { + view.InputTransparent = true; + } + } + } +} \ No newline at end of file diff --git a/Maui.Tabs/Effects/TouchEffect.cs b/Maui.Tabs/Effects/TouchEffect.cs new file mode 100644 index 0000000..0357537 --- /dev/null +++ b/Maui.Tabs/Effects/TouchEffect.cs @@ -0,0 +1,55 @@ +using System; +using System.Linq; +using Xamarin.Forms; + +namespace XamEffects { + public static class TouchEffect { + + public static readonly BindableProperty ColorProperty = + BindableProperty.CreateAttached( + "Color", + typeof(Color), + typeof(TouchEffect), + Colors.Transparent, + propertyChanged: PropertyChanged + ); + + public static void SetColor(BindableObject view, Color value) { + view.SetValue(ColorProperty, value); + } + + public static Color GetColor(BindableObject view) { + return (Color)view.GetValue(ColorProperty); + } + + static void PropertyChanged(BindableObject bindable, object oldValue, object newValue) { + if (!(bindable is View view)) + return; + + var eff = view.Effects.FirstOrDefault(e => e is TouchRoutingEffect); + if (GetColor(bindable) != Colors.Transparent) { + view.InputTransparent = false; + + if (eff != null) return; + view.Effects.Add(new TouchRoutingEffect()); + if (EffectsConfig.AutoChildrenInputTransparent && bindable is Layout && + !EffectsConfig.GetChildrenInputTransparent(view)) { + EffectsConfig.SetChildrenInputTransparent(view, true); + } + } + else { + if (eff == null || view.BindingContext == null) return; + view.Effects.Remove(eff); + if (EffectsConfig.AutoChildrenInputTransparent && bindable is Layout && + EffectsConfig.GetChildrenInputTransparent(view)) { + EffectsConfig.SetChildrenInputTransparent(view, false); + } + } + } + } + + public class TouchRoutingEffect : RoutingEffect { + public TouchRoutingEffect() : base("XamEffects." + nameof(TouchEffect)) { + } + } +} \ No newline at end of file diff --git a/Maui.Tabs/Maui.Tabs.csproj b/Maui.Tabs/Maui.Tabs.csproj index 9f823ab..f201ebf 100644 --- a/Maui.Tabs/Maui.Tabs.csproj +++ b/Maui.Tabs/Maui.Tabs.csproj @@ -62,39 +62,20 @@ * Independent ViewSwitcher * Bindable with ItemsSource - -------------- - Installation - -------------- + ## Installation * In Core project, in `App.xaml.cs`: ```csharp - public App() + public static MauiApp CreateMauiApp() { - InitializeComponent(); - - Sharpnado.Tabs.Initializer.Initialize(loggerEnable: false); - ... + var builder = MauiApp.CreateBuilder(); + builder + .UseMauiApp() + .UseSharpnadoTabs(); } ``` - * In iOS project: - - ```csharp - Xamarin.Forms.Forms.Init(); - Sharpnado.Tabs.iOS.Preserver.Preserve(); - ``` - - * In UWP project: - ```csharp - var rendererAssemblies = new[] - { - typeof(UWPShadowsRenderer).GetTypeInfo().Assembly, - typeof(UwpTintableImageEffect).GetTypeInfo().Assembly, - }; - - Xamarin.Forms.Forms.Init(e, rendererAssemblies); - ``` Jean-Marie Alfonsi @@ -158,8 +139,6 @@ - - @@ -177,9 +156,6 @@ - - - diff --git a/Maui.Tabs/MauiAppBuilderExtensions.cs b/Maui.Tabs/MauiAppBuilderExtensions.cs new file mode 100644 index 0000000..b2ecb97 --- /dev/null +++ b/Maui.Tabs/MauiAppBuilderExtensions.cs @@ -0,0 +1,21 @@ +namespace Sharpnado.Tabs; + +public static class MauiAppBuilderExtensions +{ + public static MauiAppBuilder UseSharpnadoTabs( + this MauiAppBuilder builder, + bool loggerEnable, + bool debugLogEnable = false) + { + InternalLogger.EnableDebug = debugLogEnable; + InternalLogger.EnableLogging = loggerEnable; + +#if __IOS__ + XamEffects.iOS.CommandsPlatform.Init(); + XamEffects.iOS.TouchEffectPlatform.Init(); + Sharpnado.Tabs.iOS.iOSTintableImageEffect.Init(); +#endif + + return builder; + } +} diff --git a/Maui.Tabs/Platforms/Android/CommandsPlatform.cs b/Maui.Tabs/Platforms/Android/CommandsPlatform.cs new file mode 100644 index 0000000..e4c7d1c --- /dev/null +++ b/Maui.Tabs/Platforms/Android/CommandsPlatform.cs @@ -0,0 +1,97 @@ +// +// https://github.com/mrxten/XamEffects/blob/master/src/XamEffects.Droid/CommandsPlatform.cs +// +// ec1641f on 25 May 2019 +// +// This will exclude this file from stylecop analysis +// + +using System; +using Android.Graphics; +using Android.Views; +using Xamarin.Forms; +using Xamarin.Forms.Platform.Android; +using XamEffects; +using XamEffects.Droid; +using View = Android.Views.View; +using System.Threading; +using XamEffects.Droid.GestureCollectors; +using Microsoft.Maui.Controls.Compatibility.Platform.Android; +using System.ComponentModel; + +using Microsoft.Maui.Controls.Platform; + +using Rect = Android.Graphics.Rect; + +[assembly: ExportEffect(typeof(CommandsPlatform), nameof(Commands))] + +namespace XamEffects.Droid { + public class CommandsPlatform : PlatformEffect { + public View View => Control ?? Container; + public bool IsDisposed => (Container as IVisualElementRenderer)?.Element == null; + + DateTime _tapTime; + readonly Rect _rect = new Rect(); + readonly int[] _location = new int[2]; + + public static void Init() { + } + + protected override void OnAttached() { + View.Clickable = true; + View.LongClickable = true; + View.SoundEffectsEnabled = true; + TouchCollector.Add(View, OnTouch); + } + + void OnTouch(View.TouchEventArgs args) { + switch (args.Event.Action) { + case MotionEventActions.Down: + _tapTime = DateTime.Now; + break; + + case MotionEventActions.Up: + if (IsViewInBounds((int)args.Event.RawX, (int)args.Event.RawY)) { + var range = (DateTime.Now - _tapTime).TotalMilliseconds; + if (range > 800) + LongClickHandler(); + else + ClickHandler(); + } + break; + } + } + + bool IsViewInBounds(int x, int y) { + View.GetDrawingRect(_rect); + View.GetLocationOnScreen(_location); + _rect.Offset(_location[0], _location[1]); + return _rect.Contains(x, y); + } + + void ClickHandler() { + var cmd = Commands.GetTap(Element); + var param = Commands.GetTapParameter(Element); + if (cmd?.CanExecute(param) ?? false) + cmd.Execute(param); + } + + void LongClickHandler() { + var cmd = Commands.GetLongTap(Element); + + if (cmd == null) { + ClickHandler(); + return; + } + + var param = Commands.GetLongTapParameter(Element); + if (cmd.CanExecute(param)) + cmd.Execute(param); + } + + protected override void OnDetached() { + if (IsDisposed) return; + TouchCollector.Delete(View, OnTouch); + } + } +} \ No newline at end of file diff --git a/Maui.Tabs/Platforms/Android/TintableImageEffect.cs b/Maui.Tabs/Platforms/Android/TintableImageEffect.cs new file mode 100644 index 0000000..176561b --- /dev/null +++ b/Maui.Tabs/Platforms/Android/TintableImageEffect.cs @@ -0,0 +1,49 @@ +using Android.Widget; + +using Microsoft.Maui.Controls.Compatibility.Platform.Android; +using Microsoft.Maui.Controls.Platform; + +using Sharpnado.Tabs.Droid; +using Sharpnado.Tabs.Effects; + +[assembly: ResolutionGroupName("Sharpnado")] +[assembly: ExportEffect(typeof(AndroidTintableImageEffect), nameof(TintableImageEffect))] + +namespace Sharpnado.Tabs.Droid +{ + public class AndroidTintableImageEffect : PlatformEffect + { + protected override void OnAttached() + { + UpdateColor(); + } + + protected override void OnDetached() + { + } + + protected override void OnElementPropertyChanged(System.ComponentModel.PropertyChangedEventArgs args) + { + base.OnElementPropertyChanged(args); + + if ((Element is Image) && args.PropertyName == Image.SourceProperty.PropertyName) + { + UpdateColor(); + } + } + + private void UpdateColor() + { + var effect = + (TintableImageEffect)Element.Effects.FirstOrDefault(x => x is TintableImageEffect); + var color = effect?.TintColor.ToAndroid(); + + if (Control is ImageView imageView && imageView.Handle != IntPtr.Zero && color.HasValue) + { + Android.Graphics.Color tint = color.Value; + + imageView.SetColorFilter(tint); + } + } + } +} \ No newline at end of file diff --git a/Maui.Tabs/Platforms/Android/TouchCollector.cs b/Maui.Tabs/Platforms/Android/TouchCollector.cs new file mode 100644 index 0000000..f67d963 --- /dev/null +++ b/Maui.Tabs/Platforms/Android/TouchCollector.cs @@ -0,0 +1,66 @@ +// +// https://github.com/mrxten/XamEffects/blob/master/src/XamEffects.Droid/GestureCollectors/TouchCollector.cs +// +// ec1641f on 25 May 2019 +// +// This will exclude this file from stylecop analysis +// + +using System.Collections.Generic; +using Android.Views; +using System; + +using View = Android.Views.View; + +namespace XamEffects.Droid.GestureCollectors { + internal static class TouchCollector { + static Dictionary>> Collection { get; } = + new Dictionary>>(); + + static View _activeView; + + public static void Add(View view, Action action) { + if (Collection.ContainsKey(view)) { + Collection[view].Add(action); + } + else { + view.Touch += ActionActivator; + Collection.Add(view, new List> { action }); + } + } + + public static void Delete(View view, Action action) { + if (!Collection.ContainsKey(view)) return; + + var actions = Collection[view]; + actions.Remove(action); + + if (actions.Count != 0) return; + view.Touch -= ActionActivator; + Collection.Remove(view); + } + + static void ActionActivator(object sender, View.TouchEventArgs e) { + var view = (View)sender; + if (!Collection.ContainsKey(view) || (_activeView != null && _activeView != view)) return; + + switch (e.Event.Action) { + case MotionEventActions.Down: + _activeView = view; + view.PlaySoundEffect(SoundEffects.Click); + break; + + case MotionEventActions.Up: + case MotionEventActions.Cancel: + _activeView = null; + e.Handled = true; + break; + } + + var actions = Collection[view].ToArray(); + foreach (var valueAction in actions) { + valueAction?.Invoke(e); + } + } + } +} \ No newline at end of file diff --git a/Maui.Tabs/Platforms/Android/TouchEffectPlatform.cs b/Maui.Tabs/Platforms/Android/TouchEffectPlatform.cs new file mode 100644 index 0000000..b264969 --- /dev/null +++ b/Maui.Tabs/Platforms/Android/TouchEffectPlatform.cs @@ -0,0 +1,237 @@ +using System.ComponentModel; +using Android.Animation; +using Android.Content.Res; +using Android.Graphics.Drawables; +using Android.OS; +using Android.Views; +using Android.Widget; +using Microsoft.Maui.Controls.Compatibility.Platform.Android; +using Microsoft.Maui.Controls.Platform; + +using XamEffects; +using XamEffects.Droid; +using XamEffects.Droid.GestureCollectors; +using Color = Android.Graphics.Color; +using ListView = Android.Widget.ListView; +using ScrollView = Android.Widget.ScrollView; +using View = Android.Views.View; + +[assembly: ExportEffect(typeof(TouchEffectPlatform), nameof(TouchEffect))] + +namespace XamEffects.Droid { + public class TouchEffectPlatform : PlatformEffect { + public bool EnableRipple => Build.VERSION.SdkInt >= BuildVersionCodes.Lollipop; + public bool IsDisposed => (Container as IVisualElementRenderer)?.Element == null; + public View View => Control ?? Container; + + Color _color; + byte _alpha; + RippleDrawable _ripple; + FrameLayout _viewOverlay; + ObjectAnimator _animator; + + public static void Init() { + } + + protected override void OnAttached() { + if (Control is ListView || Control is ScrollView) { + return; + } + + if (Container is not ViewGroup group) + { + throw new InvalidOperationException("Container must be a ViewGroup"); + } + + View.Clickable = true; + View.LongClickable = true; + _viewOverlay = new FrameLayout(Container.Context) { + LayoutParameters = new ViewGroup.LayoutParams(-1, -1), + Clickable = false, + Focusable = false, + }; + Container.LayoutChange += ViewOnLayoutChange; + + if (EnableRipple) + _viewOverlay.Background = CreateRipple(_color); + + SetEffectColor(); + TouchCollector.Add(View, OnTouch); + + group.AddView(_viewOverlay); + _viewOverlay.BringToFront(); + } + + protected override void OnDetached() { + if (IsDisposed) return; + + if (Container is not ViewGroup group) + { + throw new InvalidOperationException("Container must be a ViewGroup"); + } + + group.RemoveView(_viewOverlay); + _viewOverlay.Pressed = false; + _viewOverlay.Foreground = null; + _viewOverlay.Dispose(); + Container.LayoutChange -= ViewOnLayoutChange; + + if (EnableRipple) + _ripple?.Dispose(); + + TouchCollector.Delete(View, OnTouch); + } + + protected override void OnElementPropertyChanged(PropertyChangedEventArgs e) { + base.OnElementPropertyChanged(e); + + if (e.PropertyName == TouchEffect.ColorProperty.PropertyName) { + SetEffectColor(); + } + } + + void SetEffectColor() { + var color = TouchEffect.GetColor(Element); + if (color == Colors.Transparent) { + return; + } + + _color = color.ToAndroid(); + _alpha = _color.A == 255 ? (byte)80 : _color.A; + + if (EnableRipple) { + _ripple.SetColor(GetPressedColorSelector(_color)); + } + } + + void OnTouch(View.TouchEventArgs args) { + switch (args.Event.Action) { + case MotionEventActions.Down: + if (EnableRipple) + ForceStartRipple(args.Event.GetX(), args.Event.GetY()); + else + BringLayer(); + + break; + + case MotionEventActions.Up: + case MotionEventActions.Cancel: + if (IsDisposed) return; + + if (EnableRipple) + ForceEndRipple(); + else + TapAnimation(250, _alpha, 0); + + break; + } + } + + void ViewOnLayoutChange(object sender, View.LayoutChangeEventArgs layoutChangeEventArgs) { + var group = (ViewGroup)sender; + if (group == null || IsDisposed) return; + _viewOverlay.Right = group.Width; + _viewOverlay.Bottom = group.Height; + } + + #region Ripple + + RippleDrawable CreateRipple(Color color) { + if (Element is Layout) { + var mask = new ColorDrawable(Color.White); + return _ripple = new RippleDrawable(GetPressedColorSelector(color), null, mask); + } + + var back = View.Background; + if (back == null) { + var mask = new ColorDrawable(Color.White); + return _ripple = new RippleDrawable(GetPressedColorSelector(color), null, mask); + } + + if (back is RippleDrawable) { + _ripple = (RippleDrawable)back.GetConstantState().NewDrawable(); + _ripple.SetColor(GetPressedColorSelector(color)); + + return _ripple; + } + + return _ripple = new RippleDrawable(GetPressedColorSelector(color), back, null); + } + + static ColorStateList GetPressedColorSelector(int pressedColor) { + return new ColorStateList( + new[] { new int[] { } }, + new[] { pressedColor, }); + } + + void ForceStartRipple(float x, float y) { + if (IsDisposed || !(_viewOverlay.Background is RippleDrawable bc)) return; + + _viewOverlay.BringToFront(); + bc.SetHotspot(x, y); + _viewOverlay.Pressed = true; + } + + void ForceEndRipple() { + if (IsDisposed) return; + + _viewOverlay.Pressed = false; + } + + #endregion + + #region Overlay + + void BringLayer() { + if (IsDisposed) + return; + + ClearAnimation(); + + _viewOverlay.BringToFront(); + var color = _color; + color.A = _alpha; + _viewOverlay.SetBackgroundColor(color); + } + + void TapAnimation(long duration, byte startAlpha, byte endAlpha) { + if (IsDisposed) + return; + + _viewOverlay.BringToFront(); + + var start = _color; + var end = _color; + start.A = startAlpha; + end.A = endAlpha; + + ClearAnimation(); + _animator = ObjectAnimator.OfObject(_viewOverlay, + "BackgroundColor", + new ArgbEvaluator(), + start.ToArgb(), + end.ToArgb()); + _animator.SetDuration(duration); + _animator.RepeatCount = 0; + _animator.RepeatMode = ValueAnimatorRepeatMode.Restart; + _animator.Start(); + _animator.AnimationEnd += AnimationOnAnimationEnd; + } + + void AnimationOnAnimationEnd(object sender, EventArgs eventArgs) { + if (IsDisposed) return; + + ClearAnimation(); + } + + void ClearAnimation() { + if (_animator == null) return; + _animator.AnimationEnd -= AnimationOnAnimationEnd; + _animator.Cancel(); + _animator.Dispose(); + _animator = null; + } + + #endregion + } +} \ No newline at end of file diff --git a/Maui.Tabs/Platforms/iOS/CommandsPlatform.cs b/Maui.Tabs/Platforms/iOS/CommandsPlatform.cs new file mode 100644 index 0000000..fbe629f --- /dev/null +++ b/Maui.Tabs/Platforms/iOS/CommandsPlatform.cs @@ -0,0 +1,105 @@ +using Sharpnado.Tabs.iOS; +using System.ComponentModel; +using System.Windows.Input; + +using Microsoft.Maui.Controls.Platform; + +using UIKit; +using XamEffects; +using XamEffects.iOS; +using XamEffects.iOS.GestureCollectors; +using XamEffects.iOS.GestureRecognizers; + +[assembly: ExportEffect(typeof(CommandsPlatform), nameof(Commands))] + +namespace XamEffects.iOS { + public class CommandsPlatform : PlatformEffect { + public UIView View => Control ?? Container; + + DateTime _tapTime; + ICommand _tapCommand; + ICommand _longCommand; + object _tapParameter; + object _longParameter; + + protected override void OnAttached() { + View.UserInteractionEnabled = true; + + UpdateTap(); + UpdateTapParameter(); + UpdateLongTap(); + UpdateLongTapParameter(); + + TouchGestureCollector.Add(View, OnTouch); + } + + protected override void OnDetached() { + TouchGestureCollector.Delete(View, OnTouch); + } + + void OnTouch(TouchGestureRecognizer.TouchArgs e) { + switch (e.State) { + case TouchGestureRecognizer.TouchState.Started: + _tapTime = DateTime.Now; + break; + + case TouchGestureRecognizer.TouchState.Ended: + if (e.Inside) { + var range = (DateTime.Now - _tapTime).TotalMilliseconds; + if (range > 800) + LongClickHandler(); + else + ClickHandler(); + } + break; + + case TouchGestureRecognizer.TouchState.Cancelled: + break; + } + } + + void ClickHandler() { + if (_tapCommand?.CanExecute(_tapParameter) ?? false) + _tapCommand.Execute(_tapParameter); + } + + void LongClickHandler() { + if (_longCommand == null) + ClickHandler(); + else if (_longCommand.CanExecute(_longParameter)) + _longCommand.Execute(_longParameter); + } + + protected override void OnElementPropertyChanged(PropertyChangedEventArgs args) { + base.OnElementPropertyChanged(args); + + if (args.PropertyName == Commands.TapProperty.PropertyName) + UpdateTap(); + else if (args.PropertyName == Commands.TapParameterProperty.PropertyName) + UpdateTapParameter(); + else if (args.PropertyName == Commands.LongTapProperty.PropertyName) + UpdateLongTap(); + else if (args.PropertyName == Commands.LongTapParameterProperty.PropertyName) + UpdateLongTapParameter(); + } + + void UpdateTap() { + _tapCommand = Commands.GetTap(Element); + } + + void UpdateTapParameter() { + _tapParameter = Commands.GetTapParameter(Element); + } + + void UpdateLongTap() { + _longCommand = Commands.GetLongTap(Element); + } + + void UpdateLongTapParameter() { + _longParameter = Commands.GetLongTapParameter(Element); + } + + public static void Init() { + } + } +} \ No newline at end of file diff --git a/Maui.Tabs/Platforms/iOS/TintableImageEffect.cs b/Maui.Tabs/Platforms/iOS/TintableImageEffect.cs new file mode 100644 index 0000000..d6e6bec --- /dev/null +++ b/Maui.Tabs/Platforms/iOS/TintableImageEffect.cs @@ -0,0 +1,104 @@ +using System; +using System.Linq; +using System.Threading.Tasks; + +using Foundation; + +using Microsoft.Maui.Controls.Compatibility.Platform.iOS; +using Microsoft.Maui.Controls.Platform; + +using Sharpnado.Tabs.Effects; +using Sharpnado.Tabs.iOS; +using Sharpnado.Tasks; + +using UIKit; + +[assembly: ResolutionGroupName("Sharpnado")] +[assembly: ExportEffect(typeof(iOSTintableImageEffect), nameof(TintableImageEffect))] + +namespace Sharpnado.Tabs.iOS +{ + [Preserve] + public class iOSTintableImageEffect : PlatformEffect + { + private int _tintAttempts = 0; + private bool _isAttached = false; + + public static void Init() + { + } + + protected override void OnElementPropertyChanged(System.ComponentModel.PropertyChangedEventArgs args) + { + base.OnElementPropertyChanged(args); + + if ((Element is Image) && args.PropertyName == Image.SourceProperty.PropertyName) + { + _tintAttempts = 0; + UpdateColor(); + } + } + + protected override void OnAttached() + { + _tintAttempts = 0; + _isAttached = true; + UpdateColor(); + } + + protected override void OnDetached() + { + _isAttached = false; + _tintAttempts = 0; + if (Control is UIImageView imageView && imageView.Image != null) + { + imageView.Image = imageView.Image.ImageWithRenderingMode(UIImageRenderingMode.Automatic); + } + } + + private void UpdateColor() + { + if (!_isAttached || Control == null || Element == null) + { + return; + } + + var imageView = (UIImageView)Control; + var effect = (TintableImageEffect)Element.Effects.FirstOrDefault(x => x is TintableImageEffect); + + var color = effect?.TintColor.ToUIColor(); + if (color == null) + { + return; + } + + if (effect.TintColor.IsDefault()) + { + color = UIDevice.CurrentDevice.CheckSystemVersion(13, 0) + ? UIColor.Label + : UIColor.Black; + } + + Control.TintColor = color; + + if (imageView?.Image == null) + { + if (_tintAttempts < 5) + { + TaskMonitor.Create(() => DelayedPost(500, UpdateColor)); + } + + return; + } + + _tintAttempts = 0; + imageView.Image = imageView.Image.ImageWithRenderingMode(UIImageRenderingMode.AlwaysTemplate); + } + + private async Task DelayedPost(int milliseconds, Action action) + { + await Task.Delay(milliseconds); + Device.BeginInvokeOnMainThread(action); + } + } +} diff --git a/Maui.Tabs/Platforms/iOS/TouchEffectPlatform.cs b/Maui.Tabs/Platforms/iOS/TouchEffectPlatform.cs new file mode 100644 index 0000000..4fa54ba --- /dev/null +++ b/Maui.Tabs/Platforms/iOS/TouchEffectPlatform.cs @@ -0,0 +1,109 @@ +using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; +using CoreGraphics; +using UIKit; +using XamEffects; +using XamEffects.iOS; +using XamEffects.iOS.GestureCollectors; +using XamEffects.iOS.GestureRecognizers; +using Microsoft.Maui.Controls.Compatibility.Platform.iOS; +using Sharpnado.Tabs.iOS; +using System.Runtime.InteropServices; + +using Microsoft.Maui.Controls.Platform; + +[assembly: ExportEffect(typeof(TouchEffectPlatform), nameof(TouchEffect))] + +namespace XamEffects.iOS { + public class TouchEffectPlatform : PlatformEffect { + public bool IsDisposed => (Container as IVisualElementRenderer)?.Element == null; + public UIView View => Control ?? Container; + + UIView _layer; + float _alpha; + + protected override void OnAttached() { + View.UserInteractionEnabled = true; + _layer = new UIView { + UserInteractionEnabled = false, + Opaque = false, + Alpha = 0, + TranslatesAutoresizingMaskIntoConstraints = false + }; + + UpdateEffectColor(); + TouchGestureCollector.Add(View, OnTouch); + + View.AddSubview(_layer); + View.BringSubviewToFront(_layer); + _layer.TopAnchor.ConstraintEqualTo(View.TopAnchor).Active = true; + _layer.LeftAnchor.ConstraintEqualTo(View.LeftAnchor).Active = true; + _layer.BottomAnchor.ConstraintEqualTo(View.BottomAnchor).Active = true; + _layer.RightAnchor.ConstraintEqualTo(View.RightAnchor).Active = true; + } + + protected override void OnDetached() { + TouchGestureCollector.Delete(View, OnTouch); + _layer?.RemoveFromSuperview(); + _layer?.Dispose(); + } + + void OnTouch(TouchGestureRecognizer.TouchArgs e) { + switch (e.State) { + case TouchGestureRecognizer.TouchState.Started: + BringLayer(); + break; + + case TouchGestureRecognizer.TouchState.Ended: + EndAnimation(); + break; + + case TouchGestureRecognizer.TouchState.Cancelled: + if (!IsDisposed && _layer != null) { + _layer.Layer.RemoveAllAnimations(); + _layer.Alpha = 0; + } + + break; + } + } + + protected override void OnElementPropertyChanged(PropertyChangedEventArgs e) { + base.OnElementPropertyChanged(e); + + if (e.PropertyName == TouchEffect.ColorProperty.PropertyName) { + UpdateEffectColor(); + } + } + + void UpdateEffectColor() { + var color = TouchEffect.GetColor(Element); + if (color == Colors.Transparent) { + return; + } + + _alpha = color.Alpha < 1.0 ? 1 : (float)0.3; + _layer.BackgroundColor = color.ToUIColor(); + } + + void BringLayer() { + _layer.Layer.RemoveAllAnimations(); + _layer.Alpha = _alpha; + View.BringSubviewToFront(_layer); + } + + void EndAnimation() { + if (!IsDisposed && _layer != null) { + _layer.Layer.RemoveAllAnimations(); + UIView.Animate(0.225, + () => { + _layer.Alpha = 0; + }); + } + } + + public static void Init() { + } + } +} \ No newline at end of file diff --git a/Maui.Tabs/Platforms/iOS/TouchGestureCollector.cs b/Maui.Tabs/Platforms/iOS/TouchGestureCollector.cs new file mode 100644 index 0000000..b7b0a7e --- /dev/null +++ b/Maui.Tabs/Platforms/iOS/TouchGestureCollector.cs @@ -0,0 +1,58 @@ +using Sharpnado.Tabs.iOS; +using System; +using System.Collections.Generic; +using System.Linq; +using UIKit; +using XamEffects.iOS.GestureRecognizers; + +namespace XamEffects.iOS.GestureCollectors { + internal static class TouchGestureCollector { + static Dictionary Collection { get; } = + new Dictionary(); + + public static void Add(UIView view, Action action) { + if (Collection.ContainsKey(view)) { + Collection[view].Actions.Add(action); + } + else { + var gest = new TouchGestureRecognizer { + CancelsTouchesInView = false, + Delegate = new TouchGestureRecognizerDelegate(view) + }; + gest.OnTouch += ActionActivator; + Collection.Add(view, + new GestureActionsContainer { + Recognizer = gest, + Actions = new List> { action } + }); + view.AddGestureRecognizer(gest); + } + } + + public static void Delete(UIView view, Action action) { + if (!Collection.ContainsKey(view)) return; + + var ci = Collection[view]; + ci.Actions.Remove(action); + + if (ci.Actions.Count != 0) return; + view.RemoveGestureRecognizer(ci.Recognizer); + Collection.Remove(view); + } + + static void ActionActivator(object sender, TouchGestureRecognizer.TouchArgs e) { + var gest = (TouchGestureRecognizer)sender; + if (!Collection.ContainsKey(gest.View)) return; + + var actions = Collection[gest.View].Actions.ToArray(); + foreach (var valueAction in actions) { + valueAction?.Invoke(e); + } + } + + class GestureActionsContainer { + public TouchGestureRecognizer Recognizer { get; set; } + public List> Actions { get; set; } + } + } +} \ No newline at end of file diff --git a/Maui.Tabs/Platforms/iOS/TouchGestureRecognizer.cs b/Maui.Tabs/Platforms/iOS/TouchGestureRecognizer.cs new file mode 100644 index 0000000..c6d3215 --- /dev/null +++ b/Maui.Tabs/Platforms/iOS/TouchGestureRecognizer.cs @@ -0,0 +1,132 @@ +using System; +using System.Linq; +using Foundation; +using UIKit; +using Xamarin.Forms; +using System.Threading.Tasks; +using CoreGraphics; +using XamEffects.iOS.GestureCollectors; +using System.Threading; +using CoreFoundation; + +namespace XamEffects.iOS.GestureRecognizers { + public class TouchGestureRecognizer : UIGestureRecognizer { + public class TouchArgs : EventArgs { + public TouchState State { get; } + public bool Inside { get; } + + public TouchArgs(TouchState state, bool inside) { + State = state; + Inside = inside; + } + } + + public enum TouchState { + Started, + Ended, + Cancelled + } + + bool _disposed; + bool _startCalled; + + public static bool IsActive { get; private set; } + + public bool Processing => State == UIGestureRecognizerState.Began || State == UIGestureRecognizerState.Changed; + public event EventHandler OnTouch; + + public override async void TouchesBegan(NSSet touches, UIEvent evt) { + base.TouchesBegan(touches, evt); + if (Processing) + return; + + State = UIGestureRecognizerState.Began; + IsActive = true; + _startCalled = false; + + await Task.Delay(125); + DispatchQueue.MainQueue.DispatchAsync(() => { + if (!Processing || _disposed) return; + OnTouch?.Invoke(this, new TouchArgs(TouchState.Started, true)); + _startCalled = true; + }); + } + + public override void TouchesMoved(NSSet touches, UIEvent evt) { + base.TouchesMoved(touches, evt); + + var inside = View.PointInside(LocationInView(View), evt); + + if (!inside) { + if (_startCalled) + OnTouch?.Invoke(this, new TouchArgs(TouchState.Ended, false)); + State = UIGestureRecognizerState.Ended; + IsActive = false; + return; + } + + State = UIGestureRecognizerState.Changed; + } + + public override void TouchesEnded(NSSet touches, UIEvent evt) { + base.TouchesEnded(touches, evt); + + if (!_startCalled) + OnTouch?.Invoke(this, new TouchArgs(TouchState.Started, true)); + + OnTouch?.Invoke(this, new TouchArgs(TouchState.Ended, View.PointInside(LocationInView(View), null))); + State = UIGestureRecognizerState.Ended; + IsActive = false; + } + + public override void TouchesCancelled(NSSet touches, UIEvent evt) { + base.TouchesCancelled(touches, evt); + OnTouch?.Invoke(this, new TouchArgs(TouchState.Cancelled, false)); + State = UIGestureRecognizerState.Cancelled; + IsActive = false; + } + + internal void TryEndOrFail() { + if (_startCalled) { + OnTouch?.Invoke(this, new TouchArgs(TouchState.Ended, false)); + State = UIGestureRecognizerState.Ended; + } + + State = UIGestureRecognizerState.Failed; + IsActive = false; + } + + protected override void Dispose(bool disposing) { + _disposed = true; + IsActive = false; + + base.Dispose(disposing); + } + } + + public class TouchGestureRecognizerDelegate : UIGestureRecognizerDelegate { + readonly UIView _view; + + public TouchGestureRecognizerDelegate(UIView view) { + _view = view; + } + + public override bool ShouldRecognizeSimultaneously(UIGestureRecognizer gestureRecognizer, + UIGestureRecognizer otherGestureRecognizer) { + if (gestureRecognizer is TouchGestureRecognizer rec && otherGestureRecognizer is UIPanGestureRecognizer && + otherGestureRecognizer.State == UIGestureRecognizerState.Began) { + rec.TryEndOrFail(); + } + + return true; + } + + public override bool ShouldReceiveTouch(UIGestureRecognizer recognizer, UITouch touch) { + if (recognizer is TouchGestureRecognizer && TouchGestureRecognizer.IsActive) { + return false; + } + + return touch.View == _view; + } + } +} \ No newline at end of file diff --git a/Maui.Tabs/Shims/XamarinFormsInternalsNamespace.cs b/Maui.Tabs/Shims/XamarinFormsInternalsNamespace.cs new file mode 100644 index 0000000..e00b565 --- /dev/null +++ b/Maui.Tabs/Shims/XamarinFormsInternalsNamespace.cs @@ -0,0 +1 @@ +namespace Xamarin.Forms.Internals; \ No newline at end of file diff --git a/Maui.Tabs/Shims/XamarinFormsPlatformAndroidNamespace.cs b/Maui.Tabs/Shims/XamarinFormsPlatformAndroidNamespace.cs new file mode 100644 index 0000000..8c4f575 --- /dev/null +++ b/Maui.Tabs/Shims/XamarinFormsPlatformAndroidNamespace.cs @@ -0,0 +1 @@ +namespace Xamarin.Forms.Platform.Android; \ No newline at end of file diff --git a/Tabs/Tabs/Effects/ImageEffect.cs b/Tabs/Tabs/Effects/ImageEffect.cs index 1a14605..e12eebf 100644 --- a/Tabs/Tabs/Effects/ImageEffect.cs +++ b/Tabs/Tabs/Effects/ImageEffect.cs @@ -13,7 +13,11 @@ public static class ImageEffect "TintColor", typeof(Color), typeof(ImageEffect), +#if NET6_0_OR_GREATER + Colors.DodgerBlue, +#else Color.Default, +#endif propertyChanged: OnTintColorPropertyPropertyChanged); #pragma warning restore SA1401 // Fields should be private diff --git a/Tabs/Tabs/Effects/TapCommandEffect.cs b/Tabs/Tabs/Effects/TapCommandEffect.cs index aa1ce00..aa7fc10 100644 --- a/Tabs/Tabs/Effects/TapCommandEffect.cs +++ b/Tabs/Tabs/Effects/TapCommandEffect.cs @@ -6,7 +6,6 @@ namespace Sharpnado.Tabs.Effects { - [Preserve] public static class TapCommandEffect { public static readonly BindableProperty TapProperty = BindableProperty.CreateAttached( diff --git a/Tabs/Tabs/Effects/ViewEffect.cs b/Tabs/Tabs/Effects/ViewEffect.cs index fd10e6c..7495d42 100644 --- a/Tabs/Tabs/Effects/ViewEffect.cs +++ b/Tabs/Tabs/Effects/ViewEffect.cs @@ -26,7 +26,11 @@ public static class ViewEffect "TouchFeedbackColor", typeof(Color), typeof(ViewEffect), +#if NET6_0_OR_GREATER + Colors.Transparent, +#else Color.Default, +#endif propertyChanged: AttachEffect ); @@ -40,161 +44,9 @@ public static void SetTouchFeedbackColor(BindableObject view, Color value) view.SetValue(TouchFeedbackColorProperty, value); } - public static readonly BindableProperty BorderWidthProperty = BindableProperty.CreateAttached( - "BorderWidth", - typeof(double), - typeof(ViewEffect), - default(double), - BindingMode.TwoWay, propertyChanged: AttachEffect); - - public static double GetBorderWidth(BindableObject element) - { - return (double) element.GetValue(BorderWidthProperty); - } - - public static void SetBorderWidth(BindableObject element, double value) - { - element.SetValue(BorderWidthProperty, value); - } - - public static readonly BindableProperty BorderColorProperty = BindableProperty.CreateAttached( - "BorderColor", - typeof(Color), - typeof(ViewEffect), - Color.Default, - BindingMode.TwoWay, propertyChanged: AttachEffect); - - public static Color GetBorderColor(BindableObject element) - { - return (Color) element.GetValue(BorderColorProperty); - } - - public static void SetBorderColor(BindableObject element, Color value) - { - element.SetValue(BorderColorProperty, value); - } - - public static readonly BindableProperty CornerRadiusProperty = BindableProperty.CreateAttached( - "CornerRadius", - typeof(double), - typeof(ViewEffect), - default(double), - BindingMode.TwoWay, propertyChanged: AttachEffect); - - public static double GetCornerRadius(BindableObject element) - { - return (double) element.GetValue(CornerRadiusProperty); - } - - public static void SetCornerRadius(BindableObject element, double value) - { - element.SetValue(CornerRadiusProperty, value); - } - - public static readonly BindableProperty ShadowRadiusProperty = BindableProperty.CreateAttached( - "ShadowRadius", - typeof(double), - typeof(ViewEffect), - default(double), - BindingMode.TwoWay, propertyChanged: AttachEffect); - - public static double GetShadowRadius(BindableObject element) - { - return (double) element.GetValue(ShadowRadiusProperty); - } - - public static void SetShadowRadius(BindableObject element, double value) - { - element.SetValue(ShadowRadiusProperty, value); - } - - public static readonly BindableProperty ShadowColorProperty = BindableProperty.CreateAttached( - "ShadowColor", - typeof(Color), - typeof(ViewEffect), - Color.Default, - BindingMode.TwoWay, propertyChanged: AttachEffect); - - public static Color GetShadowColor(BindableObject element) - { - return (Color) element.GetValue(ShadowColorProperty); - } - - public static void SetShadowColor(BindableObject element, Color value) - { - element.SetValue(ShadowColorProperty, value); - } - - public static readonly BindableProperty ShadowOpacityProperty = BindableProperty.CreateAttached( - "ShadowOpacity", - typeof(float), - typeof(ViewEffect), - default(float), - BindingMode.TwoWay, propertyChanged: AttachEffect); - - public static float GetShadowOpacity(BindableObject element) - { - return (float) element.GetValue(ShadowOpacityProperty); - } - - public static void SetShadowOpacity(BindableObject element, float value) - { - element.SetValue(ShadowOpacityProperty, value); - } - - public static readonly BindableProperty ShadowOffsetXProperty = BindableProperty.CreateAttached( - "ShadowOffsetX", - typeof(double), - typeof(ViewEffect), - default(double), - BindingMode.TwoWay, - propertyChanged: AttachEffect - ); - - public static double GetShadowOffsetX(BindableObject element) - { - return (double) element.GetValue(ShadowOffsetXProperty); - } - - public static void SetShadowOffsetX(BindableObject element, double value) - { - element.SetValue(ShadowOffsetXProperty, value); - } - - public static readonly BindableProperty ShadowOffsetYProperty = BindableProperty.CreateAttached( - "ShadowOffsetY", - typeof(double), - typeof(ViewEffect), - default(double), - BindingMode.TwoWay, - propertyChanged: AttachEffect - ); - - public static double GetShadowOffsetY(BindableObject element) - { - return (double) element.GetValue(ShadowOffsetYProperty); - } - - public static void SetShadowOffsetY(BindableObject element, double value) - { - element.SetValue(ShadowOffsetYProperty, value); - } - - public static bool IsStyleSet(BindableObject element) - { - return ViewEffect.GetBorderColor(element) != Color.Default - || ViewEffect.GetBorderWidth(element) != default(double) - || ViewEffect.GetCornerRadius(element) != default(double) - || ViewEffect.GetShadowColor(element) != Color.Default - || ViewEffect.GetShadowOffsetX(element) != default(double) - || ViewEffect.GetShadowOffsetY(element) != default(double) - || ViewEffect.GetShadowOpacity(element) != default(float) - || ViewEffect.GetShadowRadius(element) != default(double); - } - public static bool IsTapFeedbackColorSet(BindableObject element) { - return ViewEffect.GetTouchFeedbackColor(element) != Color.Default; + return ViewEffect.GetTouchFeedbackColor(element) != Colors.Transparent; } private static void AttachEffect(BindableObject bindable, object oldValue, object newValue) diff --git a/Tabs/Tabs/TabHostView.cs b/Tabs/Tabs/TabHostView.cs index 2c1acab..9185869 100644 --- a/Tabs/Tabs/TabHostView.cs +++ b/Tabs/Tabs/TabHostView.cs @@ -693,12 +693,21 @@ private void AddTapCommand(TabItem tabItem) } else { +#if NET6_0_OR_GREATER + XamEffects.TouchEffect.SetColor(tabItem, tabItem.SelectedTabColor); + XamEffects.Commands.SetTap(tabItem, TabItemTappedCommand); + XamEffects.Commands.SetTapParameter(tabItem, tabItem); + + tabItem.Effects.Add(new XamEffects.TouchRoutingEffect()); + tabItem.Effects.Add(new XamEffects.CommandsRoutingEffect()); +#else ViewEffect.SetTouchFeedbackColor(tabItem, tabItem.SelectedTabColor); TapCommandEffect.SetTap(tabItem, TabItemTappedCommand); TapCommandEffect.SetTapParameter(tabItem, tabItem); tabItem.Effects.Add(new ViewStyleEffect()); tabItem.Effects.Add(new TapCommandRoutingEffect()); +#endif } }