From a03e20e1150bfe4e86469f15938a4ed34b39fcad Mon Sep 17 00:00:00 2001 From: "alexander.marek" Date: Thu, 12 Mar 2026 13:56:46 +0100 Subject: [PATCH] #20880 - added swipe gesture support for SplitView and enabled it by default on mobile platforms --- .../ControlCatalog/Pages/SplitViewPage.xaml | 4 + src/Avalonia.Controls/SplitView/SplitView.cs | 477 +++++++++++++++++- .../Controls/SplitView.xaml | 1 + .../Controls/SplitView.xaml | 1 + 4 files changed, 479 insertions(+), 4 deletions(-) diff --git a/samples/ControlCatalog/Pages/SplitViewPage.xaml b/samples/ControlCatalog/Pages/SplitViewPage.xaml index 201765ad88..19be8d6c29 100644 --- a/samples/ControlCatalog/Pages/SplitViewPage.xaml +++ b/samples/ControlCatalog/Pages/SplitViewPage.xaml @@ -20,6 +20,10 @@ Content="UseLightDismissOverlayMode" IsChecked="{Binding UseLightDismissOverlayMode, ElementName=SplitView}" /> + + Left Right diff --git a/src/Avalonia.Controls/SplitView/SplitView.cs b/src/Avalonia.Controls/SplitView/SplitView.cs index 14054d183f..5a2701f198 100644 --- a/src/Avalonia.Controls/SplitView/SplitView.cs +++ b/src/Avalonia.Controls/SplitView/SplitView.cs @@ -1,14 +1,19 @@ -using System; +using System; +using System.Collections.Generic; +using Avalonia.Animation; +using Avalonia.Animation.Easings; using Avalonia.Controls.Metadata; using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; using Avalonia.Input; using Avalonia.Interactivity; +using Avalonia.Layout; using Avalonia.LogicalTree; using Avalonia.Media; using Avalonia.Metadata; using Avalonia.Reactive; +using Avalonia.Threading; namespace Avalonia.Controls { @@ -33,6 +38,17 @@ namespace Avalonia.Controls private const string pcTop = ":top"; private const string pcBottom = ":bottom"; private const string pcLightDismiss = ":lightDismiss"; + #region Swipe gesture constants + + private const double SwipeEdgeZoneFraction = 1.0 / 3.0; + private const double SwipeDirectionLockThreshold = 10; + private const double SwipeVelocityThreshold = 800; + private const double SwipeOpenPositionThreshold = 0.4; + private const double SwipeClosePositionThreshold = 0.6; + private const int SwipeSnapDurationMs = 200; + private const int SwipeVelocitySampleCount = 5; + + #endregion /// /// Defines the property @@ -97,6 +113,12 @@ namespace Avalonia.Controls public static readonly StyledProperty UseLightDismissOverlayModeProperty = AvaloniaProperty.Register(nameof(UseLightDismissOverlayMode)); + /// + /// Defines the property + /// + public static readonly StyledProperty IsSwipeToOpenEnabledProperty = + AvaloniaProperty.Register(nameof(IsSwipeToOpenEnabled), defaultValue: false); + /// /// Defines the property /// @@ -142,8 +164,21 @@ namespace Avalonia.Controls private string? _lastDisplayModePseudoclass; private string? _lastPlacementPseudoclass; + #region Swipe gesture state + + private bool _isSwipeDragging; + private bool _isSwipeDirectionLocked; + private bool _isSwipeAnimating; + private bool _isSwipeToClose; + private Point _swipeStartPoint; + private double _swipeCurrentPaneSize; + private bool _swipeHandlersAttached; + private readonly List<(DateTime time, double position)> _swipeVelocitySamples = new(); + + #endregion + /// - /// Gets or sets the length of the pane when in + /// Gets or sets the length of the pane when in /// or mode /// public double CompactPaneLength @@ -228,6 +263,17 @@ namespace Avalonia.Controls set => SetValue(UseLightDismissOverlayModeProperty, value); } + /// + /// Gets or sets whether swipe-from-edge gesture is enabled for opening/closing the pane. + /// When enabled, the user can swipe from the pane edge to open the pane, + /// and swipe the open pane back to close it. Supports both touch and mouse input. + /// + public bool IsSwipeToOpenEnabled + { + get => GetValue(IsSwipeToOpenEnabledProperty); + set => SetValue(IsSwipeToOpenEnabledProperty, value); + } + /// /// Gets or sets the TemplateSettings for the . /// @@ -237,6 +283,11 @@ namespace Avalonia.Controls private set => SetAndRaise(TemplateSettingsProperty, ref _templateSettings, value); } + /// + /// Gets whether a swipe gesture is currently in progress (dragging or snap-animating). + /// + public bool IsSwipeGestureActive => _isSwipeDirectionLocked || _isSwipeAnimating; + /// /// Fired when the pane is closed. /// @@ -310,6 +361,8 @@ namespace Avalonia.Controls // soon as we're attached so the template applies. The other visual states can // be updated after the template applies UpdateVisualStateForPanePlacementProperty(PanePlacement); + + AttachSwipeHandlers(); } protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) @@ -317,6 +370,8 @@ namespace Avalonia.Controls base.OnDetachedFromVisualTree(e); _pointerDisposable?.Dispose(); _pointerDisposable = null; + + DetachSwipeHandlers(); } /// @@ -384,6 +439,9 @@ namespace Avalonia.Controls private void PointerReleasedOutside(object? sender, PointerReleasedEventArgs e) { + if (IsSwipeGestureActive) + return; + if (!IsPaneOpen || _pane == null) { return; @@ -396,7 +454,7 @@ namespace Avalonia.Controls // Make assumption that if Popup is in visual tree, // owning control is within pane // This works because if pane is triggered to close - // when clicked anywhere else in Window, the pane + // when clicked anywhere else in Window, the pane // would close before the popup is opened if (src == _pane || src is PopupRoot) { @@ -547,7 +605,7 @@ namespace Avalonia.Controls }; TemplateSettings.ClosedPaneHeight = closedPaneHeight; TemplateSettings.PaneRowGridLength = paneRowGridLength; - + InvalidateLightDismissSubscription(); } @@ -631,5 +689,416 @@ namespace Avalonia.Controls return value; } + + #region Swipe gesture handling + + private bool IsHorizontalPlacement => + PanePlacement == SplitViewPanePlacement.Left || + PanePlacement == SplitViewPanePlacement.Right; + + /// + /// Returns true when the "open" direction is positive in the drag axis. + /// For Left placement (LTR): rightward = positive X = true. + /// For Right placement (LTR): leftward = negative X = false. + /// RTL inverts horizontal placements. + /// For Top: downward = positive Y = true. + /// For Bottom: upward = negative Y = false. + /// + private bool IsOpenDirectionPositive + { + get + { + var placement = PanePlacement; + bool isRtl = FlowDirection == FlowDirection.RightToLeft; + return placement switch + { + SplitViewPanePlacement.Left => !isRtl, + SplitViewPanePlacement.Right => isRtl, + SplitViewPanePlacement.Top => true, + SplitViewPanePlacement.Bottom => false, + _ => true + }; + } + } + + private void AttachSwipeHandlers() + { + if (_swipeHandlersAttached) + return; + _swipeHandlersAttached = true; + + AddHandler(InputElement.PointerPressedEvent, OnSwipePointerPressed, RoutingStrategies.Tunnel); + AddHandler(InputElement.PointerMovedEvent, OnSwipePointerMoved, RoutingStrategies.Tunnel); + AddHandler(InputElement.PointerReleasedEvent, OnSwipePointerReleased, RoutingStrategies.Tunnel); + AddHandler(InputElement.PointerCaptureLostEvent, OnSwipePointerCaptureLost, RoutingStrategies.Tunnel); + SizeChanged += OnSwipeSizeChanged; + } + + private void DetachSwipeHandlers() + { + if (!_swipeHandlersAttached) + return; + _swipeHandlersAttached = false; + + RemoveHandler(InputElement.PointerPressedEvent, OnSwipePointerPressed); + RemoveHandler(InputElement.PointerMovedEvent, OnSwipePointerMoved); + RemoveHandler(InputElement.PointerReleasedEvent, OnSwipePointerReleased); + RemoveHandler(InputElement.PointerCaptureLostEvent, OnSwipePointerCaptureLost); + SizeChanged -= OnSwipeSizeChanged; + } + + private bool IsInSwipeEdgeZone(Point point) + { + var placement = PanePlacement; + bool isRtl = FlowDirection == FlowDirection.RightToLeft; + + if (IsHorizontalPlacement) + { + var width = Bounds.Width; + var edgeZone = width * SwipeEdgeZoneFraction; + + bool paneOnLeft = (placement == SplitViewPanePlacement.Left && !isRtl) || + (placement == SplitViewPanePlacement.Right && isRtl); + return paneOnLeft ? point.X <= edgeZone : point.X >= width - edgeZone; + } + else + { + var height = Bounds.Height; + var edgeZone = height * SwipeEdgeZoneFraction; + + return placement == SplitViewPanePlacement.Top + ? point.Y <= edgeZone + : point.Y >= height - edgeZone; + } + } + + /// + /// Gets the drag delta along the relevant axis, signed so that + /// positive = toward-open direction. + /// + private double GetSwipeDelta(Point current) + { + double raw; + if (IsHorizontalPlacement) + raw = current.X - _swipeStartPoint.X; + else + raw = current.Y - _swipeStartPoint.Y; + + return IsOpenDirectionPositive ? raw : -raw; + } + + /// + /// Gets the position along the drag axis for velocity tracking. + /// + private double GetSwipeAxisPosition(Point point) => + IsHorizontalPlacement ? point.X : point.Y; + + private void SetSwipePaneSize(double size) + { + if (_pane == null) return; + _swipeCurrentPaneSize = size; + + if (IsHorizontalPlacement) + _pane.Width = size; + else + _pane.Height = size; + } + + private void ClearSwipePaneSizeOverride() + { + if (_pane == null) return; + + if (IsHorizontalPlacement) + _pane.ClearValue(Layoutable.WidthProperty); + else + _pane.ClearValue(Layoutable.HeightProperty); + } + + private void SuppressPaneTransitions() + { + if (_pane == null) return; + _pane.SetValue(Animatable.TransitionsProperty, new Transitions()); + } + + private void RestorePaneTransitions() + { + _pane?.ClearValue(Animatable.TransitionsProperty); + } + + private void OnSwipePointerPressed(object? sender, PointerPressedEventArgs e) + { + if (!IsSwipeToOpenEnabled || _isSwipeDragging || _isSwipeAnimating || _pane == null) + return; + + var pointerType = e.Pointer.Type; + if (pointerType != PointerType.Touch && pointerType != PointerType.Mouse) + return; + + var point = e.GetPosition(this); + + if (IsPaneOpen) + { + _isSwipeToClose = true; + } + else + { + if (!IsInSwipeEdgeZone(point)) + return; + _isSwipeToClose = false; + } + + _swipeStartPoint = point; + _isSwipeDragging = true; + _isSwipeDirectionLocked = false; + _swipeVelocitySamples.Clear(); + SwipeRecordVelocitySample(GetSwipeAxisPosition(point)); + } + + private void OnSwipePointerMoved(object? sender, PointerEventArgs e) + { + if (!_isSwipeDragging || _pane == null) + return; + + var point = e.GetPosition(this); + var delta = GetSwipeDelta(point); + + if (!_isSwipeDirectionLocked) + { + double absPrimary, absSecondary; + if (IsHorizontalPlacement) + { + absPrimary = Math.Abs(point.X - _swipeStartPoint.X); + absSecondary = Math.Abs(point.Y - _swipeStartPoint.Y); + } + else + { + absPrimary = Math.Abs(point.Y - _swipeStartPoint.Y); + absSecondary = Math.Abs(point.X - _swipeStartPoint.X); + } + + if (absPrimary < SwipeDirectionLockThreshold && + absSecondary < SwipeDirectionLockThreshold) + return; + + // Must be more along the primary axis than the secondary + if (absSecondary > absPrimary) + { + SwipeCancelDrag(e.Pointer); + return; + } + + // For open: delta must be positive (toward open) + if (!_isSwipeToClose && delta <= 0) + { + SwipeCancelDrag(e.Pointer); + return; + } + + // For close: delta must be negative (toward close) + if (_isSwipeToClose && delta >= 0) + { + SwipeCancelDrag(e.Pointer); + return; + } + + _isSwipeDirectionLocked = true; + e.Pointer.Capture(this); + + // Suppress XAML theme transitions during drag + SuppressPaneTransitions(); + + + // For swipe-to-open: start with pane at 0 size + if (!_isSwipeToClose) + { + SetSwipePaneSize(0); + } + } + + SwipeRecordVelocitySample(GetSwipeAxisPosition(point)); + + var target = OpenPaneLength; + if (target <= 0) return; + + if (_isSwipeToClose) + { + var absDelta = Math.Abs(delta); + _swipeCurrentPaneSize = Math.Max(0, Math.Min(target, target - absDelta)); + } + else + { + _swipeCurrentPaneSize = Math.Max(0, Math.Min(target, delta)); + } + + SetSwipePaneSize(_swipeCurrentPaneSize); + e.Handled = true; + } + + private void OnSwipePointerReleased(object? sender, PointerReleasedEventArgs e) + { + if (!_isSwipeDragging) + return; + + var point = e.GetPosition(this); + SwipeRecordVelocitySample(GetSwipeAxisPosition(point)); + + if (!_isSwipeDirectionLocked) + { + // No gesture occurred — don't release capture (would steal button's capture) + _isSwipeDragging = false; + return; + } + + // Clear _isSwipeDragging BEFORE releasing capture so that the synchronous + // OnSwipePointerCaptureLost handler is a no-op (it also checks _isSwipeDragging). + _isSwipeDragging = false; + e.Pointer.Capture(null); + + var target = OpenPaneLength; + var velocity = SwipeCalculateVelocity(); + + bool shouldOpen; + if (_isSwipeToClose) + { + // Velocity is in raw axis units; convert to open-direction sign + double openDirVelocity = IsOpenDirectionPositive ? -velocity : velocity; + shouldOpen = !(openDirVelocity > SwipeVelocityThreshold || + _swipeCurrentPaneSize < target * SwipeClosePositionThreshold); + } + else + { + double openDirVelocity = IsOpenDirectionPositive ? velocity : -velocity; + shouldOpen = openDirVelocity > SwipeVelocityThreshold || + _swipeCurrentPaneSize > target * SwipeOpenPositionThreshold; + } + + SwipeAnimateToState(shouldOpen, target); + } + + private void OnSwipePointerCaptureLost(object? sender, PointerCaptureLostEventArgs e) + { + if (!_isSwipeDragging) + return; + + if (_isSwipeDirectionLocked) + { + var wasOpen = _isSwipeToClose; + _isSwipeDragging = false; + SwipeAnimateToState(wasOpen, OpenPaneLength); + } + else + { + _isSwipeDragging = false; + } + } + + private void OnSwipeSizeChanged(object? sender, SizeChangedEventArgs e) + { + if (!_isSwipeDragging && !_isSwipeDirectionLocked) + return; + + // Cancel gesture on resize + var wasOpen = _isSwipeToClose; + _isSwipeDragging = false; + _isSwipeDirectionLocked = false; + _isSwipeAnimating = false; + + ClearSwipePaneSizeOverride(); + RestorePaneTransitions(); + SetCurrentValue(IsPaneOpenProperty, wasOpen); + } + + private void SwipeCancelDrag(IPointer pointer) + { + _isSwipeDragging = false; + _isSwipeDirectionLocked = false; + pointer.Capture(null); + } + + private void SwipeRecordVelocitySample(double position) + { + _swipeVelocitySamples.Add((DateTime.UtcNow, position)); + while (_swipeVelocitySamples.Count > SwipeVelocitySampleCount) + _swipeVelocitySamples.RemoveAt(0); + } + + private double SwipeCalculateVelocity() + { + if (_swipeVelocitySamples.Count < 2) return 0; + + var first = _swipeVelocitySamples[0]; + var last = _swipeVelocitySamples[_swipeVelocitySamples.Count - 1]; + var dt = (last.time - first.time).TotalSeconds; + if (dt <= 0) return 0; + + return (last.position - first.position) / dt; + } + + private void SwipeAnimateToState(bool open, double targetWidth) + { + var from = _swipeCurrentPaneSize; + var to = open ? targetWidth : 0; + + if (Math.Abs(from - to) < 1) + { + SwipeFinalizeState(open, targetWidth); + return; + } + + _isSwipeAnimating = true; + var easing = new CubicEaseOut(); + var startTime = DateTime.UtcNow; + var duration = TimeSpan.FromMilliseconds(SwipeSnapDurationMs); + + var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; + timer.Tick += (_, _) => + { + var elapsed = DateTime.UtcNow - startTime; + var progress = Math.Min(1.0, elapsed.TotalMilliseconds / duration.TotalMilliseconds); + var easedProgress = easing.Ease(progress); + var current = from + (to - from) * easedProgress; + + SetSwipePaneSize(Math.Max(0, current)); + + if (progress >= 1.0) + { + timer.Stop(); + SwipeFinalizeState(open, targetWidth); + _isSwipeAnimating = false; + _isSwipeDirectionLocked = false; + } + }; + timer.Start(); + } + + private void SwipeFinalizeState(bool open, double targetWidth) + { + // Set the pane to the exact final size before committing state + SetSwipePaneSize(open ? targetWidth : 0); + + // Commit the IsPaneOpen state — events fire, pseudo-classes update + SetCurrentValue(IsPaneOpenProperty, open); + + // Clear the local Width/Height override so style takes over + ClearSwipePaneSizeOverride(); + + // Restore theme transitions and remove swiping pseudo-class + // Done on next dispatcher tick so IsSwipeGestureActive stays true + // through any PaneClosing events fired on this event cycle + if (!_isSwipeAnimating) + { + Dispatcher.UIThread.Post(() => + { + RestorePaneTransitions(); + _isSwipeDirectionLocked = false; + }, DispatcherPriority.Input); + } + else + { + RestorePaneTransitions(); + } + } + + #endregion } } diff --git a/src/Avalonia.Themes.Fluent/Controls/SplitView.xaml b/src/Avalonia.Themes.Fluent/Controls/SplitView.xaml index 1298318e9e..c140bd4b49 100644 --- a/src/Avalonia.Themes.Fluent/Controls/SplitView.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/SplitView.xaml @@ -26,6 +26,7 @@ +