From 17c948a0e9f5d9aa24e83ca7e78b268e3dbe5650 Mon Sep 17 00:00:00 2001 From: Xiaotian Gu Date: Thu, 7 Jul 2022 14:00:31 -0400 Subject: [PATCH] feat: drawer flyout (#203) --- doc/controls-styles.md | 13 +- doc/controls/DrawerFlyoutPresenter.md | 56 +++ doc/toc.yml | 2 + .../Controls/DrawerFlyoutSamplePage.xaml | 98 ++++ .../Controls/DrawerFlyoutSamplePage.xaml.cs | 41 ++ .../Uno.Toolkit.Samples.Shared.projitems | 9 +- .../Controls/DrawerControl/DrawerControl.cs | 2 +- .../DrawerFlyoutPresenter.Properties.cs | 117 +++++ .../DrawerFlyout/DrawerFlyoutPresenter.cs | 449 ++++++++++++++++++ .../DrawerFlyout/DrawerFlyoutPresenter.xaml | 106 +++++ .../Helpers/VisualTreeHelperEx.cs | 99 ++++ src/Uno.Toolkit.UI/ToolkitResources.cs | 1 + 12 files changed, 987 insertions(+), 6 deletions(-) create mode 100644 doc/controls/DrawerFlyoutPresenter.md create mode 100644 samples/Uno.Toolkit.Samples/Uno.Toolkit.Samples.Shared/Content/Controls/DrawerFlyoutSamplePage.xaml create mode 100644 samples/Uno.Toolkit.Samples/Uno.Toolkit.Samples.Shared/Content/Controls/DrawerFlyoutSamplePage.xaml.cs create mode 100644 src/Uno.Toolkit.UI/Controls/DrawerFlyout/DrawerFlyoutPresenter.Properties.cs create mode 100644 src/Uno.Toolkit.UI/Controls/DrawerFlyout/DrawerFlyoutPresenter.cs create mode 100644 src/Uno.Toolkit.UI/Controls/DrawerFlyout/DrawerFlyoutPresenter.xaml create mode 100644 src/Uno.Toolkit.UI/Helpers/VisualTreeHelperEx.cs diff --git a/doc/controls-styles.md b/doc/controls-styles.md index 638b08b22..c9cc70297 100644 --- a/doc/controls-styles.md +++ b/doc/controls-styles.md @@ -5,6 +5,7 @@ The `Uno.Toolkit.UI` library adds the following controls: - [`Chip` and `ChipGroup`](controls\ChipAndChipGroup.md): \[Material control\] Chips are compact elements that represent an input, attribute, or action. - `Divider`: \[Material control\] A divider is a thin line that groups content in lists and layouts. - [`DrawerControl`](controls\DrawerControl.md): A container to display additional content, in a hidden pane that can be revealed using a swipe gesture, like a drawer. +- [`DrawerFlyoutPresenter`](controls\DrawerFlyoutPresenter.md): A specialized `ContentPresenter` to be used in the template of a `FlyoutPresenter` to enable gesture support. - [`TabBar` and `TabBarItem`](controls\TabBarAndTabBarItem.md): A list of selectable items that can be used to facilitate lateral navigation within an application. - [`NavigationBar`](controls\NavigationBar.md): A custom control that helps implement navigation logic for your application. @@ -15,9 +16,9 @@ The `Uno.Toolkit.UI` library adds the following helper classes: - [`StatusBar`](helpers\StatusBar-extensions.md): Provides two attached properties on `Page` to controls the visual of the status bar on mobile platforms. ## Control Styles -Control|Style Key|IsDefaultStyle*| +Control|Style Key|IsDefaultStyle* -|-|- -AppBarButton|MainCommandStyle|True| +AppBarButton|MainCommandStyle|True AppBarButton|ModalMainCommandStyle| AppBarButton|PrimaryMainCommandStyle| AppBarButton|PrimaryModalMainCommandStyle| @@ -48,8 +49,12 @@ utu:ChipGroup|ElevatedFilterChipGroupStyle| utu:ChipGroup|FilterChipGroupStyle| utu:ChipGroup|ElevatedAssistChipGroupStyle| utu:ChipGroup|AssistChipGroupStyle| -utu:Divider|DividerStyle|True| -utu:NavigationBar|NavigationBarStyle|True| +utu:Divider|DividerStyle|True +FlyoutPresenter|LeftDrawerFlyoutPresenterStyle| +FlyoutPresenter|TopDrawerFlyoutPresenterStyle| +FlyoutPresenter|RightDrawerFlyoutPresenterStyle| +FlyoutPresenter|BottomDrawerFlyoutPresenterStyle| +utu:NavigationBar|NavigationBarStyle|True utu:NavigationBar|ModalNavigationBarStyle| utu:NavigationBar|PrimaryNavigationBarStyle| utu:NavigationBar|PrimaryModalNavigationBarStyle| diff --git a/doc/controls/DrawerFlyoutPresenter.md b/doc/controls/DrawerFlyoutPresenter.md new file mode 100644 index 000000000..19e25d064 --- /dev/null +++ b/doc/controls/DrawerFlyoutPresenter.md @@ -0,0 +1,56 @@ +# DrawerFlyoutPresenter +## Summary +`DrawerFlyoutPresenter` is a special `ContentPresenter` to be used in the template of a `FlyoutPresenter` to enable gesture support. + +## Properties +### Remarks +All of the properties below can be used both as a dependency property or as an attached property, much like the `ScrollViewer` properties: +```xml +xmlns:utu="using:Uno.Toolkit.UI.Controls" + + + + +``` + +### Properties +Property|Type|Description +-|-|- +OpenDirection|DrawerOpenDirection|Gets or sets the direction in which the drawer opens toward.
note: The position of drawer when opened is the opposite of this value. +LightDismissOverlayBackground|Brush|Gets or sets the brush used to paint the light dismiss overlay. The default value is `#80808080` (from the default style). +IsGestureEnabled|bool|Get or sets a value that indicates whether the user can interact with the control using gesture. The default value is `true`. + +## Usage + +To use this, simply use a `Flyout` with `Placement="Full"` and one of the followings as the `FlyoutPresenterStyle`: +> note: The direction here indicates the initial position of the drawer. The open direction is the opposite. +- `LeftDrawerFlyoutPresenterStyle` +- `TopDrawerFlyoutPresenterStyle` +- `RightDrawerFlyoutPresenterStyle` +- `BottomDrawerFlyoutPresenterStyle` + +Example: +```xml + +``` +> note: Here `VisibleBoundsPadding.PaddingMask` is used to prevent the content from being placed outside of the user-interactable area on mobile devices. diff --git a/doc/toc.yml b/doc/toc.yml index 01515f672..c8de2b07e 100644 --- a/doc/toc.yml +++ b/doc/toc.yml @@ -18,6 +18,8 @@ href: controls/ChipAndChipGroup.md - name: DrawerControl href: controls/DrawerControl.md + - name: DrawerFlyoutPresenter + href: controls/DrawerFlyoutPresenter.md - name: NavigationBar href: controls/NavigationBar.md - name: TabBar and TabBarItem diff --git a/samples/Uno.Toolkit.Samples/Uno.Toolkit.Samples.Shared/Content/Controls/DrawerFlyoutSamplePage.xaml b/samples/Uno.Toolkit.Samples/Uno.Toolkit.Samples.Shared/Content/Controls/DrawerFlyoutSamplePage.xaml new file mode 100644 index 000000000..986e69fd7 --- /dev/null +++ b/samples/Uno.Toolkit.Samples/Uno.Toolkit.Samples.Shared/Content/Controls/DrawerFlyoutSamplePage.xaml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/Uno.Toolkit.Samples/Uno.Toolkit.Samples.Shared/Content/Controls/DrawerFlyoutSamplePage.xaml.cs b/samples/Uno.Toolkit.Samples/Uno.Toolkit.Samples.Shared/Content/Controls/DrawerFlyoutSamplePage.xaml.cs new file mode 100644 index 000000000..52ea16259 --- /dev/null +++ b/samples/Uno.Toolkit.Samples/Uno.Toolkit.Samples.Shared/Content/Controls/DrawerFlyoutSamplePage.xaml.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices.WindowsRuntime; +using Uno.Toolkit.Samples.Entities; +using Uno.Toolkit.UI.Controls; +using Windows.Foundation; +using Windows.Foundation.Collections; + +#if IS_WINUI +using Microsoft.UI; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Navigation; +#else +using Windows.UI; +using Windows.UI.Xaml; +using Windows.UI.Xaml.Controls; +using Windows.UI.Xaml.Controls.Primitives; +using Windows.UI.Xaml.Data; +using Windows.UI.Xaml.Input; +using Windows.UI.Xaml.Media; +using Windows.UI.Xaml.Navigation; +#endif + +namespace Uno.Toolkit.Samples.Content.Controls +{ + [SamplePage(SampleCategory.Controls, "DrawerFlyout")] + public sealed partial class DrawerFlyoutSamplePage : Page + { + public DrawerFlyoutSamplePage() + { + this.InitializeComponent(); + } + } +} diff --git a/samples/Uno.Toolkit.Samples/Uno.Toolkit.Samples.Shared/Uno.Toolkit.Samples.Shared.projitems b/samples/Uno.Toolkit.Samples/Uno.Toolkit.Samples.Shared/Uno.Toolkit.Samples.Shared.projitems index 541fffe84..3c94fd8d7 100644 --- a/samples/Uno.Toolkit.Samples/Uno.Toolkit.Samples.Shared/Uno.Toolkit.Samples.Shared.projitems +++ b/samples/Uno.Toolkit.Samples/Uno.Toolkit.Samples.Shared/Uno.Toolkit.Samples.Shared.projitems @@ -25,6 +25,9 @@ ChipSamplePage.xaml + + DrawerFlyoutSamplePage.xaml + StatusBarSamplePage.xaml @@ -159,6 +162,10 @@ Designer MSBuild:Compile + + Designer + MSBuild:Compile + Designer MSBuild:Compile @@ -352,4 +359,4 @@ - + \ No newline at end of file diff --git a/src/Uno.Toolkit.UI/Controls/DrawerControl/DrawerControl.cs b/src/Uno.Toolkit.UI/Controls/DrawerControl/DrawerControl.cs index 49ba81646..ba0c7ee89 100644 --- a/src/Uno.Toolkit.UI/Controls/DrawerControl/DrawerControl.cs +++ b/src/Uno.Toolkit.UI/Controls/DrawerControl/DrawerControl.cs @@ -1,5 +1,5 @@ #if HAS_UNO -#define STORYBOARD_RETARGET_ISSUE // https://github.com/unoplatform/uno/issues/6960 +//#define STORYBOARD_RETARGET_ISSUE // https://github.com/unoplatform/uno/issues/6960 #define MANIPULATION_ABSOLUTE_COORD_ISSUE // https://github.com/unoplatform/uno/issues/6964 #endif diff --git a/src/Uno.Toolkit.UI/Controls/DrawerFlyout/DrawerFlyoutPresenter.Properties.cs b/src/Uno.Toolkit.UI/Controls/DrawerFlyout/DrawerFlyoutPresenter.Properties.cs new file mode 100644 index 000000000..c1fa38d41 --- /dev/null +++ b/src/Uno.Toolkit.UI/Controls/DrawerFlyout/DrawerFlyoutPresenter.Properties.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +#if IS_WINUI +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media; +#else +using Windows.UI.Xaml; +using Windows.UI.Xaml.Media; +#endif + +namespace Uno.Toolkit.UI.Controls +{ + public partial class DrawerFlyoutPresenter + { + internal static class DefaultValues + { + public const DrawerOpenDirection OpenDirection = DrawerOpenDirection.Up; + public const bool IsGestureEnabled = true; + } + + #region DependencyProperty: [Private] IsOpen + + private static DependencyProperty IsOpenProperty { get; } = DependencyProperty.Register( + nameof(IsOpen), + typeof(bool), + typeof(DrawerFlyoutPresenter), + new PropertyMetadata(default(bool), OnIsOpenChanged)); + + /// + /// Gets or sets a value that specifies whether the drawer is open. + /// + private bool IsOpen + { + get => (bool)GetValue(IsOpenProperty); + set => SetValue(IsOpenProperty, value); + } + + #endregion + + // note: These properties below can function both as a direct DP (from the owner) and as an attached DP (from any dependency object), + // just like the ScrollViewer properties. + + #region AttachedProperty: OpenDirection = Top + + public static DependencyProperty OpenDirectionProperty { get; } = DependencyProperty.RegisterAttached( + nameof(OpenDirection), + typeof(DrawerOpenDirection), + typeof(DrawerFlyoutPresenter), + new PropertyMetadata(DefaultValues.OpenDirection, OnOpenDirectionChanged)); + + /// + /// Gets or sets the direction in which the drawer opens toward. + /// + /// + /// The position of drawer when opened is the opposite of this value. + /// + public DrawerOpenDirection OpenDirection + { + get => (DrawerOpenDirection)GetValue(OpenDirectionProperty); + set => SetValue(OpenDirectionProperty, value); + } + + public static DrawerOpenDirection GetOpenDirection(DependencyObject obj) => (DrawerOpenDirection)obj.GetValue(OpenDirectionProperty); + public static void SetOpenDirection(DependencyObject obj, DrawerOpenDirection value) => obj.SetValue(OpenDirectionProperty, value); + + #endregion + #region AttachedProperty: LightDismissOverlayBackground + + public static DependencyProperty LightDismissOverlayBackgroundProperty { get; } = DependencyProperty.RegisterAttached( + nameof(LightDismissOverlayBackground), + typeof(Brush), + typeof(DrawerFlyoutPresenter), + new PropertyMetadata(default(Brush))); + + /// + /// Gets or sets the brush used to paint the light dismiss overlay. + /// + public Brush LightDismissOverlayBackground + { + get => (Brush)GetValue(LightDismissOverlayBackgroundProperty); + set => SetValue(LightDismissOverlayBackgroundProperty, value); + } + + public static Brush GetLightDismissOverlayBackground(DependencyObject obj) => (Brush)obj.GetValue(LightDismissOverlayBackgroundProperty); + public static void SetLightDismissOverlayBackground(DependencyObject obj, Brush value) => obj.SetValue(LightDismissOverlayBackgroundProperty, value); + + #endregion + #region AttachedProperty: IsGestureEnabled = true + + public static DependencyProperty IsGestureEnabledProperty { get; } = DependencyProperty.RegisterAttached( + nameof(IsGestureEnabled), + typeof(bool), + typeof(DrawerFlyoutPresenter), + new PropertyMetadata(DefaultValues.IsGestureEnabled)); + + /// + /// Get or sets a value that indicates whether the user can interact with the control using gesture. + /// + public bool IsGestureEnabled + { + get => (bool)GetValue(IsGestureEnabledProperty); + set => SetValue(IsGestureEnabledProperty, value); + } + + public static bool GetIsGestureEnabled(DependencyObject obj) => (bool)obj.GetValue(IsGestureEnabledProperty); + public static void SetIsGestureEnabled(DependencyObject obj, bool value) => obj.SetValue(IsGestureEnabledProperty, value); + + #endregion + + private static void OnOpenDirectionChanged(DependencyObject control, DependencyPropertyChangedEventArgs e) => (control as DrawerFlyoutPresenter)?.OnOpenDirectionChanged(e); + private static void OnIsOpenChanged(DependencyObject control, DependencyPropertyChangedEventArgs e) => (control as DrawerFlyoutPresenter)?.OnIsOpenChanged(e); + } +} diff --git a/src/Uno.Toolkit.UI/Controls/DrawerFlyout/DrawerFlyoutPresenter.cs b/src/Uno.Toolkit.UI/Controls/DrawerFlyout/DrawerFlyoutPresenter.cs new file mode 100644 index 000000000..a0e445531 --- /dev/null +++ b/src/Uno.Toolkit.UI/Controls/DrawerFlyout/DrawerFlyoutPresenter.cs @@ -0,0 +1,449 @@ +#if HAS_UNO +//#define STORYBOARD_RETARGET_ISSUE // PATCHED https://github.com/unoplatform/uno/issues/6960 +#define MANIPULATION_ABSOLUTE_COORD_ISSUE // https://github.com/unoplatform/uno/issues/6964 +#endif + +#nullable disable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +#if IS_WINUI +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; +using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Animation; + +using XamlWindow = Microsoft.UI.Xaml.Window; +#else +using Windows.UI.Xaml; +using Windows.UI.Xaml.Controls; +using Windows.UI.Xaml.Controls.Primitives; +using Windows.UI.Xaml.Input; +using Windows.UI.Xaml.Media; +using Windows.UI.Xaml.Media.Animation; + +using XamlWindow = Windows.UI.Xaml.Window; +#endif + +namespace Uno.Toolkit.UI.Controls +{ + public partial class DrawerFlyoutPresenter + { + public static class TemplateParts + { + public const string LightDismissOverlay = nameof(LightDismissOverlay); + public const string DrawerContentPresenter = nameof(DrawerContentPresenter); + } + + private const double DragToggleThresholdRatio = 1.0 / 3; + private const double AnimateSnappingThresholdRatio = 0.95; + private static readonly TimeSpan AnimationDuration = TimeSpan.FromMilliseconds(250); + } + + [TemplatePart(Name = TemplateParts.LightDismissOverlay, Type = typeof(Border))] + [TemplatePart(Name = TemplateParts.DrawerContentPresenter, Type = typeof(ContentPresenter))] + public partial class DrawerFlyoutPresenter : ContentControl + { + // template parts + private Border _lightDismissOverlay; + private ContentPresenter _drawerContentPresenter; + + // references + private TranslateTransform _drawerContentPresenterTransform; + private Storyboard _storyboard = new Storyboard(); + private DoubleAnimation _translateAnimation; + private Popup _popup; + + // states + private bool _isReady = false; + private bool _isGestureCaptured = false; + private bool _initOnceOnLoaded = true; + private bool _initOnceOnLayoutUpdated = true; + private double _startingTranslateOffset = 0; + private bool _suppressIsOpenHandler = false; + + public DrawerFlyoutPresenter() + { + DefaultStyleKey = typeof(DrawerFlyoutPresenter); + } + + protected override void OnApplyTemplate() + { + if (_isReady) throw new Exception("unexpected: Template is being re-applied."); + + base.OnApplyTemplate(); + + T FindTemplatePart(string name) where T : class => + (GetTemplateChild(name) ?? throw new Exception($"Expected template part not found: {name}")) + as T ?? throw new Exception($"Expected template part '{name}' to be of type: {typeof(T)}"); + + _lightDismissOverlay = FindTemplatePart(TemplateParts.LightDismissOverlay); + _drawerContentPresenter = FindTemplatePart(TemplateParts.DrawerContentPresenter); + + _drawerContentPresenter.RenderTransform = _drawerContentPresenterTransform = new TranslateTransform(); + _translateAnimation = new DoubleAnimation { Duration = new Duration(AnimationDuration) }; + Storyboard.SetTarget(_translateAnimation, _drawerContentPresenterTransform); + UpdateTranslateAnimationTargetProperty(); + _storyboard.Children.Add(_translateAnimation); + + UpdateSwipeContentPresenterLayout(); + UpdateManipulationMode(); + ManipulationStarted += OnManipulationStarted; + ManipulationDelta += OnManipulationDelta; + ManipulationCompleted += OnManipulationCompleted; + + _lightDismissOverlay.Tapped += OnLightDismissOverlayTapped; + +#if HAS_UNO // uno: the visual tree parent is not set, until Loaded. + Loaded += (s, e) => +#endif + { + if (_initOnceOnLoaded) + { + _initOnceOnLoaded = false; + + _popup = FindHostPopup() ?? throw new Exception("Unable to find host popup."); + _popup.Opened += OnPopupOpened; + _storyboard.Completed += (s, e) => + { + if (!IsOpen) + { + _popup.IsOpen = false; + } + }; + } + }; + + // note: by the time we got here, the popup would be already opened, thus we will miss the first opened event. + // in order to catch it, we use LayoutUpdated; Loaded event cannot be used here, as the _drawerContentPresenter + // still don't have its Actual(Width|Height) set which are needed for changing the position. + LayoutUpdated += OnLayoutUpdated; + + _isReady = true; + } + + private void OnLayoutUpdated(object sender, object e) + { + if (_initOnceOnLayoutUpdated) + { + _initOnceOnLayoutUpdated = false; + + // reset to close position, and animate to open position + UpdateOpenness(false); + UpdateIsOpen(true, animate: true); + } + } + + private void OnPopupOpened(object sender, object e) + { + // reset to close position, and animate to open position + UpdateOpenness(false); + UpdateIsOpen(true, animate: true); + } + + private void OnIsOpenChanged(DependencyPropertyChangedEventArgs e) + { + if (!_isReady) return; + if (_suppressIsOpenHandler) return; + + StopRunningAnimation(); + UpdateIsOpen((bool)e.NewValue, animate: IsLoaded); + } + + private void OnOpenDirectionChanged(DependencyPropertyChangedEventArgs e) + { + if (!_isReady) return; + + StopRunningAnimation(); + UpdateSwipeContentPresenterLayout(); + UpdateManipulationMode(); + UpdateTranslateAnimationTargetProperty(); + ResetOtherAxisTranslateOffset(); + UpdateIsOpen(IsOpen, animate: false); + } + + private void OnManipulationStarted(object sender, ManipulationStartedRoutedEventArgs e) + { + if (!ShouldHandleManipulationFrom(e.OriginalSource)) return; + if (!IsGestureEnabled) return; + + var position = +#if MANIPULATION_ABSOLUTE_COORD_ISSUE + this.TransformToVisual(null).Inverse.TransformPoint(e.Position); +#else + e.Position; +#endif + + _isGestureCaptured = true; + StopRunningAnimation(); + _startingTranslateOffset = TranslateOffset; + + e.Handled = true; + } + + private void OnManipulationDelta(object sender, ManipulationDeltaRoutedEventArgs e) + { + if (!_isGestureCaptured || !IsGestureEnabled) return; + e.Handled = true; + + var length = GetActualDrawerDepth(); + var cumulative = IsOpenDirectionHorizontal() ? e.Cumulative.Translation.X : e.Cumulative.Translation.Y; + var currentOffset = UseNegativeTranslation() + ? Clamp(-length, _startingTranslateOffset + cumulative, 0) + : Clamp(0, _startingTranslateOffset + cumulative, length); + var ratio = Math.Abs(currentOffset) / length; + + UpdateOpenness((1 - ratio)); + } + + private void OnManipulationCompleted(object sender, ManipulationCompletedRoutedEventArgs e) + { + if (!_isGestureCaptured || !IsGestureEnabled) return; + _isGestureCaptured = false; + e.Handled = true; + + StopRunningAnimation(); + var length = GetActualDrawerDepth(); + var cumulative = IsOpenDirectionHorizontal() ? e.Cumulative.Translation.X : e.Cumulative.Translation.Y; + + var isInCorrectDirection = Math.Sign(cumulative) == (IsOpen ^ UseNegativeTranslation() ? 1 : -1); + var isPastThresholdRatio = Math.Abs(cumulative / length) >= DragToggleThresholdRatio; + + UpdateIsOpen(IsOpen ^ (isInCorrectDirection && isPastThresholdRatio)); + } + + private void OnLightDismissOverlayTapped(object sender, TappedRoutedEventArgs e) + { + StopRunningAnimation(); + UpdateIsOpen(false); + } + + private void UpdateIsOpen(bool willBeOpen, bool animate = true) + { + var length = GetActualDrawerDepth(); + var currentOffset = TranslateOffset; + var targetOffset = GetSnappingOffsetFor(willBeOpen); + var relativeDistanceRatio = Math.Abs(Math.Abs(currentOffset) - Math.Abs(targetOffset)) / length; + + var shouldSkipAnimation = !animate || ((1 - relativeDistanceRatio) >= AnimateSnappingThresholdRatio); + if (shouldSkipAnimation) + { + UpdateOpenness(willBeOpen); + UpdateIsOpenWithSuppress(willBeOpen); + if (!willBeOpen && _popup != null) + { + _popup.IsOpen = false; + } + } + else + { + UpdateIsOpenWithSuppress(willBeOpen); + PlayAnimation(currentOffset / GetVectoredLength(), willBeOpen); + // note: the popup will be closed on Storyboard.Completed + } + + void UpdateIsOpenWithSuppress(bool value) + { + try + { + _suppressIsOpenHandler = true; + IsOpen = value; + } + finally + { + _suppressIsOpenHandler = false; + } + } + } + + private void UpdateOpenness(bool isOpen) => UpdateOpenness(isOpen ? 1 : 0); + + private void UpdateOpenness(double ratio) + { + TranslateOffset = (1 - ratio) * GetVectoredLength(); + } + + private void PlayAnimation(double fromRatio, bool willBeOpen) + { + if (_storyboard == null) return; + + var toRatio = willBeOpen ? 0 : 1; + if (_translateAnimation != null) + { + var vectoredLength = GetVectoredLength(); + + _translateAnimation.From = fromRatio * vectoredLength; + _translateAnimation.To = toRatio * vectoredLength; + } + + _storyboard.Begin(); + } + + private void StopRunningAnimation() + { + if (_storyboard != null && _storyboard.GetCurrentState() != ClockState.Stopped) + { + // we want to Pause() the animation midway to avoid the jarring feeling + // but since paused state will still yield ClockState.Active + // we have to actually use Stop() in order to differentiate + + // pause & snapshot the animated values in the middle of animation + _storyboard.Pause(); + var offset = TranslateOffset; + + // restore the values after stopping it + _storyboard.Stop(); + TranslateOffset = offset; + } + } + + private void UpdateManipulationMode() + { + ManipulationMode = IsOpenDirectionHorizontal() ? ManipulationModes.TranslateX : ManipulationModes.TranslateY; + } + + private void UpdateSwipeContentPresenterLayout() + { + if (_drawerContentPresenter == null) return; + + switch (OpenDirection) + { + case DrawerOpenDirection.Left: + _drawerContentPresenter.HorizontalAlignment = HorizontalAlignment.Right; + _drawerContentPresenter.VerticalAlignment = VerticalAlignment.Stretch; + _drawerContentPresenter.HorizontalContentAlignment = HorizontalAlignment.Right; + _drawerContentPresenter.VerticalContentAlignment = VerticalAlignment.Stretch; + break; + + case DrawerOpenDirection.Down: + _drawerContentPresenter.HorizontalAlignment = HorizontalAlignment.Stretch; + _drawerContentPresenter.VerticalAlignment = VerticalAlignment.Top; + _drawerContentPresenter.HorizontalContentAlignment = HorizontalAlignment.Stretch; + _drawerContentPresenter.VerticalContentAlignment = VerticalAlignment.Top; + break; + + case DrawerOpenDirection.Up: + _drawerContentPresenter.HorizontalAlignment = HorizontalAlignment.Stretch; + _drawerContentPresenter.VerticalAlignment = VerticalAlignment.Bottom; + _drawerContentPresenter.HorizontalContentAlignment = HorizontalAlignment.Stretch; + _drawerContentPresenter.VerticalContentAlignment = VerticalAlignment.Bottom; + break; + + case DrawerOpenDirection.Right: + default: + _drawerContentPresenter.HorizontalAlignment = HorizontalAlignment.Left; + _drawerContentPresenter.VerticalAlignment = VerticalAlignment.Stretch; + _drawerContentPresenter.HorizontalContentAlignment = HorizontalAlignment.Left; + _drawerContentPresenter.VerticalContentAlignment = VerticalAlignment.Stretch; + break; + } + } + + private void UpdateTranslateAnimationTargetProperty() + { + if (_translateAnimation == null) return; + + var property = IsOpenDirectionHorizontal() ? nameof(_drawerContentPresenterTransform.X) : nameof(_drawerContentPresenterTransform.Y); + Storyboard.SetTargetProperty(_translateAnimation, property); + } + + private void ResetOtherAxisTranslateOffset() + { + if (IsOpenDirectionHorizontal()) + { + _drawerContentPresenterTransform.Y = 0; + } + else + { + _drawerContentPresenterTransform.X = 0; + } + } + } + + public partial class DrawerFlyoutPresenter // helpers + { + private double TranslateOffset + { + get => IsOpenDirectionHorizontal() ? _drawerContentPresenterTransform.X : _drawerContentPresenterTransform.Y; + set + { + if (IsOpenDirectionHorizontal()) _drawerContentPresenterTransform.X = value; + else _drawerContentPresenterTransform.Y = value; + } + } + + private bool ShouldHandleManipulationFrom(object source) + { + // only the content area should respond to gesture + if (source == _lightDismissOverlay) return false; + + // note: on uwp, we cant distinguish the origin of the event, as it would always be from this DrawerFlyoutPresenter. + return source == this + || (source is DependencyObject sourceAsDO && VisualTreeHelperEx.GetAncestors(sourceAsDO).Any(x => x == this)); + } + + private double GetSnappingOffsetFor(bool isOpen) + { + return isOpen ? 0 : GetVectoredLength(); + } + + private bool IsOpenDirectionHorizontal() + { + return OpenDirection switch + { + DrawerOpenDirection.Down => false, + DrawerOpenDirection.Up => false, + + _ => true, + }; + } + + private bool UseNegativeTranslation() + { + return OpenDirection switch + { + DrawerOpenDirection.Left => false, + DrawerOpenDirection.Up => false, + + _ => true, + }; + } + + private double GetActualDrawerDepth() + { + if (_drawerContentPresenter == null) throw new InvalidOperationException($"{nameof(_drawerContentPresenter)} is null"); + + return IsOpenDirectionHorizontal() + ? _drawerContentPresenter.ActualWidth + : _drawerContentPresenter.ActualHeight; + } + + private double GetVectoredLength() + { + return UseNegativeTranslation() ? -GetActualDrawerDepth() : GetActualDrawerDepth(); + } + + private Popup FindHostPopup() + { + if (VisualTreeHelper.GetParent(this) is FlyoutPresenter parent) + { + return VisualTreeHelper + .GetOpenPopups(XamlWindow.Current) + .FirstOrDefault(x => x.Child == parent); + } + + return default; + } + + private static double Clamp(double min, double value, double max) + { + return Math.Max(Math.Min(value, max), min); + } + } +} diff --git a/src/Uno.Toolkit.UI/Controls/DrawerFlyout/DrawerFlyoutPresenter.xaml b/src/Uno.Toolkit.UI/Controls/DrawerFlyout/DrawerFlyoutPresenter.xaml new file mode 100644 index 000000000..2057e3157 --- /dev/null +++ b/src/Uno.Toolkit.UI/Controls/DrawerFlyout/DrawerFlyoutPresenter.xaml @@ -0,0 +1,106 @@ + + + + + + + + + diff --git a/src/Uno.Toolkit.UI/Helpers/VisualTreeHelperEx.cs b/src/Uno.Toolkit.UI/Helpers/VisualTreeHelperEx.cs new file mode 100644 index 000000000..0bccfc583 --- /dev/null +++ b/src/Uno.Toolkit.UI/Helpers/VisualTreeHelperEx.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +#if IS_WINUI +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media; +#else +using Windows.UI.Xaml; +using Windows.UI.Xaml.Media; +#endif + +namespace Uno.Toolkit.UI +{ + internal static class VisualTreeHelperEx + { + public static string TreeGraph(this DependencyObject reference) => TreeGraph(reference, DebugVTNode); + + public static string TreeGraph(this DependencyObject reference, Func describe) + { + var buffer = new StringBuilder(); + Walk(reference); + return buffer.ToString(); + + void Walk(DependencyObject o, int depth = 0) + { + Print(o, depth); + foreach (var child in GetChildren(o)) + { + Walk(child, depth + 1); + } + } + void Print(DependencyObject o, int depth) + { + buffer + .Append(new string(' ', depth * 4)) + .Append(describe(o)) + .AppendLine(); + } + } + + private static string DebugVTNode(DependencyObject x) + { + var fe = x as FrameworkElement; + + return new StringBuilder() + .Append(x.GetType().Name) + .Append(!string.IsNullOrEmpty(fe?.Name) ? $"#{fe?.Name}" : string.Empty) + .ToString(); + } + + + public static T GetFirstAncestor(this DependencyObject reference) => GetAncestors(reference) + .OfType() + .FirstOrDefault(); + + public static T GetFirstAncestor(this DependencyObject reference, Func predicate) => GetAncestors(reference) + .OfType() + .FirstOrDefault(predicate); + + public static T GetFirstDescendant(DependencyObject reference) => GetDescendants(reference) + .OfType() + .FirstOrDefault(); + + public static T GetFirstDescendant(DependencyObject reference, Func predicate) => GetDescendants(reference) + .OfType() + .FirstOrDefault(predicate); + + public static IEnumerable GetAncestors(this DependencyObject o) + { + if (o is null) yield break; + while (VisualTreeHelper.GetParent(o) is { } parent) + { + yield return o = parent; + } + } + + public static IEnumerable GetDescendants(DependencyObject reference) + { + foreach (var child in GetChildren(reference)) + { + yield return child; + foreach (var grandchild in GetDescendants(child)) + { + yield return grandchild; + } + } + } + + public static IEnumerable GetChildren(DependencyObject reference) + { + return Enumerable + .Range(0, VisualTreeHelper.GetChildrenCount(reference)) + .Select(x => VisualTreeHelper.GetChild(reference, x)); + } + } +} diff --git a/src/Uno.Toolkit.UI/ToolkitResources.cs b/src/Uno.Toolkit.UI/ToolkitResources.cs index 07349f888..1b5d09535 100644 --- a/src/Uno.Toolkit.UI/ToolkitResources.cs +++ b/src/Uno.Toolkit.UI/ToolkitResources.cs @@ -30,6 +30,7 @@ public ToolkitResources() { "DrawerControl/DrawerControl.xaml", "DrawerControl/DrawerControl.Enhanced.xaml", + "DrawerFlyout/DrawerFlyoutPresenter.xaml", "TabBar/TabBar.xaml", "AutoLayout/AutoLayout.xaml", #if __IOS__ || __ANDROID__