diff --git a/api/Avalonia.nupkg.xml b/api/Avalonia.nupkg.xml index c03d1fe6cc..8e6173a6cb 100644 --- a/api/Avalonia.nupkg.xml +++ b/api/Avalonia.nupkg.xml @@ -991,6 +991,18 @@ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll current/Avalonia/lib/net10.0/Avalonia.Base.dll + + CP0002 + F:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.CrossAxisCancelThresholdProperty + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0002 + F:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.EdgeSizeProperty + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + CP0002 F:Avalonia.Input.HoldingState.Cancelled @@ -1147,6 +1159,30 @@ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll current/Avalonia/lib/net10.0/Avalonia.Base.dll + + CP0002 + M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.get_CrossAxisCancelThreshold + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.get_EdgeSize + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.set_CrossAxisCancelThreshold(System.Double) + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.set_EdgeSize(System.Double) + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + CP0002 M:Avalonia.Input.HoldingRoutedEventArgs.#ctor(Avalonia.Input.HoldingState,Avalonia.Point,Avalonia.Input.PointerType) @@ -1411,6 +1447,18 @@ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll current/Avalonia/lib/net10.0/Avalonia.Base.dll + + CP0002 + M:Avalonia.Input.SwipeGestureEventArgs.#ctor(System.Int32,Avalonia.Input.SwipeDirection,Avalonia.Vector,Avalonia.Point) + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Input.SwipeGestureEventArgs.get_StartPoint + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + CP0002 M:Avalonia.Input.TextInput.TextInputMethodClient.ShowInputPanel @@ -2545,6 +2593,18 @@ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll current/Avalonia/lib/net8.0/Avalonia.Base.dll + + CP0002 + F:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.CrossAxisCancelThresholdProperty + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0002 + F:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.EdgeSizeProperty + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + CP0002 F:Avalonia.Input.HoldingState.Cancelled @@ -2701,6 +2761,30 @@ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll current/Avalonia/lib/net8.0/Avalonia.Base.dll + + CP0002 + M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.get_CrossAxisCancelThreshold + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.get_EdgeSize + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.set_CrossAxisCancelThreshold(System.Double) + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.set_EdgeSize(System.Double) + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + CP0002 M:Avalonia.Input.HoldingRoutedEventArgs.#ctor(Avalonia.Input.HoldingState,Avalonia.Point,Avalonia.Input.PointerType) @@ -2965,6 +3049,18 @@ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll current/Avalonia/lib/net8.0/Avalonia.Base.dll + + CP0002 + M:Avalonia.Input.SwipeGestureEventArgs.#ctor(System.Int32,Avalonia.Input.SwipeDirection,Avalonia.Vector,Avalonia.Point) + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Input.SwipeGestureEventArgs.get_StartPoint + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + CP0002 M:Avalonia.Input.TextInput.TextInputMethodClient.ShowInputPanel diff --git a/samples/ControlCatalog/Pages/CarouselPage.xaml b/samples/ControlCatalog/Pages/CarouselPage.xaml index 352fa32e30..c6e20fec5b 100644 --- a/samples/ControlCatalog/Pages/CarouselPage.xaml +++ b/samples/ControlCatalog/Pages/CarouselPage.xaml @@ -1,44 +1,117 @@ - - An items control that displays its items as pages that fill the control. + + A swipeable items control that can reveal adjacent pages with ViewportFraction. - - - + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - Transition - + + + + Transition + None - Slide - Crossfade - 3D Rotation + Page Slide + Cross Fade + Rotate 3D + Card Stack + Wave Reveal + Composite (Slide + Fade) - - - Orientation - + Orientation + Horizontal Vertical + + Viewport Fraction + + + + 1.00 + + + + + + + + + Wrap Selection + Swipe Enabled + + + + + + + + Total Items: + 0 + + + Selected Index: + 0 + + diff --git a/samples/ControlCatalog/Pages/CarouselPage.xaml.cs b/samples/ControlCatalog/Pages/CarouselPage.xaml.cs index 713da34051..0a0c973b90 100644 --- a/samples/ControlCatalog/Pages/CarouselPage.xaml.cs +++ b/samples/ControlCatalog/Pages/CarouselPage.xaml.cs @@ -1,6 +1,9 @@ using System; +using Avalonia; using Avalonia.Animation; using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using ControlCatalog.Pages.Transitions; namespace ControlCatalog.Pages { @@ -9,28 +12,137 @@ namespace ControlCatalog.Pages public CarouselPage() { InitializeComponent(); + left.Click += (s, e) => carousel.Previous(); right.Click += (s, e) => carousel.Next(); transition.SelectionChanged += TransitionChanged; orientation.SelectionChanged += TransitionChanged; + viewportFraction.ValueChanged += ViewportFractionChanged; + + wrapSelection.IsChecked = carousel.WrapSelection; + wrapSelection.IsCheckedChanged += (s, e) => + { + carousel.WrapSelection = wrapSelection.IsChecked ?? false; + UpdateButtonState(); + }; + + swipeEnabled.IsChecked = carousel.IsSwipeEnabled; + swipeEnabled.IsCheckedChanged += (s, e) => + { + carousel.IsSwipeEnabled = swipeEnabled.IsChecked ?? false; + }; + + carousel.PropertyChanged += (s, e) => + { + if (e.Property == SelectingItemsControl.SelectedIndexProperty) + { + UpdateButtonState(); + } + else if (e.Property == Carousel.ViewportFractionProperty) + { + UpdateViewportFractionDisplay(); + } + }; + + carousel.ViewportFraction = viewportFraction.Value; + UpdateButtonState(); + UpdateViewportFractionDisplay(); + } + + private void UpdateButtonState() + { + itemsCountIndicator.Text = carousel.ItemCount.ToString(); + selectedIndexIndicator.Text = carousel.SelectedIndex.ToString(); + + var wrap = carousel.WrapSelection; + left.IsEnabled = wrap || carousel.SelectedIndex > 0; + right.IsEnabled = wrap || carousel.SelectedIndex < carousel.ItemCount - 1; + } + + private void ViewportFractionChanged(object? sender, RangeBaseValueChangedEventArgs e) + { + carousel.ViewportFraction = Math.Round(e.NewValue, 2); + UpdateViewportFractionDisplay(); + } + + private void UpdateViewportFractionDisplay() + { + var value = carousel.ViewportFraction; + viewportFractionIndicator.Text = value.ToString("0.00"); + + var pagesInView = 1d / value; + viewportFractionHint.Text = value >= 1d + ? "1.00 shows a single full page." + : $"{pagesInView:0.##} pages fit in view. Try 0.80 for peeking or 0.33 for three full items."; } private void TransitionChanged(object? sender, SelectionChangedEventArgs e) { + var isVertical = orientation.SelectedIndex == 1; + var axis = isVertical ? PageSlide.SlideAxis.Vertical : PageSlide.SlideAxis.Horizontal; + switch (transition.SelectedIndex) { case 0: carousel.PageTransition = null; break; case 1: - carousel.PageTransition = new PageSlide(TimeSpan.FromSeconds(0.25), orientation.SelectedIndex == 0 ? PageSlide.SlideAxis.Horizontal : PageSlide.SlideAxis.Vertical); + carousel.PageTransition = new PageSlide(TimeSpan.FromSeconds(0.25), axis); break; case 2: carousel.PageTransition = new CrossFade(TimeSpan.FromSeconds(0.25)); break; case 3: - carousel.PageTransition = new Rotate3DTransition(TimeSpan.FromSeconds(0.5), orientation.SelectedIndex == 0 ? PageSlide.SlideAxis.Horizontal : PageSlide.SlideAxis.Vertical); + carousel.PageTransition = new Rotate3DTransition(TimeSpan.FromSeconds(0.5), axis); + break; + case 4: + carousel.PageTransition = new CardStackPageTransition(TimeSpan.FromSeconds(0.5), axis); + break; + case 5: + carousel.PageTransition = new WaveRevealPageTransition(TimeSpan.FromSeconds(0.8), axis); break; + case 6: + carousel.PageTransition = new CompositePageTransition + { + PageTransitions = + { + new PageSlide(TimeSpan.FromSeconds(0.25), axis), + new CrossFade(TimeSpan.FromSeconds(0.25)), + } + }; + break; + } + + UpdateLayoutForOrientation(isVertical); + } + + private void UpdateLayoutForOrientation(bool isVertical) + { + if (isVertical) + { + Grid.SetColumn(left, 1); + Grid.SetRow(left, 0); + Grid.SetColumn(right, 1); + Grid.SetRow(right, 2); + + left.Padding = new Thickness(20, 10); + right.Padding = new Thickness(20, 10); + + leftArrow.RenderTransform = new Avalonia.Media.RotateTransform(90); + rightArrow.RenderTransform = new Avalonia.Media.RotateTransform(90); + } + else + { + Grid.SetColumn(left, 0); + Grid.SetRow(left, 1); + Grid.SetColumn(right, 2); + Grid.SetRow(right, 1); + + left.Padding = new Thickness(10, 20); + right.Padding = new Thickness(10, 20); + + leftArrow.RenderTransform = null; + rightArrow.RenderTransform = null; } } } diff --git a/samples/ControlCatalog/Pages/DrawerPage/DrawerPageCustomizationPage.xaml.cs b/samples/ControlCatalog/Pages/DrawerPage/DrawerPageCustomizationPage.xaml.cs index 697e67f0f4..243bc5868b 100644 --- a/samples/ControlCatalog/Pages/DrawerPage/DrawerPageCustomizationPage.xaml.cs +++ b/samples/ControlCatalog/Pages/DrawerPage/DrawerPageCustomizationPage.xaml.cs @@ -1,5 +1,7 @@ +using System.Linq; using Avalonia.Controls; using Avalonia.Controls.Primitives; +using Avalonia.Input.GestureRecognizers; using Avalonia.Interactivity; using Avalonia.Media; @@ -22,6 +24,7 @@ namespace ControlCatalog.Pages public DrawerPageCustomizationPage() { InitializeComponent(); + EnableMouseSwipeGesture(DemoDrawer); } protected override void OnLoaded(RoutedEventArgs e) @@ -188,5 +191,15 @@ namespace ControlCatalog.Pages if (DemoDrawer.DrawerBehavior != DrawerBehavior.Locked) DemoDrawer.IsOpen = false; } + + private static void EnableMouseSwipeGesture(Control control) + { + var recognizer = control.GestureRecognizers + .OfType() + .FirstOrDefault(); + + if (recognizer is not null) + recognizer.IsMouseEnabled = true; + } } } diff --git a/samples/ControlCatalog/Pages/DrawerPage/DrawerPageFirstLookPage.xaml.cs b/samples/ControlCatalog/Pages/DrawerPage/DrawerPageFirstLookPage.xaml.cs index 58a981f640..de72957d73 100644 --- a/samples/ControlCatalog/Pages/DrawerPage/DrawerPageFirstLookPage.xaml.cs +++ b/samples/ControlCatalog/Pages/DrawerPage/DrawerPageFirstLookPage.xaml.cs @@ -1,4 +1,6 @@ +using System.Linq; using Avalonia.Controls; +using Avalonia.Input.GestureRecognizers; using Avalonia.Interactivity; namespace ControlCatalog.Pages @@ -8,6 +10,7 @@ namespace ControlCatalog.Pages public DrawerPageFirstLookPage() { InitializeComponent(); + EnableMouseSwipeGesture(DemoDrawer); } protected override void OnLoaded(RoutedEventArgs e) @@ -61,5 +64,15 @@ namespace ControlCatalog.Pages { StatusText.Text = $"Drawer: {(DemoDrawer.IsOpen ? "Open" : "Closed")}"; } + + private static void EnableMouseSwipeGesture(Control control) + { + var recognizer = control.GestureRecognizers + .OfType() + .FirstOrDefault(); + + if (recognizer is not null) + recognizer.IsMouseEnabled = true; + } } } diff --git a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageGesturePage.xaml.cs b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageGesturePage.xaml.cs index ff711f3a63..c18cfebc7e 100644 --- a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageGesturePage.xaml.cs +++ b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageGesturePage.xaml.cs @@ -1,4 +1,6 @@ +using System.Linq; using Avalonia.Controls; +using Avalonia.Input.GestureRecognizers; using Avalonia.Interactivity; namespace ControlCatalog.Pages @@ -8,6 +10,7 @@ namespace ControlCatalog.Pages public NavigationPageGesturePage() { InitializeComponent(); + EnableMouseSwipeGesture(DemoNav); Loaded += OnLoaded; } @@ -43,5 +46,15 @@ namespace ControlCatalog.Pages { StatusText.Text = $"Depth: {DemoNav.StackDepth}"; } + + private static void EnableMouseSwipeGesture(Control control) + { + var recognizer = control.GestureRecognizers + .OfType() + .FirstOrDefault(); + + if (recognizer is not null) + recognizer.IsMouseEnabled = true; + } } } diff --git a/samples/ControlCatalog/Pages/TabbedPage/TabbedPageGesturePage.xaml.cs b/samples/ControlCatalog/Pages/TabbedPage/TabbedPageGesturePage.xaml.cs index bee2c43efd..e17ebc5ed8 100644 --- a/samples/ControlCatalog/Pages/TabbedPage/TabbedPageGesturePage.xaml.cs +++ b/samples/ControlCatalog/Pages/TabbedPage/TabbedPageGesturePage.xaml.cs @@ -1,4 +1,6 @@ +using System.Linq; using Avalonia.Controls; +using Avalonia.Input.GestureRecognizers; namespace ControlCatalog.Pages { @@ -7,6 +9,7 @@ namespace ControlCatalog.Pages public TabbedPageGesturePage() { InitializeComponent(); + EnableMouseSwipeGesture(DemoTabs); } private void OnGestureEnabledChanged(object? sender, Avalonia.Interactivity.RoutedEventArgs e) @@ -26,5 +29,15 @@ namespace ControlCatalog.Pages _ => TabPlacement.Top }; } + + private static void EnableMouseSwipeGesture(Control control) + { + var recognizer = control.GestureRecognizers + .OfType() + .FirstOrDefault(); + + if (recognizer is not null) + recognizer.IsMouseEnabled = true; + } } } diff --git a/samples/ControlCatalog/Pages/Transitions/CardStackPageTransition.cs b/samples/ControlCatalog/Pages/Transitions/CardStackPageTransition.cs new file mode 100644 index 0000000000..89ae1e5e8a --- /dev/null +++ b/samples/ControlCatalog/Pages/Transitions/CardStackPageTransition.cs @@ -0,0 +1,447 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Animation; +using Avalonia.Animation.Easings; +using Avalonia.Media; +using Avalonia.Styling; + +namespace ControlCatalog.Pages.Transitions; + +/// +/// Transitions between two pages with a card-stack effect: +/// the top page moves/rotates away while the next page scales up underneath. +/// +public class CardStackPageTransition : PageSlide +{ + private const double ViewportLiftScale = 0.03; + private const double ViewportPromotionScale = 0.02; + private const double ViewportDepthOpacityFalloff = 0.08; + private const double SidePeekAngle = 4.0; + private const double FarPeekAngle = 7.0; + + /// + /// Initializes a new instance of the class. + /// + public CardStackPageTransition() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The duration of the animation. + /// The axis on which the animation should occur. + public CardStackPageTransition(TimeSpan duration, PageSlide.SlideAxis orientation = PageSlide.SlideAxis.Horizontal) + : base(duration, orientation) + { + } + + /// + /// Gets or sets the maximum rotation angle (degrees) applied to the top card. + /// + public double MaxSwipeAngle { get; set; } = 15.0; + + /// + /// Gets or sets the scale reduction applied to the back card (0.05 = 5%). + /// + public double BackCardScale { get; set; } = 0.05; + + /// + /// Gets or sets the vertical offset (pixels) applied to the back card. + /// + public double BackCardOffset { get; set; } = 0.0; + + /// + public override async Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return; + } + + var tasks = new List(); + var parent = GetVisualParent(from, to); + var distance = Orientation == PageSlide.SlideAxis.Horizontal ? parent.Bounds.Width : parent.Bounds.Height; + var translateProperty = Orientation == PageSlide.SlideAxis.Horizontal ? TranslateTransform.XProperty : TranslateTransform.YProperty; + var rotationTarget = Orientation == PageSlide.SlideAxis.Horizontal ? (forward ? -MaxSwipeAngle : MaxSwipeAngle) : 0.0; + var startScale = 1.0 - BackCardScale; + + if (from != null) + { + var (rotate, translate) = EnsureTopTransforms(from); + rotate.Angle = 0; + translate.X = 0; + translate.Y = 0; + from.Opacity = 1; + from.ZIndex = 1; + + var animation = new Animation + { + Easing = SlideOutEasing, + Duration = Duration, + FillMode = FillMode, + Children = + { + new KeyFrame + { + Setters = + { + new Setter(translateProperty, 0d), + new Setter(RotateTransform.AngleProperty, 0d) + }, + Cue = new Cue(0d) + }, + new KeyFrame + { + Setters = + { + new Setter(translateProperty, forward ? -distance : distance), + new Setter(RotateTransform.AngleProperty, rotationTarget) + }, + Cue = new Cue(1d) + } + } + }; + tasks.Add(animation.RunAsync(from, cancellationToken)); + } + + if (to != null) + { + var (scale, translate) = EnsureBackTransforms(to); + scale.ScaleX = startScale; + scale.ScaleY = startScale; + translate.X = 0; + translate.Y = BackCardOffset; + to.IsVisible = true; + to.Opacity = 1; + to.ZIndex = 0; + + var animation = new Animation + { + Easing = SlideInEasing, + Duration = Duration, + FillMode = FillMode, + Children = + { + new KeyFrame + { + Setters = + { + new Setter(ScaleTransform.ScaleXProperty, startScale), + new Setter(ScaleTransform.ScaleYProperty, startScale), + new Setter(TranslateTransform.YProperty, BackCardOffset) + }, + Cue = new Cue(0d) + }, + new KeyFrame + { + Setters = + { + new Setter(ScaleTransform.ScaleXProperty, 1d), + new Setter(ScaleTransform.ScaleYProperty, 1d), + new Setter(TranslateTransform.YProperty, 0d) + }, + Cue = new Cue(1d) + } + } + }; + + tasks.Add(animation.RunAsync(to, cancellationToken)); + } + + await Task.WhenAll(tasks); + + if (from != null && !cancellationToken.IsCancellationRequested) + { + from.IsVisible = false; + } + + if (!cancellationToken.IsCancellationRequested && to != null) + { + var (scale, translate) = EnsureBackTransforms(to); + scale.ScaleX = 1; + scale.ScaleY = 1; + translate.X = 0; + translate.Y = 0; + } + } + + /// + public override void Update( + double progress, + Visual? from, + Visual? to, + bool forward, + double pageLength, + IReadOnlyList visibleItems) + { + if (visibleItems.Count > 0) + { + UpdateVisibleItems(progress, from, to, forward, pageLength, visibleItems); + return; + } + + if (from is null && to is null) + return; + + var parent = GetVisualParent(from, to); + var size = parent.Bounds.Size; + var isHorizontal = Orientation == PageSlide.SlideAxis.Horizontal; + var distance = pageLength > 0 + ? pageLength + : (isHorizontal ? size.Width : size.Height); + var rotationTarget = isHorizontal ? (forward ? -MaxSwipeAngle : MaxSwipeAngle) : 0.0; + var startScale = 1.0 - BackCardScale; + + if (from != null) + { + var (rotate, translate) = EnsureTopTransforms(from); + if (isHorizontal) + { + translate.X = forward ? -distance * progress : distance * progress; + translate.Y = 0; + } + else + { + translate.X = 0; + translate.Y = forward ? -distance * progress : distance * progress; + } + + rotate.Angle = rotationTarget * progress; + from.IsVisible = true; + from.Opacity = 1; + from.ZIndex = 1; + } + + if (to != null) + { + var (scale, translate) = EnsureBackTransforms(to); + var currentScale = startScale + (1.0 - startScale) * progress; + var currentOffset = BackCardOffset * (1.0 - progress); + + scale.ScaleX = currentScale; + scale.ScaleY = currentScale; + if (isHorizontal) + { + translate.X = 0; + translate.Y = currentOffset; + } + else + { + translate.X = currentOffset; + translate.Y = 0; + } + + to.IsVisible = true; + to.Opacity = 1; + to.ZIndex = 0; + } + } + + /// + public override void Reset(Visual visual) + { + visual.RenderTransform = null; + visual.RenderTransformOrigin = default; + visual.Opacity = 1; + visual.ZIndex = 0; + } + + private void UpdateVisibleItems( + double progress, + Visual? from, + Visual? to, + bool forward, + double pageLength, + IReadOnlyList visibleItems) + { + var isHorizontal = Orientation == PageSlide.SlideAxis.Horizontal; + var rotationTarget = isHorizontal + ? (forward ? -MaxSwipeAngle : MaxSwipeAngle) + : 0.0; + var stackOffset = GetViewportStackOffset(pageLength); + var lift = Math.Sin(Math.Clamp(progress, 0.0, 1.0) * Math.PI); + + foreach (var item in visibleItems) + { + var visual = item.Visual; + var (rotate, scale, translate) = EnsureViewportTransforms(visual); + var depth = GetViewportDepth(item.ViewportCenterOffset); + var scaleValue = Math.Max(0.84, 1.0 - (BackCardScale * depth)); + var stackValue = stackOffset * depth; + var baseOpacity = Math.Max(0.8, 1.0 - (ViewportDepthOpacityFalloff * depth)); + var restingAngle = isHorizontal ? GetViewportRestingAngle(item.ViewportCenterOffset) : 0.0; + + rotate.Angle = restingAngle; + scale.ScaleX = scaleValue; + scale.ScaleY = scaleValue; + translate.X = 0; + translate.Y = 0; + + if (ReferenceEquals(visual, from)) + { + rotate.Angle = restingAngle + (rotationTarget * progress); + stackValue -= stackOffset * 0.2 * lift; + baseOpacity = Math.Min(1.0, baseOpacity + 0.08); + } + + if (ReferenceEquals(visual, to)) + { + var promotedScale = Math.Min(1.0, scaleValue + (ViewportLiftScale * lift) + (ViewportPromotionScale * progress)); + scale.ScaleX = promotedScale; + scale.ScaleY = promotedScale; + rotate.Angle = restingAngle * (1.0 - progress); + stackValue = Math.Max(0.0, stackValue - (stackOffset * (0.45 + (0.2 * lift)) * progress)); + baseOpacity = Math.Min(1.0, baseOpacity + (0.12 * lift)); + } + + if (isHorizontal) + translate.Y = stackValue; + else + translate.X = stackValue; + + visual.IsVisible = true; + visual.Opacity = baseOpacity; + visual.ZIndex = GetViewportZIndex(item.ViewportCenterOffset, visual, from, to); + } + } + + private static (RotateTransform rotate, TranslateTransform translate) EnsureTopTransforms(Visual visual) + { + if (visual.RenderTransform is TransformGroup group && + group.Children.Count == 2 && + group.Children[0] is RotateTransform rotateTransform && + group.Children[1] is TranslateTransform translateTransform) + { + visual.RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative); + return (rotateTransform, translateTransform); + } + + var rotate = new RotateTransform(); + var translate = new TranslateTransform(); + visual.RenderTransform = new TransformGroup + { + Children = + { + rotate, + translate + } + }; + visual.RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative); + return (rotate, translate); + } + + private static (ScaleTransform scale, TranslateTransform translate) EnsureBackTransforms(Visual visual) + { + if (visual.RenderTransform is TransformGroup group && + group.Children.Count == 2 && + group.Children[0] is ScaleTransform scaleTransform && + group.Children[1] is TranslateTransform translateTransform) + { + visual.RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative); + return (scaleTransform, translateTransform); + } + + var scale = new ScaleTransform(); + var translate = new TranslateTransform(); + visual.RenderTransform = new TransformGroup + { + Children = + { + scale, + translate + } + }; + visual.RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative); + return (scale, translate); + } + + private static (RotateTransform rotate, ScaleTransform scale, TranslateTransform translate) EnsureViewportTransforms(Visual visual) + { + if (visual.RenderTransform is TransformGroup group && + group.Children.Count == 3 && + group.Children[0] is RotateTransform rotateTransform && + group.Children[1] is ScaleTransform scaleTransform && + group.Children[2] is TranslateTransform translateTransform) + { + visual.RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative); + return (rotateTransform, scaleTransform, translateTransform); + } + + var rotate = new RotateTransform(); + var scale = new ScaleTransform(1, 1); + var translate = new TranslateTransform(); + visual.RenderTransform = new TransformGroup + { + Children = + { + rotate, + scale, + translate + } + }; + visual.RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative); + return (rotate, scale, translate); + } + + private double GetViewportStackOffset(double pageLength) + { + if (BackCardOffset > 0) + return BackCardOffset; + + return Math.Clamp(pageLength * 0.045, 10.0, 18.0); + } + + private static double GetViewportDepth(double offsetFromCenter) + { + var distance = Math.Abs(offsetFromCenter); + + if (distance <= 1.0) + return distance; + + if (distance <= 2.0) + return 1.0 + ((distance - 1.0) * 0.8); + + return 1.8; + } + + private static double GetViewportRestingAngle(double offsetFromCenter) + { + var sign = Math.Sign(offsetFromCenter); + if (sign == 0) + return 0; + + var distance = Math.Abs(offsetFromCenter); + if (distance <= 1.0) + return sign * Lerp(0.0, SidePeekAngle, distance); + + if (distance <= 2.0) + return sign * Lerp(SidePeekAngle, FarPeekAngle, distance - 1.0); + + return sign * FarPeekAngle; + } + + private static double Lerp(double from, double to, double t) + { + return from + ((to - from) * Math.Clamp(t, 0.0, 1.0)); + } + + private static int GetViewportZIndex(double offsetFromCenter, Visual visual, Visual? from, Visual? to) + { + if (ReferenceEquals(visual, from)) + return 5; + + if (ReferenceEquals(visual, to)) + return 4; + + var distance = Math.Abs(offsetFromCenter); + if (distance < 0.5) + return 4; + if (distance < 1.5) + return 3; + return 2; + } +} diff --git a/samples/ControlCatalog/Pages/Transitions/WaveRevealPageTransition.cs b/samples/ControlCatalog/Pages/Transitions/WaveRevealPageTransition.cs new file mode 100644 index 0000000000..9d8e80bf9c --- /dev/null +++ b/samples/ControlCatalog/Pages/Transitions/WaveRevealPageTransition.cs @@ -0,0 +1,380 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Animation; +using Avalonia.Animation.Easings; +using Avalonia.Media; + +namespace ControlCatalog.Pages.Transitions; + +/// +/// Transitions between two pages using a wave clip that reveals the next page. +/// +public class WaveRevealPageTransition : PageSlide +{ + /// + /// Initializes a new instance of the class. + /// + public WaveRevealPageTransition() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The duration of the animation. + /// The axis on which the animation should occur. + public WaveRevealPageTransition(TimeSpan duration, PageSlide.SlideAxis orientation = PageSlide.SlideAxis.Horizontal) + : base(duration, orientation) + { + } + + /// + /// Gets or sets the maximum wave bulge (pixels) along the movement axis. + /// + public double MaxBulge { get; set; } = 120.0; + + /// + /// Gets or sets the bulge factor along the movement axis (0-1). + /// + public double BulgeFactor { get; set; } = 0.35; + + /// + /// Gets or sets the bulge factor along the cross axis (0-1). + /// + public double CrossBulgeFactor { get; set; } = 0.3; + + /// + /// Gets or sets a cross-axis offset (pixels) to shift the wave center. + /// + public double WaveCenterOffset { get; set; } = 0.0; + + /// + /// Gets or sets how strongly the wave center follows the provided offset. + /// + public double CenterSensitivity { get; set; } = 1.0; + + /// + /// Gets or sets the bulge exponent used to shape the wave (1.0 = linear). + /// Higher values tighten the bulge; lower values broaden it. + /// + public double BulgeExponent { get; set; } = 1.0; + + /// + /// Gets or sets the easing applied to the wave progress (clip only). + /// + public Easing WaveEasing { get; set; } = new CubicEaseOut(); + + /// + public override async Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return; + } + + if (to != null) + { + to.IsVisible = true; + to.ZIndex = 1; + } + + if (from != null) + { + from.ZIndex = 0; + } + + await AnimateProgress(0.0, 1.0, from, to, forward, cancellationToken); + + if (to != null && !cancellationToken.IsCancellationRequested) + { + to.Clip = null; + } + + if (from != null && !cancellationToken.IsCancellationRequested) + { + from.IsVisible = false; + } + } + + /// + public override void Update( + double progress, + Visual? from, + Visual? to, + bool forward, + double pageLength, + IReadOnlyList visibleItems) + { + if (visibleItems.Count > 0) + { + UpdateVisibleItems(from, to, forward, pageLength, visibleItems); + return; + } + + if (from is null && to is null) + return; + var parent = GetVisualParent(from, to); + var size = parent.Bounds.Size; + var centerOffset = WaveCenterOffset * CenterSensitivity; + var isHorizontal = Orientation == PageSlide.SlideAxis.Horizontal; + + if (to != null) + { + to.IsVisible = progress > 0.0; + to.ZIndex = 1; + to.Opacity = 1; + + if (progress >= 1.0) + { + to.Clip = null; + } + else + { + var waveProgress = WaveEasing?.Ease(progress) ?? progress; + var clip = LiquidSwipeClipper.CreateWavePath( + waveProgress, + size, + centerOffset, + forward, + isHorizontal, + MaxBulge, + BulgeFactor, + CrossBulgeFactor, + BulgeExponent); + to.Clip = clip; + } + } + + if (from != null) + { + from.IsVisible = true; + from.ZIndex = 0; + from.Opacity = 1; + } + } + + private void UpdateVisibleItems( + Visual? from, + Visual? to, + bool forward, + double pageLength, + IReadOnlyList visibleItems) + { + if (from is null && to is null) + return; + + var parent = GetVisualParent(from, to); + var size = parent.Bounds.Size; + var centerOffset = WaveCenterOffset * CenterSensitivity; + var isHorizontal = Orientation == PageSlide.SlideAxis.Horizontal; + var resolvedPageLength = pageLength > 0 + ? pageLength + : (isHorizontal ? size.Width : size.Height); + foreach (var item in visibleItems) + { + var visual = item.Visual; + visual.IsVisible = true; + visual.Opacity = 1; + visual.Clip = null; + visual.ZIndex = ReferenceEquals(visual, to) ? 1 : 0; + + if (!ReferenceEquals(visual, to)) + continue; + + var visibleFraction = GetVisibleFraction(item.ViewportCenterOffset, size, resolvedPageLength, isHorizontal); + if (visibleFraction >= 1.0) + continue; + + visual.Clip = LiquidSwipeClipper.CreateWavePath( + visibleFraction, + size, + centerOffset, + forward, + isHorizontal, + MaxBulge, + BulgeFactor, + CrossBulgeFactor, + BulgeExponent); + } + } + + private static double GetVisibleFraction(double offsetFromCenter, Size viewportSize, double pageLength, bool isHorizontal) + { + if (pageLength <= 0) + return 1.0; + + var viewportLength = isHorizontal ? viewportSize.Width : viewportSize.Height; + if (viewportLength <= 0) + return 0.0; + + var viewportUnits = viewportLength / pageLength; + var edgePeek = Math.Max(0.0, (viewportUnits - 1.0) / 2.0); + return Math.Clamp(1.0 + edgePeek - Math.Abs(offsetFromCenter), 0.0, 1.0); + } + + /// + public override void Reset(Visual visual) + { + visual.Clip = null; + visual.ZIndex = 0; + visual.Opacity = 1; + } + + private async Task AnimateProgress( + double from, + double to, + Visual? fromVisual, + Visual? toVisual, + bool forward, + CancellationToken cancellationToken) + { + var parent = GetVisualParent(fromVisual, toVisual); + var pageLength = Orientation == PageSlide.SlideAxis.Horizontal + ? parent.Bounds.Width + : parent.Bounds.Height; + var durationMs = Math.Max(Duration.TotalMilliseconds * Math.Abs(to - from), 50); + var startTicks = Stopwatch.GetTimestamp(); + var tickFreq = Stopwatch.Frequency; + + while (!cancellationToken.IsCancellationRequested) + { + var elapsedMs = (Stopwatch.GetTimestamp() - startTicks) * 1000.0 / tickFreq; + var t = Math.Clamp(elapsedMs / durationMs, 0.0, 1.0); + var eased = SlideInEasing?.Ease(t) ?? t; + var progress = from + (to - from) * eased; + + Update(progress, fromVisual, toVisual, forward, pageLength, Array.Empty()); + + if (t >= 1.0) + break; + + await Task.Delay(16, cancellationToken); + } + + if (!cancellationToken.IsCancellationRequested) + { + Update(to, fromVisual, toVisual, forward, pageLength, Array.Empty()); + } + } + + private static class LiquidSwipeClipper + { + public static Geometry CreateWavePath( + double progress, + Size size, + double waveCenterOffset, + bool forward, + bool isHorizontal, + double maxBulge, + double bulgeFactor, + double crossBulgeFactor, + double bulgeExponent) + { + var width = size.Width; + var height = size.Height; + + if (progress <= 0) + return new RectangleGeometry(new Rect(0, 0, 0, 0)); + + if (progress >= 1) + return new RectangleGeometry(new Rect(0, 0, width, height)); + + if (width <= 0 || height <= 0) + return new RectangleGeometry(new Rect(0, 0, 0, 0)); + + var mainLength = isHorizontal ? width : height; + var crossLength = isHorizontal ? height : width; + + var wavePhase = Math.Sin(progress * Math.PI); + var bulgeProgress = bulgeExponent == 1.0 ? wavePhase : Math.Pow(wavePhase, bulgeExponent); + var revealedLength = mainLength * progress; + var bulgeMain = Math.Min(mainLength * bulgeFactor, maxBulge) * bulgeProgress; + bulgeMain = Math.Min(bulgeMain, revealedLength * 0.45); + var bulgeCross = crossLength * crossBulgeFactor; + + var waveCenter = crossLength / 2 + waveCenterOffset; + waveCenter = Math.Clamp(waveCenter, bulgeCross, crossLength - bulgeCross); + + var geometry = new StreamGeometry(); + using (var context = geometry.Open()) + { + if (isHorizontal) + { + if (forward) + { + var waveX = width * (1 - progress); + context.BeginFigure(new Point(width, 0), true); + context.LineTo(new Point(waveX, 0)); + context.CubicBezierTo( + new Point(waveX, waveCenter - bulgeCross), + new Point(waveX - bulgeMain, waveCenter - bulgeCross * 0.5), + new Point(waveX - bulgeMain, waveCenter)); + context.CubicBezierTo( + new Point(waveX - bulgeMain, waveCenter + bulgeCross * 0.5), + new Point(waveX, waveCenter + bulgeCross), + new Point(waveX, height)); + context.LineTo(new Point(width, height)); + context.EndFigure(true); + } + else + { + var waveX = width * progress; + context.BeginFigure(new Point(0, 0), true); + context.LineTo(new Point(waveX, 0)); + context.CubicBezierTo( + new Point(waveX, waveCenter - bulgeCross), + new Point(waveX + bulgeMain, waveCenter - bulgeCross * 0.5), + new Point(waveX + bulgeMain, waveCenter)); + context.CubicBezierTo( + new Point(waveX + bulgeMain, waveCenter + bulgeCross * 0.5), + new Point(waveX, waveCenter + bulgeCross), + new Point(waveX, height)); + context.LineTo(new Point(0, height)); + context.EndFigure(true); + } + } + else + { + if (forward) + { + var waveY = height * (1 - progress); + context.BeginFigure(new Point(0, height), true); + context.LineTo(new Point(0, waveY)); + context.CubicBezierTo( + new Point(waveCenter - bulgeCross, waveY), + new Point(waveCenter - bulgeCross * 0.5, waveY - bulgeMain), + new Point(waveCenter, waveY - bulgeMain)); + context.CubicBezierTo( + new Point(waveCenter + bulgeCross * 0.5, waveY - bulgeMain), + new Point(waveCenter + bulgeCross, waveY), + new Point(width, waveY)); + context.LineTo(new Point(width, height)); + context.EndFigure(true); + } + else + { + var waveY = height * progress; + context.BeginFigure(new Point(0, 0), true); + context.LineTo(new Point(0, waveY)); + context.CubicBezierTo( + new Point(waveCenter - bulgeCross, waveY), + new Point(waveCenter - bulgeCross * 0.5, waveY + bulgeMain), + new Point(waveCenter, waveY + bulgeMain)); + context.CubicBezierTo( + new Point(waveCenter + bulgeCross * 0.5, waveY + bulgeMain), + new Point(waveCenter + bulgeCross, waveY), + new Point(width, waveY)); + context.LineTo(new Point(width, 0)); + context.EndFigure(true); + } + } + } + + return geometry; + } + } +} diff --git a/src/Avalonia.Base/Animation/CompositePageTransition.cs b/src/Avalonia.Base/Animation/CompositePageTransition.cs index 62119a0051..e5e3511337 100644 --- a/src/Avalonia.Base/Animation/CompositePageTransition.cs +++ b/src/Avalonia.Base/Animation/CompositePageTransition.cs @@ -28,7 +28,7 @@ namespace Avalonia.Animation /// /// /// - public class CompositePageTransition : IPageTransition + public class CompositePageTransition : IPageTransition, IProgressPageTransition { /// /// Gets or sets the transitions to be executed. Can be defined from XAML. @@ -44,5 +44,35 @@ namespace Avalonia.Animation .ToArray(); return Task.WhenAll(transitionTasks); } + + /// + public void Update( + double progress, + Visual? from, + Visual? to, + bool forward, + double pageLength, + IReadOnlyList visibleItems) + { + foreach (var transition in PageTransitions) + { + if (transition is IProgressPageTransition progressive) + { + progressive.Update(progress, from, to, forward, pageLength, visibleItems); + } + } + } + + /// + public void Reset(Visual visual) + { + foreach (var transition in PageTransitions) + { + if (transition is IProgressPageTransition progressive) + { + progressive.Reset(visual); + } + } + } } } diff --git a/src/Avalonia.Base/Animation/CrossFade.cs b/src/Avalonia.Base/Animation/CrossFade.cs index f00d835020..45a4300e5b 100644 --- a/src/Avalonia.Base/Animation/CrossFade.cs +++ b/src/Avalonia.Base/Animation/CrossFade.cs @@ -12,8 +12,13 @@ namespace Avalonia.Animation /// /// Defines a cross-fade animation between two s. /// - public class CrossFade : IPageTransition + public class CrossFade : IPageTransition, IProgressPageTransition { + private const double SidePeekOpacity = 0.72; + private const double FarPeekOpacity = 0.42; + private const double OutgoingDip = 0.22; + private const double IncomingBoost = 0.12; + private const double PassiveDip = 0.05; private readonly Animation _fadeOutAnimation; private readonly Animation _fadeInAnimation; @@ -182,5 +187,82 @@ namespace Avalonia.Animation { return Start(from, to, cancellationToken); } + + /// + public void Update( + double progress, + Visual? from, + Visual? to, + bool forward, + double pageLength, + IReadOnlyList visibleItems) + { + if (visibleItems.Count > 0) + { + UpdateVisibleItems(progress, from, to, visibleItems); + return; + } + + if (from != null) + from.Opacity = 1 - progress; + if (to != null) + { + to.IsVisible = true; + to.Opacity = progress; + } + } + + /// + public void Reset(Visual visual) + { + visual.Opacity = 1; + } + + private static void UpdateVisibleItems( + double progress, + Visual? from, + Visual? to, + IReadOnlyList visibleItems) + { + var emphasis = Math.Sin(Math.Clamp(progress, 0.0, 1.0) * Math.PI); + foreach (var item in visibleItems) + { + item.Visual.IsVisible = true; + var opacity = GetOpacityForOffset(item.ViewportCenterOffset); + + if (ReferenceEquals(item.Visual, from)) + { + opacity = Math.Max(FarPeekOpacity, opacity - (OutgoingDip * emphasis)); + } + else if (ReferenceEquals(item.Visual, to)) + { + opacity = Math.Min(1.0, opacity + (IncomingBoost * emphasis)); + } + else + { + opacity = Math.Max(FarPeekOpacity, opacity - (PassiveDip * emphasis)); + } + + item.Visual.Opacity = opacity; + } + } + + private static double GetOpacityForOffset(double offsetFromCenter) + { + var distance = Math.Abs(offsetFromCenter); + + if (distance <= 1.0) + return Lerp(1.0, SidePeekOpacity, distance); + + if (distance <= 2.0) + return Lerp(SidePeekOpacity, FarPeekOpacity, distance - 1.0); + + return FarPeekOpacity; + } + + private static double Lerp(double from, double to, double t) + { + return from + ((to - from) * Math.Clamp(t, 0.0, 1.0)); + } } } diff --git a/src/Avalonia.Base/Animation/IProgressPageTransition.cs b/src/Avalonia.Base/Animation/IProgressPageTransition.cs new file mode 100644 index 0000000000..01f892d1fd --- /dev/null +++ b/src/Avalonia.Base/Animation/IProgressPageTransition.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using Avalonia.VisualTree; + +namespace Avalonia.Animation +{ + /// + /// An that supports progress-driven updates. + /// + /// + /// Transitions implementing this interface can be driven by a normalized progress value + /// (0.0 to 1.0) during swipe gestures or programmatic animations, rather than running + /// as a timed animation via . + /// + public interface IProgressPageTransition : IPageTransition + { + /// + /// Updates the transition to reflect the given progress. + /// + /// The normalized progress value from 0.0 (start) to 1.0 (complete). + /// The visual being transitioned away from. May be null. + /// The visual being transitioned to. May be null. + /// Whether the transition direction is forward (next) or backward (previous). + /// The size of a page along the transition axis. + /// The currently visible realized pages, if more than one page is visible. + void Update( + double progress, + Visual? from, + Visual? to, + bool forward, + double pageLength, + IReadOnlyList visibleItems); + + /// + /// Resets any visual state applied to the given visual by this transition. + /// + /// The visual to reset. + void Reset(Visual visual); + } +} diff --git a/src/Avalonia.Base/Animation/PageSlide.cs b/src/Avalonia.Base/Animation/PageSlide.cs index 24797a6d80..d75f391c79 100644 --- a/src/Avalonia.Base/Animation/PageSlide.cs +++ b/src/Avalonia.Base/Animation/PageSlide.cs @@ -12,7 +12,7 @@ namespace Avalonia.Animation /// /// Transitions between two pages by sliding them horizontally or vertically. /// - public class PageSlide : IPageTransition + public class PageSlide : IPageTransition, IProgressPageTransition { /// /// The axis on which the PageSlide should occur @@ -50,12 +50,12 @@ namespace Avalonia.Animation /// Gets the orientation of the animation. /// public SlideAxis Orientation { get; set; } - + /// /// Gets or sets element entrance easing. /// public Easing SlideInEasing { get; set; } = new LinearEasing(); - + /// /// Gets or sets element exit easing. /// @@ -152,8 +152,6 @@ namespace Avalonia.Animation if (from != null) { - // Hide BEFORE resetting transform so there is no single-frame flash - // where the element snaps back to position 0 while still visible. from.IsVisible = false; if (FillMode != FillMode.None) from.RenderTransform = null; @@ -163,6 +161,55 @@ namespace Avalonia.Animation to.RenderTransform = null; } + /// + public virtual void Update( + double progress, + Visual? from, + Visual? to, + bool forward, + double pageLength, + IReadOnlyList visibleItems) + { + if (visibleItems.Count > 0) + return; + + if (from is null && to is null) + return; + + var parent = GetVisualParent(from, to); + var distance = pageLength > 0 + ? pageLength + : (Orientation == SlideAxis.Horizontal ? parent.Bounds.Width : parent.Bounds.Height); + var offset = distance * progress; + + if (from != null) + { + if (from.RenderTransform is not TranslateTransform ft) + from.RenderTransform = ft = new TranslateTransform(); + if (Orientation == SlideAxis.Horizontal) + ft.X = forward ? -offset : offset; + else + ft.Y = forward ? -offset : offset; + } + + if (to != null) + { + to.IsVisible = true; + if (to.RenderTransform is not TranslateTransform tt) + to.RenderTransform = tt = new TranslateTransform(); + if (Orientation == SlideAxis.Horizontal) + tt.X = forward ? distance - offset : -(distance - offset); + else + tt.Y = forward ? distance - offset : -(distance - offset); + } + } + + /// + public virtual void Reset(Visual visual) + { + visual.RenderTransform = null; + } + /// /// Gets the common visual parent of the two control. /// diff --git a/src/Avalonia.Base/Animation/PageTransitionItem.cs b/src/Avalonia.Base/Animation/PageTransitionItem.cs new file mode 100644 index 0000000000..fed0145a2a --- /dev/null +++ b/src/Avalonia.Base/Animation/PageTransitionItem.cs @@ -0,0 +1,12 @@ +using Avalonia.VisualTree; + +namespace Avalonia.Animation +{ + /// + /// Describes a single visible page within a carousel viewport. + /// + public readonly record struct PageTransitionItem( + int Index, + Visual Visual, + double ViewportCenterOffset); +} diff --git a/src/Avalonia.Base/Animation/Transitions/Rotate3DTransition.cs b/src/Avalonia.Base/Animation/Transitions/Rotate3DTransition.cs index 239f3aea08..1075198881 100644 --- a/src/Avalonia.Base/Animation/Transitions/Rotate3DTransition.cs +++ b/src/Avalonia.Base/Animation/Transitions/Rotate3DTransition.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Avalonia.Media; @@ -8,6 +9,8 @@ namespace Avalonia.Animation; public class Rotate3DTransition: PageSlide { + private const double SidePeekAngle = 24.0; + private const double FarPeekAngle = 38.0; /// /// Creates a new instance of the @@ -20,7 +23,7 @@ public class Rotate3DTransition: PageSlide { Depth = depth; } - + /// /// Defines the depth of the 3D Effect. If null, depth will be calculated automatically from the width or height /// of the common parent of the visual being rotated. @@ -28,12 +31,12 @@ public class Rotate3DTransition: PageSlide public double? Depth { get; set; } /// - /// Creates a new instance of the + /// Initializes a new instance of the class. /// public Rotate3DTransition() { } /// - public override async Task Start(Visual? @from, Visual? to, bool forward, CancellationToken cancellationToken) + public override async Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken) { if (cancellationToken.IsCancellationRequested) { @@ -49,11 +52,12 @@ public class Rotate3DTransition: PageSlide _ => throw new ArgumentOutOfRangeException() }; - var depthSetter = new Setter {Property = Rotate3DTransform.DepthProperty, Value = Depth ?? center}; - var centerZSetter = new Setter {Property = Rotate3DTransform.CenterZProperty, Value = -center / 2}; + var depthSetter = new Setter { Property = Rotate3DTransform.DepthProperty, Value = Depth ?? center }; + var centerZSetter = new Setter { Property = Rotate3DTransform.CenterZProperty, Value = -center / 2 }; - KeyFrame CreateKeyFrame(double cue, double rotation, int zIndex, bool isVisible = true) => - new() { + KeyFrame CreateKeyFrame(double cue, double rotation, int zIndex, bool isVisible = true) => + new() + { Setters = { new Setter { Property = rotateProperty, Value = rotation }, @@ -71,7 +75,7 @@ public class Rotate3DTransition: PageSlide { Easing = SlideOutEasing, Duration = Duration, - FillMode = FillMode.Forward, + FillMode = FillMode, Children = { CreateKeyFrame(0d, 0d, 2), @@ -90,7 +94,7 @@ public class Rotate3DTransition: PageSlide { Easing = SlideInEasing, Duration = Duration, - FillMode = FillMode.Forward, + FillMode = FillMode, Children = { CreateKeyFrame(0d, 90d * (forward ? 1 : -1), 1), @@ -107,10 +111,8 @@ public class Rotate3DTransition: PageSlide if (!cancellationToken.IsCancellationRequested) { if (to != null) - { to.ZIndex = 2; - } - + if (from != null) { from.IsVisible = false; @@ -118,4 +120,139 @@ public class Rotate3DTransition: PageSlide } } } + + /// + public override void Update( + double progress, + Visual? from, + Visual? to, + bool forward, + double pageLength, + IReadOnlyList visibleItems) + { + if (visibleItems.Count > 0) + { + UpdateVisibleItems(progress, from, to, pageLength, visibleItems); + return; + } + + if (from is null && to is null) + return; + + var parent = GetVisualParent(from, to); + var center = pageLength > 0 + ? pageLength + : (Orientation == SlideAxis.Vertical ? parent.Bounds.Height : parent.Bounds.Width); + var depth = Depth ?? center; + var sign = forward ? 1.0 : -1.0; + + if (from != null) + { + if (from.RenderTransform is not Rotate3DTransform ft) + from.RenderTransform = ft = new Rotate3DTransform(); + ft.Depth = depth; + ft.CenterZ = -center / 2; + from.ZIndex = progress < 0.5 ? 2 : 1; + if (Orientation == SlideAxis.Horizontal) + ft.AngleY = -sign * 90.0 * progress; + else + ft.AngleX = -sign * 90.0 * progress; + } + + if (to != null) + { + to.IsVisible = true; + if (to.RenderTransform is not Rotate3DTransform tt) + to.RenderTransform = tt = new Rotate3DTransform(); + tt.Depth = depth; + tt.CenterZ = -center / 2; + to.ZIndex = progress < 0.5 ? 1 : 2; + if (Orientation == SlideAxis.Horizontal) + tt.AngleY = sign * 90.0 * (1.0 - progress); + else + tt.AngleX = sign * 90.0 * (1.0 - progress); + } + } + + private void UpdateVisibleItems( + double progress, + Visual? from, + Visual? to, + double pageLength, + IReadOnlyList visibleItems) + { + var anchor = from ?? to ?? visibleItems[0].Visual; + if (anchor.VisualParent is not Visual parent) + return; + + var center = pageLength > 0 + ? pageLength + : (Orientation == SlideAxis.Vertical ? parent.Bounds.Height : parent.Bounds.Width); + var depth = Depth ?? center; + var angleStrength = Math.Sin(Math.Clamp(progress, 0.0, 1.0) * Math.PI); + + foreach (var item in visibleItems) + { + var visual = item.Visual; + visual.IsVisible = true; + visual.ZIndex = GetZIndex(item.ViewportCenterOffset); + + if (visual.RenderTransform is not Rotate3DTransform transform) + visual.RenderTransform = transform = new Rotate3DTransform(); + + transform.Depth = depth; + transform.CenterZ = -center / 2; + + var angle = GetAngleForOffset(item.ViewportCenterOffset) * angleStrength; + if (Orientation == SlideAxis.Horizontal) + { + transform.AngleY = angle; + transform.AngleX = 0; + } + else + { + transform.AngleX = angle; + transform.AngleY = 0; + } + } + } + + private static double GetAngleForOffset(double offsetFromCenter) + { + var sign = Math.Sign(offsetFromCenter); + if (sign == 0) + return 0; + + var distance = Math.Abs(offsetFromCenter); + if (distance <= 1.0) + return sign * Lerp(0.0, SidePeekAngle, distance); + + if (distance <= 2.0) + return sign * Lerp(SidePeekAngle, FarPeekAngle, distance - 1.0); + + return sign * FarPeekAngle; + } + + private static int GetZIndex(double offsetFromCenter) + { + var distance = Math.Abs(offsetFromCenter); + + if (distance < 0.5) + return 3; + if (distance < 1.5) + return 2; + return 1; + } + + private static double Lerp(double from, double to, double t) + { + return from + ((to - from) * Math.Clamp(t, 0.0, 1.0)); + } + + /// + public override void Reset(Visual visual) + { + visual.RenderTransform = null; + visual.ZIndex = 0; + } } diff --git a/src/Avalonia.Base/Input/GestureRecognizers/GestureRecognizerCollection.cs b/src/Avalonia.Base/Input/GestureRecognizers/GestureRecognizerCollection.cs index 74e8061292..34e900c7d7 100644 --- a/src/Avalonia.Base/Input/GestureRecognizers/GestureRecognizerCollection.cs +++ b/src/Avalonia.Base/Input/GestureRecognizers/GestureRecognizerCollection.cs @@ -39,6 +39,24 @@ namespace Avalonia.Input.GestureRecognizers } } + public bool Remove(GestureRecognizer recognizer) + { + if (_recognizers == null) + return false; + + var removed = _recognizers.Remove(recognizer); + + if (removed) + { + recognizer.Target = null; + + if (recognizer is ISetLogicalParent logical) + logical.SetParent(null); + } + + return removed; + } + static readonly List s_Empty = new List(); public IEnumerator GetEnumerator() diff --git a/src/Avalonia.Base/Input/GestureRecognizers/SwipeGestureRecognizer.cs b/src/Avalonia.Base/Input/GestureRecognizers/SwipeGestureRecognizer.cs index 5d17940c8a..2328e5e874 100644 --- a/src/Avalonia.Base/Input/GestureRecognizers/SwipeGestureRecognizer.cs +++ b/src/Avalonia.Base/Input/GestureRecognizers/SwipeGestureRecognizer.cs @@ -1,87 +1,102 @@ using System; -using Avalonia.Logging; -using Avalonia.Media; +using System.Diagnostics; +using Avalonia.Platform; namespace Avalonia.Input.GestureRecognizers { /// - /// A gesture recognizer that detects swipe gestures and raises - /// on the target element when a swipe is confirmed. + /// A gesture recognizer that detects swipe gestures for paging interactions. /// + /// + /// Unlike , this recognizer is optimized for discrete + /// paging interactions (e.g., carousel navigation) rather than continuous scrolling. + /// It does not include inertia or friction physics. + /// public class SwipeGestureRecognizer : GestureRecognizer { + private bool _swiping; + private Point _trackedRootPoint; private IPointer? _tracking; - private IPointer? _captured; - private Point _initialPosition; - private int _gestureId; + private int _id; + + private Vector _velocity; + private long _lastTimestamp; /// - /// Defines the property. + /// Defines the property. /// - public static readonly StyledProperty ThresholdProperty = - AvaloniaProperty.Register(nameof(Threshold), 30d); + public static readonly StyledProperty CanHorizontallySwipeProperty = + AvaloniaProperty.Register(nameof(CanHorizontallySwipe)); /// - /// Defines the property. + /// Defines the property. /// - public static readonly StyledProperty CrossAxisCancelThresholdProperty = - AvaloniaProperty.Register( - nameof(CrossAxisCancelThreshold), 8d); + public static readonly StyledProperty CanVerticallySwipeProperty = + AvaloniaProperty.Register(nameof(CanVerticallySwipe)); /// - /// Defines the property. - /// Leading-edge start zone in px. 0 (default) = full area. - /// When > 0, only starts tracking if the pointer is within this many px - /// of the leading edge (LTR: left; RTL: right). + /// Defines the property. /// - public static readonly StyledProperty EdgeSizeProperty = - AvaloniaProperty.Register(nameof(EdgeSize), 0d); + /// + /// A value of 0 (the default) causes the distance to be read from + /// at the time of the first gesture. + /// + public static readonly StyledProperty ThresholdProperty = + AvaloniaProperty.Register(nameof(Threshold), defaultValue: 0d); + + /// + /// Defines the property. + /// + public static readonly StyledProperty IsMouseEnabledProperty = + AvaloniaProperty.Register(nameof(IsMouseEnabled), defaultValue: false); /// /// Defines the property. - /// When false, the recognizer ignores all pointer events. - /// Lets callers toggle the recognizer at runtime without needing to remove it from the - /// collection (GestureRecognizerCollection has Add but no Remove). - /// Default: true. /// public static readonly StyledProperty IsEnabledProperty = - AvaloniaProperty.Register(nameof(IsEnabled), true); + AvaloniaProperty.Register(nameof(IsEnabled), defaultValue: true); /// - /// Gets or sets the minimum distance in pixels the pointer must travel before a swipe - /// is recognized. Default is 30px. + /// Gets or sets a value indicating whether horizontal swipes are tracked. /// - public double Threshold + public bool CanHorizontallySwipe { - get => GetValue(ThresholdProperty); - set => SetValue(ThresholdProperty, value); + get => GetValue(CanHorizontallySwipeProperty); + set => SetValue(CanHorizontallySwipeProperty, value); } /// - /// Gets or sets the maximum cross-axis drift in pixels allowed before the gesture is - /// cancelled. Default is 8px. + /// Gets or sets a value indicating whether vertical swipes are tracked. /// - public double CrossAxisCancelThreshold + public bool CanVerticallySwipe { - get => GetValue(CrossAxisCancelThresholdProperty); - set => SetValue(CrossAxisCancelThresholdProperty, value); + get => GetValue(CanVerticallySwipeProperty); + set => SetValue(CanVerticallySwipeProperty, value); } /// - /// Gets or sets the leading-edge start zone in pixels. When greater than zero, tracking - /// only begins if the pointer is within this distance of the leading edge. Default is 0 - /// (full area). + /// Gets or sets the minimum pointer movement in pixels before a swipe is recognized. + /// A value of 0 reads the threshold from at gesture time. /// - public double EdgeSize + public double Threshold { - get => GetValue(EdgeSizeProperty); - set => SetValue(EdgeSizeProperty, value); + get => GetValue(ThresholdProperty); + set => SetValue(ThresholdProperty, value); + } + + /// + /// Gets or sets a value indicating whether mouse pointer events trigger swipe gestures. + /// Defaults to ; touch and pen are always enabled. + /// + public bool IsMouseEnabled + { + get => GetValue(IsMouseEnabledProperty); + set => SetValue(IsMouseEnabledProperty, value); } /// - /// Gets or sets a value indicating whether the recognizer responds to pointer events. - /// Setting this to false is a lightweight alternative to removing the recognizer from - /// the collection. Default is true. + /// Gets or sets a value indicating whether this recognizer responds to pointer events. + /// Defaults to . /// public bool IsEnabled { @@ -89,104 +104,122 @@ namespace Avalonia.Input.GestureRecognizers set => SetValue(IsEnabledProperty, value); } + /// protected override void PointerPressed(PointerPressedEventArgs e) { - if (!IsEnabled) return; - if (!e.GetCurrentPoint(null).Properties.IsLeftButtonPressed) return; - if (Target is not Visual visual) return; + if (!IsEnabled) + return; - var pos = e.GetPosition(visual); - var edgeSize = EdgeSize; + var point = e.GetCurrentPoint(null); - if (edgeSize > 0) + if ((e.Pointer.Type is PointerType.Touch or PointerType.Pen || + (IsMouseEnabled && e.Pointer.Type == PointerType.Mouse)) + && point.Properties.IsLeftButtonPressed) { - bool isRtl = visual.FlowDirection == FlowDirection.RightToLeft; - bool inEdge = isRtl - ? pos.X >= visual.Bounds.Width - edgeSize - : pos.X <= edgeSize; - if (!inEdge) - { - Logger.TryGet(LogEventLevel.Verbose, LogArea.Control)?.Log( - this, "SwipeGestureRecognizer: press at {Pos} outside edge zone ({EdgeSize}px), ignoring", - pos, edgeSize); - return; - } + EndGesture(); + _tracking = e.Pointer; + _id = SwipeGestureEventArgs.GetNextFreeId(); + _trackedRootPoint = point.Position; + _velocity = default; + _lastTimestamp = 0; } - - _gestureId = SwipeGestureEventArgs.GetNextFreeId(); - _tracking = e.Pointer; - _initialPosition = pos; - - Logger.TryGet(LogEventLevel.Verbose, LogArea.Control)?.Log( - this, "SwipeGestureRecognizer: tracking started at {Pos} (pointer={PointerType})", - pos, e.Pointer.Type); } + /// protected override void PointerMoved(PointerEventArgs e) { - if (_tracking != e.Pointer || Target is not Visual visual) return; - - var pos = e.GetPosition(visual); - double dx = pos.X - _initialPosition.X; - double dy = pos.Y - _initialPosition.Y; - double absDx = Math.Abs(dx); - double absDy = Math.Abs(dy); - double threshold = Threshold; - - if (absDx < threshold && absDy < threshold) - return; - - SwipeDirection dir; - Vector delta; - if (absDx >= absDy) + if (e.Pointer == _tracking) { - dir = dx > 0 ? SwipeDirection.Right : SwipeDirection.Left; - delta = new Vector(dx, 0); - } - else - { - dir = dy > 0 ? SwipeDirection.Down : SwipeDirection.Up; - delta = new Vector(0, dy); - } + var rootPoint = e.GetPosition(null); + var threshold = GetEffectiveThreshold(); - Logger.TryGet(LogEventLevel.Verbose, LogArea.Control)?.Log( - this, "SwipeGestureRecognizer: swipe recognized — direction={Direction}, delta={Delta}", - dir, delta); + if (!_swiping) + { + var horizontalTriggered = CanHorizontallySwipe && Math.Abs(_trackedRootPoint.X - rootPoint.X) > threshold; + var verticalTriggered = CanVerticallySwipe && Math.Abs(_trackedRootPoint.Y - rootPoint.Y) > threshold; + + if (horizontalTriggered || verticalTriggered) + { + _swiping = true; + + _trackedRootPoint = new Point( + horizontalTriggered + ? _trackedRootPoint.X - (_trackedRootPoint.X >= rootPoint.X ? threshold : -threshold) + : rootPoint.X, + verticalTriggered + ? _trackedRootPoint.Y - (_trackedRootPoint.Y >= rootPoint.Y ? threshold : -threshold) + : rootPoint.Y); + + Capture(e.Pointer); + } + } - _tracking = null; - _captured = e.Pointer; - Capture(e.Pointer); - e.Handled = true; + if (_swiping) + { + var delta = _trackedRootPoint - rootPoint; + + var now = Stopwatch.GetTimestamp(); + if (_lastTimestamp > 0) + { + var elapsedSeconds = (double)(now - _lastTimestamp) / Stopwatch.Frequency; + if (elapsedSeconds > 0) + { + var instantVelocity = delta / elapsedSeconds; + _velocity = _velocity * 0.5 + instantVelocity * 0.5; + } + } + _lastTimestamp = now; + + Target!.RaiseEvent(new SwipeGestureEventArgs(_id, delta, _velocity)); + _trackedRootPoint = rootPoint; + e.Handled = true; + } + } + } - var args = new SwipeGestureEventArgs(_gestureId, dir, delta, _initialPosition); - Target?.RaiseEvent(args); + /// + protected override void PointerCaptureLost(IPointer pointer) + { + if (pointer == _tracking) + EndGesture(); } + /// protected override void PointerReleased(PointerReleasedEventArgs e) { - if (_tracking == e.Pointer) + if (e.Pointer == _tracking && _swiping) { - Logger.TryGet(LogEventLevel.Verbose, LogArea.Control)?.Log( - this, "SwipeGestureRecognizer: pointer released without crossing threshold — gesture discarded"); - _tracking = null; + e.Handled = true; + EndGesture(); } + } - if (_captured == e.Pointer) + private void EndGesture() + { + _tracking = null; + if (_swiping) { - (e.Pointer as Pointer)?.CaptureGestureRecognizer(null); - _captured = null; + _swiping = false; + var endedArgs = new SwipeGestureEndedEventArgs(_id, _velocity); + _velocity = default; + _lastTimestamp = 0; + _id = 0; + Target!.RaiseEvent(endedArgs); } } - protected override void PointerCaptureLost(IPointer pointer) + private const double DefaultTapSize = 10; + + private double GetEffectiveThreshold() { - if (_tracking == pointer) - { - Logger.TryGet(LogEventLevel.Verbose, LogArea.Control)?.Log( - this, "SwipeGestureRecognizer: capture lost — gesture cancelled"); - _tracking = null; - } - _captured = null; + var configured = Threshold; + if (configured > 0) + return configured; + + var tapSize = AvaloniaLocator.Current?.GetService() + ?.GetTapSize(PointerType.Touch).Height ?? DefaultTapSize; + + return tapSize / 2; } } } diff --git a/src/Avalonia.Base/Input/Gestures.cs b/src/Avalonia.Base/Input/Gestures.cs index 3ae504a77f..07c9ab18be 100644 --- a/src/Avalonia.Base/Input/Gestures.cs +++ b/src/Avalonia.Base/Input/Gestures.cs @@ -30,14 +30,12 @@ namespace Avalonia.Input private static readonly WeakReference s_lastPress = new WeakReference(null); private static Point s_lastPressPoint; private static CancellationTokenSource? s_holdCancellationToken; - static Gestures() { InputElement.PointerPressedEvent.RouteFinished.Subscribe(PointerPressed); InputElement.PointerReleasedEvent.RouteFinished.Subscribe(PointerReleased); InputElement.PointerMovedEvent.RouteFinished.Subscribe(PointerMoved); } - private static object? GetCaptured(RoutedEventArgs? args) { if (args is not PointerEventArgs pointerEventArgs) diff --git a/src/Avalonia.Base/Input/InputElement.Gestures.cs b/src/Avalonia.Base/Input/InputElement.Gestures.cs index 83f350f0e7..1323e4d35e 100644 --- a/src/Avalonia.Base/Input/InputElement.Gestures.cs +++ b/src/Avalonia.Base/Input/InputElement.Gestures.cs @@ -54,6 +54,13 @@ namespace Avalonia.Input RoutedEvent.Register( nameof(SwipeGesture), RoutingStrategies.Bubble); + /// + /// Defines the event. + /// + public static readonly RoutedEvent SwipeGestureEndedEvent = + RoutedEvent.Register( + nameof(SwipeGestureEnded), RoutingStrategies.Bubble); + /// /// Defines the event. /// @@ -238,6 +245,15 @@ namespace Avalonia.Input remove { RemoveHandler(SwipeGestureEvent, value); } } + /// + /// Occurs when a swipe gesture ends on the control. + /// + public event EventHandler? SwipeGestureEnded + { + add { AddHandler(SwipeGestureEndedEvent, value); } + remove { RemoveHandler(SwipeGestureEndedEvent, value); } + } + /// /// Occurs when the user performs a rapid dragging motion in a single direction on a touchpad. /// diff --git a/src/Avalonia.Base/Input/SwipeDirection.cs b/src/Avalonia.Base/Input/SwipeDirection.cs new file mode 100644 index 0000000000..3043b443e6 --- /dev/null +++ b/src/Avalonia.Base/Input/SwipeDirection.cs @@ -0,0 +1,28 @@ +namespace Avalonia.Input +{ + /// + /// Specifies the direction of a swipe gesture. + /// + public enum SwipeDirection + { + /// + /// The swipe moved to the left. + /// + Left, + + /// + /// The swipe moved to the right. + /// + Right, + + /// + /// The swipe moved upward. + /// + Up, + + /// + /// The swipe moved downward. + /// + Down + } +} diff --git a/src/Avalonia.Base/Input/SwipeGestureEventArgs.cs b/src/Avalonia.Base/Input/SwipeGestureEventArgs.cs index 0c2a91556a..3fa9aede82 100644 --- a/src/Avalonia.Base/Input/SwipeGestureEventArgs.cs +++ b/src/Avalonia.Base/Input/SwipeGestureEventArgs.cs @@ -1,50 +1,81 @@ +using System; +using System.Threading; using Avalonia.Interactivity; namespace Avalonia.Input { /// - /// Specifies the direction of a swipe gesture. - /// - public enum SwipeDirection { Left, Right, Up, Down } - - /// - /// Provides data for the routed event. + /// Provides data for swipe gesture events. /// public class SwipeGestureEventArgs : RoutedEventArgs { - private static int _nextId = 1; - internal static int GetNextFreeId() => _nextId++; + /// + /// Initializes a new instance of the class. + /// + /// The unique identifier for this gesture. + /// The pixel delta since the last event. + /// The current swipe velocity in pixels per second. + public SwipeGestureEventArgs(int id, Vector delta, Vector velocity) + : base(InputElement.SwipeGestureEvent) + { + Id = id; + Delta = delta; + Velocity = velocity; + SwipeDirection = Math.Abs(delta.X) >= Math.Abs(delta.Y) + ? (delta.X <= 0 ? SwipeDirection.Right : SwipeDirection.Left) + : (delta.Y <= 0 ? SwipeDirection.Down : SwipeDirection.Up); + } /// - /// Gets the unique identifier for this swipe gesture instance. + /// Gets the unique identifier for this gesture sequence. /// public int Id { get; } /// - /// Gets the direction of the swipe gesture. + /// Gets the pixel delta since the last event. /// - public SwipeDirection SwipeDirection { get; } + public Vector Delta { get; } /// - /// Gets the total translation vector of the swipe gesture. + /// Gets the current swipe velocity in pixels per second. /// - public Vector Delta { get; } + public Vector Velocity { get; } /// - /// Gets the position, relative to the target element, where the swipe started. + /// Gets the direction of the dominant swipe axis. /// - public Point StartPoint { get; } + public SwipeDirection SwipeDirection { get; } + + private static int s_nextId; + internal static int GetNextFreeId() => Interlocked.Increment(ref s_nextId); + } + + /// + /// Provides data for the swipe gesture ended event. + /// + public class SwipeGestureEndedEventArgs : RoutedEventArgs + { /// - /// Initializes a new instance of . + /// Initializes a new instance of the class. /// - public SwipeGestureEventArgs(int id, SwipeDirection direction, Vector delta, Point startPoint) - : base(InputElement.SwipeGestureEvent) + /// The unique identifier for this gesture. + /// The swipe velocity at release in pixels per second. + public SwipeGestureEndedEventArgs(int id, Vector velocity) + : base(InputElement.SwipeGestureEndedEvent) { Id = id; - SwipeDirection = direction; - Delta = delta; - StartPoint = startPoint; + Velocity = velocity; } + + /// + /// Gets the unique identifier for this gesture sequence. + /// + public int Id { get; } + + /// + /// Gets the swipe velocity at release in pixels per second. + /// + public Vector Velocity { get; } } } diff --git a/src/Avalonia.Controls/Carousel.cs b/src/Avalonia.Controls/Carousel.cs index 533f7bb626..bf22671462 100644 --- a/src/Avalonia.Controls/Carousel.cs +++ b/src/Avalonia.Controls/Carousel.cs @@ -1,11 +1,13 @@ using Avalonia.Animation; using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; +using Avalonia.Input; namespace Avalonia.Controls { /// - /// An items control that displays its items as pages that fill the control. + /// An items control that displays its items as pages and can reveal adjacent pages + /// using . /// public class Carousel : SelectingItemsControl { @@ -16,13 +18,36 @@ namespace Avalonia.Controls AvaloniaProperty.Register(nameof(PageTransition)); /// - /// The default value of for + /// Defines the property. + /// + public static readonly StyledProperty IsSwipeEnabledProperty = + AvaloniaProperty.Register(nameof(IsSwipeEnabled), defaultValue: false); + + /// + /// Defines the property. + /// + public static readonly StyledProperty ViewportFractionProperty = + AvaloniaProperty.Register( + nameof(ViewportFraction), + defaultValue: 1d, + coerce: (_, value) => double.IsFinite(value) && value > 0 ? value : 1d); + + /// + /// Defines the property. + /// + public static readonly DirectProperty IsSwipingProperty = + AvaloniaProperty.RegisterDirect(nameof(IsSwiping), + o => o.IsSwiping); + + /// + /// The default value of for /// . /// private static readonly FuncTemplate DefaultPanel = new(() => new VirtualizingCarouselPanel()); private IScrollable? _scroller; + private bool _isSwiping; /// /// Initializes static members of the class. @@ -42,15 +67,51 @@ namespace Avalonia.Controls set => SetValue(PageTransitionProperty, value); } + /// + /// Gets or sets whether swipe gestures are enabled for navigating between pages. + /// When enabled, mouse pointer events are also accepted in addition to touch and pen. + /// + public bool IsSwipeEnabled + { + get => GetValue(IsSwipeEnabledProperty); + set => SetValue(IsSwipeEnabledProperty, value); + } + + /// + /// Gets or sets the fraction of the viewport occupied by each page. + /// A value of 1 shows a single full page; values below 1 reveal adjacent pages. + /// + public double ViewportFraction + { + get => GetValue(ViewportFractionProperty); + set => SetValue(ViewportFractionProperty, value); + } + + /// + /// Gets a value indicating whether a swipe gesture is currently in progress. + /// + public bool IsSwiping + { + get => _isSwiping; + internal set => SetAndRaise(IsSwipingProperty, ref _isSwiping, value); + } + /// /// Moves to the next item in the carousel. /// public void Next() { + if (ItemCount == 0) + return; + if (SelectedIndex < ItemCount - 1) { ++SelectedIndex; } + else if (WrapSelection) + { + SelectedIndex = 0; + } } /// @@ -58,18 +119,78 @@ namespace Avalonia.Controls /// public void Previous() { + if (ItemCount == 0) + return; + if (SelectedIndex > 0) { --SelectedIndex; } + else if (WrapSelection) + { + SelectedIndex = ItemCount - 1; + } + } + + internal PageSlide.SlideAxis? GetTransitionAxis() + { + var transition = PageTransition; + + if (transition is CompositePageTransition composite) + { + foreach (var t in composite.PageTransitions) + { + if (t is PageSlide slide) + return slide.Orientation; + } + + return null; + } + + return transition is PageSlide ps ? ps.Orientation : null; + } + + internal PageSlide.SlideAxis GetLayoutAxis() => GetTransitionAxis() ?? PageSlide.SlideAxis.Horizontal; + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + + if (e.Handled || ItemCount == 0) + return; + + var axis = ViewportFraction != 1d ? GetLayoutAxis() : GetTransitionAxis(); + var isVertical = axis == PageSlide.SlideAxis.Vertical; + var isHorizontal = axis == PageSlide.SlideAxis.Horizontal; + + switch (e.Key) + { + case Key.Left when !isVertical: + case Key.Up when !isHorizontal: + Previous(); + e.Handled = true; + break; + case Key.Right when !isVertical: + case Key.Down when !isHorizontal: + Next(); + e.Handled = true; + break; + case Key.Home: + SelectedIndex = 0; + e.Handled = true; + break; + case Key.End: + SelectedIndex = ItemCount - 1; + e.Handled = true; + break; + } } protected override Size ArrangeOverride(Size finalSize) { var result = base.ArrangeOverride(finalSize); - if (_scroller is not null) - _scroller.Offset = new(SelectedIndex, 0); + SyncScrollOffset(); return result; } @@ -84,11 +205,54 @@ namespace Avalonia.Controls { base.OnPropertyChanged(change); - if (change.Property == SelectedIndexProperty && _scroller is not null) + if (change.Property == SelectedIndexProperty) + { + SyncScrollOffset(); + } + + if (change.Property == IsSwipeEnabledProperty || + change.Property == PageTransitionProperty || + change.Property == ViewportFractionProperty || + change.Property == WrapSelectionProperty) + { + if (ItemsPanelRoot is VirtualizingCarouselPanel panel) + { + if (change.Property == ViewportFractionProperty && !panel.IsManagingInteractionOffset) + panel.SyncSelectionOffset(SelectedIndex); + + panel.RefreshGestureRecognizer(); + panel.InvalidateMeasure(); + } + + SyncScrollOffset(); + } + } + + private void SyncScrollOffset() + { + if (ItemsPanelRoot is VirtualizingCarouselPanel panel) { - var value = change.GetNewValue(); - _scroller.Offset = new(value, 0); + if (panel.IsManagingInteractionOffset) + return; + + panel.SyncSelectionOffset(SelectedIndex); + + if (ViewportFraction != 1d) + return; } + + if (_scroller is null) + return; + + _scroller.Offset = CreateScrollOffset(SelectedIndex); + } + + private Vector CreateScrollOffset(int index) + { + if (ViewportFraction != 1d && GetLayoutAxis() == PageSlide.SlideAxis.Vertical) + return new(0, index); + + return new(index, 0); } } } diff --git a/src/Avalonia.Controls/Page/DrawerPage.cs b/src/Avalonia.Controls/Page/DrawerPage.cs index 814e533939..1392e98fbb 100644 --- a/src/Avalonia.Controls/Page/DrawerPage.cs +++ b/src/Avalonia.Controls/Page/DrawerPage.cs @@ -211,6 +211,7 @@ namespace Avalonia.Controls private Border? _topBar; private ToggleButton? _paneButton; private Border? _backdrop; + private Point _swipeStartPoint; private IDisposable? _navBarVisibleSub; private const double EdgeGestureWidth = 20; @@ -292,6 +293,8 @@ namespace Avalonia.Controls public DrawerPage() { GestureRecognizers.Add(_swipeRecognizer); + AddHandler(PointerPressedEvent, OnSwipePointerPressed, handledEventsToo: true); + UpdateSwipeRecognizerAxes(); } /// @@ -617,6 +620,7 @@ namespace Avalonia.Controls } else if (change.Property == DrawerPlacementProperty) { + UpdateSwipeRecognizerAxes(); UpdatePanePlacement(); UpdateContentSafeAreaPadding(); } @@ -664,6 +668,12 @@ namespace Avalonia.Controls nav.SetDrawerPage(null); } + private void UpdateSwipeRecognizerAxes() + { + _swipeRecognizer.CanVerticallySwipe = IsVerticalPlacement; + _swipeRecognizer.CanHorizontallySwipe = !IsVerticalPlacement; + } + protected override void OnLoaded(RoutedEventArgs e) { base.OnLoaded(e); @@ -675,6 +685,11 @@ namespace Avalonia.Controls } } + private void OnSwipePointerPressed(object? sender, PointerPressedEventArgs e) + { + _swipeStartPoint = e.GetPosition(this); + } + protected override void OnKeyDown(KeyEventArgs e) { base.OnKeyDown(e); @@ -714,8 +729,8 @@ namespace Avalonia.Controls : EdgeGestureWidth; bool inEdge = DrawerPlacement == DrawerPlacement.Bottom - ? e.StartPoint.Y >= Bounds.Height - openGestureEdge - : e.StartPoint.Y <= openGestureEdge; + ? _swipeStartPoint.Y >= Bounds.Height - openGestureEdge + : _swipeStartPoint.Y <= openGestureEdge; if (towardPane && inEdge) { @@ -746,8 +761,8 @@ namespace Avalonia.Controls : EdgeGestureWidth; bool inEdge = IsPaneOnRight - ? e.StartPoint.X >= Bounds.Width - openGestureEdge - : e.StartPoint.X <= openGestureEdge; + ? _swipeStartPoint.X >= Bounds.Width - openGestureEdge + : _swipeStartPoint.X <= openGestureEdge; if (towardPane && inEdge) { diff --git a/src/Avalonia.Controls/Page/NavigationPage.cs b/src/Avalonia.Controls/Page/NavigationPage.cs index dd14d71a04..7f496ab10b 100644 --- a/src/Avalonia.Controls/Page/NavigationPage.cs +++ b/src/Avalonia.Controls/Page/NavigationPage.cs @@ -68,6 +68,8 @@ namespace Avalonia.Controls private bool _isBackButtonEffectivelyEnabled; private DrawerPage? _drawerPage; private IPageTransition? _overrideTransition; + private Point _swipeStartPoint; + private int _lastSwipeGestureId; private bool _hasOverrideTransition; private readonly HashSet _pageSet = new(ReferenceEqualityComparer.Instance); @@ -257,7 +259,12 @@ namespace Avalonia.Controls public NavigationPage() { SetCurrentValue(PagesProperty, new Stack()); - GestureRecognizers.Add(new SwipeGestureRecognizer { EdgeSize = EdgeGestureWidth }); + GestureRecognizers.Add(new SwipeGestureRecognizer + { + CanHorizontallySwipe = true, + CanVerticallySwipe = false + }); + AddHandler(PointerPressedEvent, OnSwipePointerPressed, handledEventsToo: true); } /// @@ -1871,18 +1878,31 @@ namespace Avalonia.Controls private void OnSwipeGesture(object? sender, SwipeGestureEventArgs e) { - if (!IsGestureEnabled || StackDepth <= 1 || _isNavigating || _modalStack.Count > 0) + if (!IsGestureEnabled || StackDepth <= 1 || _isNavigating || _modalStack.Count > 0 || e.Id == _lastSwipeGestureId) + return; + + bool inEdge = IsRtl + ? _swipeStartPoint.X >= Bounds.Width - EdgeGestureWidth + : _swipeStartPoint.X <= EdgeGestureWidth; + if (!inEdge) return; + bool shouldPop = IsRtl ? e.SwipeDirection == SwipeDirection.Left : e.SwipeDirection == SwipeDirection.Right; if (shouldPop) { e.Handled = true; + _lastSwipeGestureId = e.Id; _ = PopAsync(); } } + private void OnSwipePointerPressed(object? sender, PointerPressedEventArgs e) + { + _swipeStartPoint = e.GetPosition(this); + } + protected override void OnKeyDown(KeyEventArgs e) { base.OnKeyDown(e); diff --git a/src/Avalonia.Controls/Page/TabbedPage.cs b/src/Avalonia.Controls/Page/TabbedPage.cs index 6a5422b365..8fccb45223 100644 --- a/src/Avalonia.Controls/Page/TabbedPage.cs +++ b/src/Avalonia.Controls/Page/TabbedPage.cs @@ -26,6 +26,7 @@ namespace Avalonia.Controls private TabControl? _tabControl; private readonly Dictionary _containerPageMap = new(); private readonly Dictionary _pageContainerMap = new(); + private int _lastSwipeGestureId; private readonly SwipeGestureRecognizer _swipeRecognizer = new SwipeGestureRecognizer { IsEnabled = false @@ -92,6 +93,7 @@ namespace Avalonia.Controls Focusable = true; GestureRecognizers.Add(_swipeRecognizer); AddHandler(InputElement.SwipeGestureEvent, OnSwipeGesture); + UpdateSwipeRecognizerAxes(); } /// @@ -194,7 +196,10 @@ namespace Avalonia.Controls base.OnPropertyChanged(change); if (change.Property == TabPlacementProperty) + { ApplyTabPlacement(); + UpdateSwipeRecognizerAxes(); + } else if (change.Property == PageTransitionProperty && _tabControl != null) _tabControl.PageTransition = change.GetNewValue(); else if (change.Property == IndicatorTemplateProperty) @@ -227,6 +232,14 @@ namespace Avalonia.Controls }; } + private void UpdateSwipeRecognizerAxes() + { + var placement = ResolveTabPlacement(); + var isHorizontal = placement == TabPlacement.Top || placement == TabPlacement.Bottom; + _swipeRecognizer.CanHorizontallySwipe = isHorizontal; + _swipeRecognizer.CanVerticallySwipe = !isHorizontal; + } + private void ApplyIndicatorTemplate() { if (_tabControl == null) @@ -500,7 +513,8 @@ namespace Avalonia.Controls private void OnSwipeGesture(object? sender, SwipeGestureEventArgs e) { - if (!IsGestureEnabled || _tabControl == null) return; + if (!IsGestureEnabled || _tabControl == null || e.Id == _lastSwipeGestureId) + return; var placement = ResolveTabPlacement(); bool isHorizontal = placement == TabPlacement.Top || placement == TabPlacement.Bottom; @@ -524,6 +538,7 @@ namespace Avalonia.Controls { _tabControl.SelectedIndex = next; e.Handled = true; + _lastSwipeGestureId = e.Id; } } diff --git a/src/Avalonia.Controls/VirtualizingCarouselPanel.cs b/src/Avalonia.Controls/VirtualizingCarouselPanel.cs index 454069b4b2..66e717d265 100644 --- a/src/Avalonia.Controls/VirtualizingCarouselPanel.cs +++ b/src/Avalonia.Controls/VirtualizingCarouselPanel.cs @@ -2,11 +2,17 @@ using System.Collections.Generic; using System.Collections.Specialized; using System.Diagnostics; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Avalonia.Animation; +using Avalonia.Animation.Easings; using Avalonia.Controls.Primitives; using Avalonia.Input; +using Avalonia.Input.GestureRecognizers; +using Avalonia.Media; +using Avalonia.Styling; +using Avalonia.Utilities; namespace Avalonia.Controls { @@ -15,23 +21,76 @@ namespace Avalonia.Controls /// public class VirtualizingCarouselPanel : VirtualizingPanel, ILogicalScrollable { + private sealed class ViewportRealizedItem + { + public ViewportRealizedItem(int itemIndex, Control control) + { + ItemIndex = itemIndex; + Control = control; + } + + public int ItemIndex { get; } + public Control Control { get; } + } + private static readonly AttachedProperty RecycleKeyProperty = - AvaloniaProperty.RegisterAttached("RecycleKey"); + AvaloniaProperty.RegisterAttached("RecycleKey"); private static readonly object s_itemIsItsOwnContainer = new object(); private Size _extent; private Vector _offset; private Size _viewport; private Dictionary>? _recyclePool; + private readonly Dictionary _viewportRealized = new(); private Control? _realized; private int _realizedIndex = -1; private Control? _transitionFrom; private int _transitionFromIndex = -1; private CancellationTokenSource? _transition; + private Task? _transitionTask; private EventHandler? _scrollInvalidated; private bool _canHorizontallyScroll; private bool _canVerticallyScroll; + private SwipeGestureRecognizer? _swipeGestureRecognizer; + private int _swipeGestureId; + private bool _isDragging; + private double _totalDelta; + private bool _isForward; + private Control? _swipeTarget; + private int _swipeTargetIndex = -1; + private PageSlide.SlideAxis? _swipeAxis; + private PageSlide.SlideAxis _lockedAxis; + + private const double SwipeCommitThreshold = 0.25; + private const double VelocityCommitThreshold = 800; + private const double MinSwipeDistanceForVelocityCommit = 0.05; + private const double RubberBandFactor = 0.3; + private const double RubberBandReturnDuration = 0.16; + private const double MaxCompletionDuration = 0.35; + private const double MinCompletionDuration = 0.12; + + private static readonly StyledProperty CompletionProgressProperty = + AvaloniaProperty.Register("CompletionProgress"); + private static readonly StyledProperty OffsetAnimationProgressProperty = + AvaloniaProperty.Register("OffsetAnimationProgress"); + + private CancellationTokenSource? _completionCts; + private CancellationTokenSource? _offsetAnimationCts; + private double _completionEndProgress; + private bool _isRubberBanding; + private double _dragStartOffset; + private double _progressStartOffset; + private double _offsetAnimationStart; + private double _offsetAnimationTarget; + private double _activeViewportTargetOffset; + private int _progressFromIndex = -1; + private int _progressToIndex = -1; + + internal bool IsManagingInteractionOffset => + UsesViewportFractionLayout() && + (_isDragging || _offsetAnimationCts is { IsCancellationRequested: false }); + bool ILogicalScrollable.CanHorizontallyScroll { get => _canHorizontallyScroll; @@ -55,12 +114,7 @@ namespace Avalonia.Controls Vector IScrollable.Offset { get => _offset; - set - { - if ((int)_offset.X != value.X) - InvalidateMeasure(); - _offset = value; - } + set => SetOffset(value); } private Size Extent @@ -99,37 +153,335 @@ namespace Avalonia.Controls Control? ILogicalScrollable.GetControlInDirection(NavigationDirection direction, Control? from) => null; void ILogicalScrollable.RaiseScrollInvalidated(EventArgs e) => _scrollInvalidated?.Invoke(this, e); + private bool UsesViewportFractionLayout() + { + return ItemsControl is Carousel carousel && + !MathUtilities.AreClose(carousel.ViewportFraction, 1d); + } + + private PageSlide.SlideAxis GetLayoutAxis() + { + return (ItemsControl as Carousel)?.GetLayoutAxis() ?? PageSlide.SlideAxis.Horizontal; + } + + private double GetViewportFraction() + { + return (ItemsControl as Carousel)?.ViewportFraction ?? 1d; + } + + private double GetViewportUnits() + { + return 1d / GetViewportFraction(); + } + + private double GetPrimaryOffset(Vector offset) + { + return GetLayoutAxis() == PageSlide.SlideAxis.Vertical ? offset.Y : offset.X; + } + + private double GetPrimarySize(Size size) + { + return GetLayoutAxis() == PageSlide.SlideAxis.Vertical ? size.Height : size.Width; + } + + private double GetCrossSize(Size size) + { + return GetLayoutAxis() == PageSlide.SlideAxis.Vertical ? size.Width : size.Height; + } + + private Size CreateLogicalSize(double primary) + { + return GetLayoutAxis() == PageSlide.SlideAxis.Vertical ? + new Size(1, primary) : + new Size(primary, 1); + } + + private Size CreateItemSize(double primary, double cross) + { + return GetLayoutAxis() == PageSlide.SlideAxis.Vertical ? + new Size(cross, primary) : + new Size(primary, cross); + } + + private Rect CreateItemRect(double primaryOffset, double primarySize, double crossSize) + { + return GetLayoutAxis() == PageSlide.SlideAxis.Vertical ? + new Rect(0, primaryOffset, crossSize, primarySize) : + new Rect(primaryOffset, 0, primarySize, crossSize); + } + + private Vector WithPrimaryOffset(Vector offset, double primary) + { + return GetLayoutAxis() == PageSlide.SlideAxis.Vertical ? + new Vector(offset.X, primary) : + new Vector(primary, offset.Y); + } + + private Size ResolveLayoutSize(Size availableSize) + { + var owner = ItemsControl as Control; + + double ResolveDimension(double available, double bounds, double ownerBounds, double ownerExplicit) + { + if (!double.IsInfinity(available) && available > 0) + return available; + + if (bounds > 0) + return bounds; + + if (ownerBounds > 0) + return ownerBounds; + + return double.IsNaN(ownerExplicit) ? 0 : ownerExplicit; + } + + var width = ResolveDimension(availableSize.Width, Bounds.Width, owner?.Bounds.Width ?? 0, owner?.Width ?? double.NaN); + var height = ResolveDimension(availableSize.Height, Bounds.Height, owner?.Bounds.Height ?? 0, owner?.Height ?? double.NaN); + return new Size(width, height); + } + + private double GetViewportItemExtent(Size size) + { + var viewportUnits = GetViewportUnits(); + return viewportUnits <= 0 ? 0 : GetPrimarySize(size) / viewportUnits; + } + + private bool UsesViewportWrapLayout() + { + return UsesViewportFractionLayout() && + ItemsControl is Carousel { WrapSelection: true } && + Items.Count > 1; + } + + private static int NormalizeIndex(int index, int count) + { + return ((index % count) + count) % count; + } + + private double GetNearestLogicalOffset(int itemIndex, double referenceOffset) + { + if (!UsesViewportWrapLayout() || Items.Count == 0) + return Math.Clamp(itemIndex, 0, Math.Max(0, Items.Count - 1)); + + var wrapSpan = Items.Count; + var wrapMultiplier = Math.Round((referenceOffset - itemIndex) / wrapSpan); + return itemIndex + (wrapMultiplier * wrapSpan); + } + + private bool IsPreferredViewportSlot(int candidateLogicalIndex, int existingLogicalIndex, double primaryOffset) + { + var candidateDistance = Math.Abs(candidateLogicalIndex - primaryOffset); + var existingDistance = Math.Abs(existingLogicalIndex - primaryOffset); + + if (!MathUtilities.AreClose(candidateDistance, existingDistance)) + return candidateDistance < existingDistance; + + var candidateInRange = candidateLogicalIndex >= 0 && candidateLogicalIndex < Items.Count; + var existingInRange = existingLogicalIndex >= 0 && existingLogicalIndex < Items.Count; + + if (candidateInRange != existingInRange) + return candidateInRange; + + if (_isDragging) + return _isForward ? candidateLogicalIndex > existingLogicalIndex : candidateLogicalIndex < existingLogicalIndex; + + return candidateLogicalIndex < existingLogicalIndex; + } + + private IReadOnlyList<(int LogicalIndex, int ItemIndex)> GetRequiredViewportSlots(double primaryOffset) + { + if (Items.Count == 0) + return Array.Empty<(int LogicalIndex, int ItemIndex)>(); + + var viewportUnits = GetViewportUnits(); + var edgeInset = (viewportUnits - 1) / 2; + var start = (int)Math.Floor(primaryOffset - edgeInset); + var end = (int)Math.Ceiling(primaryOffset + viewportUnits - edgeInset) - 1; + + if (!UsesViewportWrapLayout()) + { + start = Math.Max(0, start); + end = Math.Min(Items.Count - 1, end); + + if (start > end) + return Array.Empty<(int LogicalIndex, int ItemIndex)>(); + + var result = new (int LogicalIndex, int ItemIndex)[end - start + 1]; + + for (var i = 0; i < result.Length; ++i) + { + var index = start + i; + result[i] = (index, index); + } + + return result; + } + + var bestSlots = new Dictionary(); + + for (var logicalIndex = start; logicalIndex <= end; ++logicalIndex) + { + var itemIndex = NormalizeIndex(logicalIndex, Items.Count); + + if (!bestSlots.TryGetValue(itemIndex, out var existingLogicalIndex) || + IsPreferredViewportSlot(logicalIndex, existingLogicalIndex, primaryOffset)) + { + bestSlots[itemIndex] = logicalIndex; + } + } + + return bestSlots + .Select(x => (LogicalIndex: x.Value, ItemIndex: x.Key)) + .OrderBy(x => x.LogicalIndex) + .ToArray(); + } + + private bool ViewportSlotsChanged(double oldPrimaryOffset, double newPrimaryOffset) + { + var oldSlots = GetRequiredViewportSlots(oldPrimaryOffset); + var newSlots = GetRequiredViewportSlots(newPrimaryOffset); + + if (oldSlots.Count != newSlots.Count) + return true; + + for (var i = 0; i < oldSlots.Count; ++i) + { + if (oldSlots[i].LogicalIndex != newSlots[i].LogicalIndex || + oldSlots[i].ItemIndex != newSlots[i].ItemIndex) + { + return true; + } + } + + return false; + } + + private void SetOffset(Vector value) + { + if (UsesViewportFractionLayout()) + { + var oldPrimaryOffset = GetPrimaryOffset(_offset); + var newPrimaryOffset = GetPrimaryOffset(value); + + if (MathUtilities.AreClose(oldPrimaryOffset, newPrimaryOffset)) + { + _offset = value; + return; + } + + _offset = value; + + var rangeChanged = ViewportSlotsChanged(oldPrimaryOffset, newPrimaryOffset); + + if (rangeChanged) + InvalidateMeasure(); + else + InvalidateArrange(); + + _scrollInvalidated?.Invoke(this, EventArgs.Empty); + return; + } + + if ((int)_offset.X != value.X) + InvalidateMeasure(); + + _offset = value; + } + + private void ClearViewportRealized() + { + if (_viewportRealized.Count == 0) + return; + + foreach (var element in _viewportRealized.Values.Select(x => x.Control).ToArray()) + RecycleElement(element); + + _viewportRealized.Clear(); + } + + private void ResetSinglePageState() + { + _transition?.Cancel(); + _transition = null; + _transitionTask = null; + + if (_transitionFrom is not null) + RecycleElement(_transitionFrom); + + if (_swipeTarget is not null) + RecycleElement(_swipeTarget); + + if (_realized is not null) + RecycleElement(_realized); + + _transitionFrom = null; + _transitionFromIndex = -1; + _swipeTarget = null; + _swipeTargetIndex = -1; + _realized = null; + _realizedIndex = -1; + } + + private void CancelOffsetAnimation() + { + _offsetAnimationCts?.Cancel(); + _offsetAnimationCts = null; + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + RefreshGestureRecognizer(); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + TeardownGestureRecognizer(); + } + protected override Size MeasureOverride(Size availableSize) + { + if (UsesViewportFractionLayout()) + return MeasureViewportFractionOverride(availableSize); + + ClearViewportRealized(); + CancelOffsetAnimation(); + + return MeasureSinglePageOverride(availableSize); + } + + private Size MeasureSinglePageOverride(Size availableSize) { var items = Items; var index = (int)_offset.X; + CompleteFinishedTransitionIfNeeded(); + if (index != _realizedIndex) { if (_realized is not null) { - var cancelTransition = _transition is not null; - // Cancel any already running transition, and recycle the element we're transitioning from. - if (cancelTransition) + if (_transition is not null) { - _transition!.Cancel(); + _transition.Cancel(); _transition = null; + _transitionTask = null; if (_transitionFrom is not null) RecycleElement(_transitionFrom); _transitionFrom = null; _transitionFromIndex = -1; + ResetTransitionState(_realized); } - if (cancelTransition || GetTransition() is null) + if (GetTransition() is null) { - // If don't have a transition or we've just canceled a transition then recycle the element - // we're moving from. RecycleElement(_realized); } else { - // We have a transition to do: record the current element as the element we're transitioning + // Record the current element as the element we're transitioning // from and we'll start the transition in the arrange pass. _transitionFrom = _realized; _transitionFromIndex = _realizedIndex; @@ -163,6 +515,14 @@ namespace Avalonia.Controls } protected override Size ArrangeOverride(Size finalSize) + { + if (UsesViewportFractionLayout()) + return ArrangeViewportFractionOverride(finalSize); + + return ArrangeSinglePageOverride(finalSize); + } + + private Size ArrangeSinglePageOverride(Size finalSize) { var result = base.ArrangeOverride(finalSize); @@ -180,19 +540,115 @@ namespace Avalonia.Controls forward = forward && !(_transitionFromIndex == 0 && _realizedIndex == Items.Count - 1); } - transition.Start(_transitionFrom, to, forward, _transition.Token) - .ContinueWith(TransitionFinished, TaskScheduler.FromCurrentSynchronizationContext()); + _transitionTask = RunTransitionAsync(_transition, _transitionFrom, to, forward, transition); } return result; } + private Size MeasureViewportFractionOverride(Size availableSize) + { + ResetSinglePageState(); + + if (Items.Count == 0) + { + ClearViewportRealized(); + Extent = Viewport = new(0, 0); + return default; + } + + var layoutSize = ResolveLayoutSize(availableSize); + var primarySize = GetPrimarySize(layoutSize); + var crossSize = GetCrossSize(layoutSize); + var viewportUnits = GetViewportUnits(); + + if (primarySize <= 0 || viewportUnits <= 0) + { + ClearViewportRealized(); + Extent = Viewport = new(0, 0); + return default; + } + + var itemPrimarySize = primarySize / viewportUnits; + var itemSize = CreateItemSize(itemPrimarySize, crossSize); + var requiredSlots = GetRequiredViewportSlots(GetPrimaryOffset(_offset)); + var requiredMap = requiredSlots.ToDictionary(x => x.LogicalIndex, x => x.ItemIndex); + + foreach (var entry in _viewportRealized.ToArray()) + { + if (!requiredMap.TryGetValue(entry.Key, out var itemIndex) || + entry.Value.ItemIndex != itemIndex) + { + RecycleElement(entry.Value.Control); + _viewportRealized.Remove(entry.Key); + } + } + + foreach (var slot in requiredSlots) + { + if (!_viewportRealized.ContainsKey(slot.LogicalIndex)) + { + _viewportRealized[slot.LogicalIndex] = new ViewportRealizedItem( + slot.ItemIndex, + GetOrCreateElement(Items, slot.ItemIndex)); + } + } + + var maxCrossDesiredSize = 0d; + + foreach (var element in _viewportRealized.Values.Select(x => x.Control)) + { + element.Measure(itemSize); + maxCrossDesiredSize = Math.Max(maxCrossDesiredSize, GetCrossSize(element.DesiredSize)); + } + + Viewport = CreateLogicalSize(viewportUnits); + Extent = CreateLogicalSize(Math.Max(0, Items.Count + viewportUnits - 1)); + + var desiredPrimary = double.IsInfinity(primarySize) ? itemPrimarySize * viewportUnits : primarySize; + var desiredCross = double.IsInfinity(crossSize) ? maxCrossDesiredSize : crossSize; + return CreateItemSize(desiredPrimary, desiredCross); + } + + private Size ArrangeViewportFractionOverride(Size finalSize) + { + var primarySize = GetPrimarySize(finalSize); + var crossSize = GetCrossSize(finalSize); + var viewportUnits = GetViewportUnits(); + + if (primarySize <= 0 || viewportUnits <= 0) + return finalSize; + + if (_viewportRealized.Count == 0 && Items.Count > 0) + { + InvalidateMeasure(); + return finalSize; + } + + var itemPrimarySize = primarySize / viewportUnits; + var edgeInset = (viewportUnits - 1) / 2; + var primaryOffset = GetPrimaryOffset(_offset); + + foreach (var entry in _viewportRealized.OrderBy(x => x.Key)) + { + var itemOffset = (edgeInset + entry.Key - primaryOffset) * itemPrimarySize; + var rect = CreateItemRect(itemOffset, itemPrimarySize, crossSize); + entry.Value.Control.IsVisible = true; + entry.Value.Control.Arrange(rect); + } + + return finalSize; + } + protected override IInputElement? GetControl(NavigationDirection direction, IInputElement? from, bool wrap) => null; protected internal override Control? ContainerFromIndex(int index) { if (index < 0 || index >= Items.Count) return null; + var viewportRealized = _viewportRealized.Values.FirstOrDefault(x => x.ItemIndex == index); + if (viewportRealized is not null) + return viewportRealized.Control; if (index == _realizedIndex) return _realized; if (Items[index] is Control c && c.GetValue(RecycleKeyProperty) == s_itemIsItsOwnContainer) @@ -202,11 +658,20 @@ namespace Avalonia.Controls protected internal override IEnumerable? GetRealizedContainers() { + if (_viewportRealized.Count > 0) + return _viewportRealized.OrderBy(x => x.Key).Select(x => x.Value.Control); + return _realized is not null ? new[] { _realized } : null; } protected internal override int IndexFromContainer(Control container) { + foreach (var entry in _viewportRealized) + { + if (ReferenceEquals(entry.Value.Control, container)) + return entry.Value.ItemIndex; + } + return container == _realized ? _realizedIndex : -1; } @@ -219,8 +684,21 @@ namespace Avalonia.Controls { base.OnItemsChanged(items, e); + if (UsesViewportFractionLayout() || _viewportRealized.Count > 0) + { + ClearViewportRealized(); + InvalidateMeasure(); + return; + } + void Add(int index, int count) { + if (_realized is null) + { + InvalidateMeasure(); + return; + } + if (index <= _realizedIndex) _realizedIndex += count; } @@ -314,6 +792,10 @@ namespace Avalonia.Controls private Control? GetRealizedElement(int index) { + var viewportRealized = _viewportRealized.Values.FirstOrDefault(x => x.ItemIndex == index); + if (viewportRealized is not null) + return viewportRealized.Control; + return _realizedIndex == index ? _realized : null; } @@ -379,9 +861,13 @@ namespace Avalonia.Controls var recycleKey = element.GetValue(RecycleKeyProperty); Debug.Assert(recycleKey is not null); + // Hide first so cleanup doesn't visibly snap transforms/opacity for a frame. + element.IsVisible = false; + ResetTransitionState(element); + if (recycleKey == s_itemIsItsOwnContainer) { - element.IsVisible = false; + return; } else { @@ -395,22 +881,764 @@ namespace Avalonia.Controls } pool.Push(element); - element.IsVisible = false; } } private IPageTransition? GetTransition() => (ItemsControl as Carousel)?.PageTransition; - private void TransitionFinished(Task task) + private void CompleteFinishedTransitionIfNeeded() + { + if (_transition is not null && _transitionTask?.IsCompleted == true) + { + if (_transitionFrom is not null) + RecycleElement(_transitionFrom); + + _transition = null; + _transitionTask = null; + _transitionFrom = null; + _transitionFromIndex = -1; + } + } + + private async Task RunTransitionAsync( + CancellationTokenSource transitionCts, + Control transitionFrom, + Control transitionTo, + bool forward, + IPageTransition transition) { - if (task.IsCanceled) + try + { + await transition.Start(transitionFrom, transitionTo, forward, transitionCts.Token); + } + catch (OperationCanceledException) + { + // Expected when a transition is interrupted by a newer navigation action. + } + catch (Exception e) + { + _ = e; + } + + if (transitionCts.IsCancellationRequested || !ReferenceEquals(_transition, transitionCts)) return; if (_transitionFrom is not null) RecycleElement(_transitionFrom); _transition = null; + _transitionTask = null; _transitionFrom = null; _transitionFromIndex = -1; } + + internal void SyncSelectionOffset(int selectedIndex) + { + if (!UsesViewportFractionLayout()) + { + SetOffset(WithPrimaryOffset(_offset, selectedIndex)); + return; + } + + var currentOffset = GetPrimaryOffset(_offset); + var targetOffset = GetNearestLogicalOffset(selectedIndex, currentOffset); + + if (MathUtilities.AreClose(currentOffset, targetOffset)) + { + SetOffset(WithPrimaryOffset(_offset, targetOffset)); + return; + } + + if (_isDragging || _offsetAnimationCts is { IsCancellationRequested: false }) + return; + + var transition = GetTransition(); + var canAnimate = transition is not null && Math.Abs(targetOffset - currentOffset) <= 1.001; + + if (!canAnimate) + { + ResetViewportTransitionState(); + ClearFractionalProgressContext(); + SetOffset(WithPrimaryOffset(_offset, targetOffset)); + return; + } + + var fromIndex = Items.Count > 0 ? NormalizeIndex((int)Math.Round(currentOffset), Items.Count) : -1; + var forward = targetOffset > currentOffset; + + ResetViewportTransitionState(); + SetFractionalProgressContext(fromIndex, selectedIndex, forward, currentOffset, targetOffset); + _ = AnimateViewportOffsetAsync( + currentOffset, + targetOffset, + TimeSpan.FromSeconds(MaxCompletionDuration), + new QuadraticEaseOut(), + () => + { + ResetViewportTransitionState(); + ClearFractionalProgressContext(); + }); + } + + /// + /// Refreshes the gesture recognizer based on the carousel's IsSwipeEnabled and PageTransition settings. + /// + internal void RefreshGestureRecognizer() + { + TeardownGestureRecognizer(); + + if (ItemsControl is not Carousel carousel || !carousel.IsSwipeEnabled) + return; + + _swipeAxis = UsesViewportFractionLayout() ? carousel.GetLayoutAxis() : carousel.GetTransitionAxis(); + + _swipeGestureRecognizer = new SwipeGestureRecognizer + { + CanHorizontallySwipe = _swipeAxis != PageSlide.SlideAxis.Vertical, + CanVerticallySwipe = _swipeAxis != PageSlide.SlideAxis.Horizontal, + IsMouseEnabled = true, + }; + + GestureRecognizers.Add(_swipeGestureRecognizer); + AddHandler(InputElement.SwipeGestureEvent, OnSwipeGesture); + AddHandler(InputElement.SwipeGestureEndedEvent, OnSwipeGestureEnded); + } + + private void TeardownGestureRecognizer() + { + _completionCts?.Cancel(); + _completionCts = null; + CancelOffsetAnimation(); + + if (_swipeGestureRecognizer is not null) + { + GestureRecognizers.Remove(_swipeGestureRecognizer); + _swipeGestureRecognizer = null; + } + + RemoveHandler(InputElement.SwipeGestureEvent, OnSwipeGesture); + RemoveHandler(InputElement.SwipeGestureEndedEvent, OnSwipeGestureEnded); + ResetSwipeState(); + } + + private Control? FindViewportControl(int itemIndex) + { + return _viewportRealized.Values.FirstOrDefault(x => x.ItemIndex == itemIndex)?.Control; + } + + private void SetFractionalProgressContext(int fromIndex, int toIndex, bool forward, double startOffset, double targetOffset) + { + _progressFromIndex = fromIndex; + _progressToIndex = toIndex; + _isForward = forward; + _progressStartOffset = startOffset; + _activeViewportTargetOffset = targetOffset; + } + + private void ClearFractionalProgressContext() + { + _progressFromIndex = -1; + _progressToIndex = -1; + _progressStartOffset = 0; + _activeViewportTargetOffset = 0; + } + + private double GetFractionalTransitionProgress(double currentOffset) + { + var totalDistance = Math.Abs(_activeViewportTargetOffset - _progressStartOffset); + if (totalDistance <= 0) + return 0; + + return Math.Clamp(Math.Abs(currentOffset - _progressStartOffset) / totalDistance, 0, 1); + } + + private void ResetViewportTransitionState() + { + foreach (var element in _viewportRealized.Values.Select(x => x.Control)) + ResetTransitionState(element); + } + + private void OnSwipeGesture(object? sender, SwipeGestureEventArgs e) + { + if (ItemsControl is not Carousel carousel || !carousel.IsSwipeEnabled) + return; + + if (UsesViewportFractionLayout()) + { + OnViewportFractionSwipeGesture(carousel, e); + return; + } + + if (_realizedIndex < 0 || Items.Count == 0) + return; + + if (_completionCts is { IsCancellationRequested: false }) + { + _completionCts.Cancel(); + _completionCts = null; + + var wasCommit = _completionEndProgress > 0.5; + if (wasCommit && _swipeTarget is not null) + { + if (_realized != null) + RecycleElement(_realized); + + _realized = _swipeTarget; + _realizedIndex = _swipeTargetIndex; + carousel.SelectedIndex = _swipeTargetIndex; + } + else + { + ResetSwipeState(); + } + + _swipeTarget = null; + _swipeTargetIndex = -1; + _totalDelta = 0; + } + + if (_isDragging && e.Id != _swipeGestureId) + return; + + if (!_isDragging) + { + // Lock the axis on gesture start to keep diagonal drags stable. + _lockedAxis = _swipeAxis ?? (Math.Abs(e.Delta.X) >= Math.Abs(e.Delta.Y) ? + PageSlide.SlideAxis.Horizontal : + PageSlide.SlideAxis.Vertical); + } + + var delta = _lockedAxis == PageSlide.SlideAxis.Horizontal ? e.Delta.X : e.Delta.Y; + + if (!_isDragging) + { + _isForward = delta > 0; + _isRubberBanding = false; + var currentIndex = _realizedIndex; + var targetIndex = _isForward ? currentIndex + 1 : currentIndex - 1; + + if (targetIndex >= Items.Count) + { + if (carousel.WrapSelection) + targetIndex = 0; + else + _isRubberBanding = true; + } + else if (targetIndex < 0) + { + if (carousel.WrapSelection) + targetIndex = Items.Count - 1; + else + _isRubberBanding = true; + } + + if (!_isRubberBanding && (targetIndex == currentIndex || targetIndex < 0 || targetIndex >= Items.Count)) + return; + + _isDragging = true; + _swipeGestureId = e.Id; + _totalDelta = 0; + _swipeTargetIndex = _isRubberBanding ? -1 : targetIndex; + carousel.IsSwiping = true; + + if (_transition is not null) + { + _transition.Cancel(); + _transition = null; + if (_transitionFrom is not null) + RecycleElement(_transitionFrom); + _transitionFrom = null; + _transitionFromIndex = -1; + } + + if (!_isRubberBanding) + { + _swipeTarget = GetOrCreateElement(Items, _swipeTargetIndex); + _swipeTarget.Measure(Bounds.Size); + _swipeTarget.Arrange(new Rect(Bounds.Size)); + _swipeTarget.IsVisible = true; + } + } + + _totalDelta += delta; + + // Clamp so totalDelta cannot cross zero (absorbs touch jitter). + if (_isForward) + _totalDelta = Math.Max(0, _totalDelta); + else + _totalDelta = Math.Min(0, _totalDelta); + + var size = _lockedAxis == PageSlide.SlideAxis.Horizontal ? Bounds.Width : Bounds.Height; + if (size <= 0) + return; + + var rawProgress = Math.Clamp(Math.Abs(_totalDelta) / size, 0, 1); + var progress = _isRubberBanding + ? RubberBandFactor * Math.Sqrt(rawProgress) + : rawProgress; + + if (GetTransition() is IProgressPageTransition progressive) + { + progressive.Update( + progress, + _realized, + _isRubberBanding ? null : _swipeTarget, + _isForward, + size, + Array.Empty()); + } + + e.Handled = true; + } + + private void OnViewportFractionSwipeGesture(Carousel carousel, SwipeGestureEventArgs e) + { + if (_offsetAnimationCts is { IsCancellationRequested: false }) + { + CancelOffsetAnimation(); + SetOffset(WithPrimaryOffset(_offset, GetNearestLogicalOffset(carousel.SelectedIndex, GetPrimaryOffset(_offset)))); + } + + if (_isDragging && e.Id != _swipeGestureId) + return; + + var delta = _lockedAxis == PageSlide.SlideAxis.Horizontal ? e.Delta.X : e.Delta.Y; + + if (!_isDragging) + { + _lockedAxis = carousel.GetLayoutAxis(); + _swipeGestureId = e.Id; + _dragStartOffset = GetNearestLogicalOffset(carousel.SelectedIndex, GetPrimaryOffset(_offset)); + _totalDelta = 0; + _isDragging = true; + _isRubberBanding = false; + carousel.IsSwiping = true; + _isForward = delta > 0; + var targetIndex = _isForward ? carousel.SelectedIndex + 1 : carousel.SelectedIndex - 1; + + if (targetIndex >= Items.Count || targetIndex < 0) + { + if (carousel.WrapSelection && Items.Count > 1) + targetIndex = NormalizeIndex(targetIndex, Items.Count); + else + _isRubberBanding = true; + } + + var targetOffset = _isForward ? _dragStartOffset + 1 : _dragStartOffset - 1; + SetFractionalProgressContext( + carousel.SelectedIndex, + _isRubberBanding ? -1 : targetIndex, + _isForward, + _dragStartOffset, + targetOffset); + ResetViewportTransitionState(); + } + + _totalDelta += delta; + + if (_isForward) + _totalDelta = Math.Max(0, _totalDelta); + else + _totalDelta = Math.Min(0, _totalDelta); + + var itemExtent = GetViewportItemExtent(Bounds.Size); + if (itemExtent <= 0) + return; + + var logicalDelta = Math.Clamp(Math.Abs(_totalDelta) / itemExtent, 0, 1); + var proposedOffset = _dragStartOffset + (_isForward ? logicalDelta : -logicalDelta); + + if (!_isRubberBanding) + { + proposedOffset = Math.Clamp( + proposedOffset, + Math.Min(_dragStartOffset, _activeViewportTargetOffset), + Math.Max(_dragStartOffset, _activeViewportTargetOffset)); + } + else if (proposedOffset < 0) + { + proposedOffset = -(RubberBandFactor * Math.Sqrt(-proposedOffset)); + } + else + { + var maxOffset = Math.Max(0, Items.Count - 1); + proposedOffset = maxOffset + (RubberBandFactor * Math.Sqrt(proposedOffset - maxOffset)); + } + + SetOffset(WithPrimaryOffset(_offset, proposedOffset)); + + if (GetTransition() is IProgressPageTransition progressive) + { + var currentOffset = GetPrimaryOffset(_offset); + var progress = Math.Clamp(Math.Abs(currentOffset - _dragStartOffset), 0, 1); + progressive.Update( + progress, + FindViewportControl(_progressFromIndex), + FindViewportControl(_progressToIndex), + _isForward, + GetViewportItemExtent(Bounds.Size), + BuildFractionalVisibleItems(currentOffset)); + } + + e.Handled = true; + } + + private void OnViewportFractionSwipeGestureEnded(Carousel carousel, SwipeGestureEndedEventArgs e) + { + var itemExtent = GetViewportItemExtent(Bounds.Size); + var currentOffset = GetPrimaryOffset(_offset); + var currentProgress = Math.Abs(currentOffset - _dragStartOffset); + var velocity = _lockedAxis == PageSlide.SlideAxis.Horizontal ? Math.Abs(e.Velocity.X) : Math.Abs(e.Velocity.Y); + var targetIndex = _progressToIndex; + var canCommit = !_isRubberBanding && targetIndex >= 0; + var commit = canCommit && + (currentProgress >= SwipeCommitThreshold || + (velocity > VelocityCommitThreshold && currentProgress >= MinSwipeDistanceForVelocityCommit)); + var endOffset = commit + ? _activeViewportTargetOffset + : GetNearestLogicalOffset(carousel.SelectedIndex, currentOffset); + var remainingDistance = Math.Abs(endOffset - currentOffset); + var durationSeconds = _isRubberBanding + ? RubberBandReturnDuration + : velocity > 0 && itemExtent > 0 + ? Math.Clamp(remainingDistance * itemExtent / velocity, MinCompletionDuration, MaxCompletionDuration) + : MaxCompletionDuration; + var easing = _isRubberBanding ? (Easing)new SineEaseOut() : new QuadraticEaseOut(); + + _isDragging = false; + _ = AnimateViewportOffsetAsync( + currentOffset, + endOffset, + TimeSpan.FromSeconds(durationSeconds), + easing, + () => + { + _totalDelta = 0; + _isRubberBanding = false; + carousel.IsSwiping = false; + + if (commit) + { + SetOffset(WithPrimaryOffset(_offset, GetNearestLogicalOffset(targetIndex, endOffset))); + carousel.SelectedIndex = targetIndex; + } + else + { + SetOffset(WithPrimaryOffset(_offset, GetNearestLogicalOffset(carousel.SelectedIndex, endOffset))); + } + + ResetViewportTransitionState(); + ClearFractionalProgressContext(); + }); + } + + private async Task AnimateViewportOffsetAsync( + double fromOffset, + double toOffset, + TimeSpan duration, + Easing easing, + Action onCompleted) + { + CancelOffsetAnimation(); + var offsetAnimationCts = new CancellationTokenSource(); + _offsetAnimationCts = offsetAnimationCts; + var cancellationToken = offsetAnimationCts.Token; + + var animation = new Animation.Animation + { + FillMode = FillMode.Forward, + Duration = duration, + Easing = easing, + Children = + { + new KeyFrame + { + Setters = { new Setter(OffsetAnimationProgressProperty, 0d) }, + Cue = new Cue(0d) + }, + new KeyFrame + { + Setters = { new Setter(OffsetAnimationProgressProperty, 1d) }, + Cue = new Cue(1d) + } + } + }; + + _offsetAnimationStart = fromOffset; + _offsetAnimationTarget = toOffset; + SetValue(OffsetAnimationProgressProperty, 0d); + + try + { + await animation.RunAsync(this, null, cancellationToken); + + if (cancellationToken.IsCancellationRequested) + return; + + SetOffset(WithPrimaryOffset(_offset, toOffset)); + + if (UsesViewportFractionLayout() && + GetTransition() is IProgressPageTransition progressive) + { + var transitionProgress = GetFractionalTransitionProgress(toOffset); + progressive.Update( + transitionProgress, + FindViewportControl(_progressFromIndex), + FindViewportControl(_progressToIndex), + _isForward, + GetViewportItemExtent(Bounds.Size), + BuildFractionalVisibleItems(toOffset)); + } + + onCompleted(); + } + finally + { + if (ReferenceEquals(_offsetAnimationCts, offsetAnimationCts)) + _offsetAnimationCts = null; + } + } + + private void OnSwipeGestureEnded(object? sender, SwipeGestureEndedEventArgs e) + { + if (!_isDragging || e.Id != _swipeGestureId || ItemsControl is not Carousel carousel) + return; + + if (UsesViewportFractionLayout()) + { + OnViewportFractionSwipeGestureEnded(carousel, e); + return; + } + + var size = _lockedAxis == PageSlide.SlideAxis.Horizontal ? Bounds.Width : Bounds.Height; + var rawProgress = size > 0 ? Math.Abs(_totalDelta) / size : 0; + var currentProgress = _isRubberBanding + ? RubberBandFactor * Math.Sqrt(rawProgress) + : rawProgress; + var velocity = _lockedAxis == PageSlide.SlideAxis.Horizontal + ? Math.Abs(e.Velocity.X) + : Math.Abs(e.Velocity.Y); + var commit = !_isRubberBanding + && (currentProgress >= SwipeCommitThreshold || + (velocity > VelocityCommitThreshold && currentProgress >= MinSwipeDistanceForVelocityCommit)) + && _swipeTarget is not null; + + _completionEndProgress = commit ? 1.0 : 0.0; + var remainingDistance = Math.Abs(_completionEndProgress - currentProgress); + var durationSeconds = _isRubberBanding + ? RubberBandReturnDuration + : velocity > 0 + ? Math.Clamp(remainingDistance * size / velocity, MinCompletionDuration, MaxCompletionDuration) + : MaxCompletionDuration; + Easing easing = _isRubberBanding ? new SineEaseOut() : new QuadraticEaseOut(); + + _completionCts?.Cancel(); + var completionCts = new CancellationTokenSource(); + _completionCts = completionCts; + + SetValue(CompletionProgressProperty, currentProgress); + + var animation = new Animation.Animation + { + FillMode = FillMode.Forward, + Easing = easing, + Duration = TimeSpan.FromSeconds(durationSeconds), + Children = + { + new KeyFrame + { + Setters = { new Setter { Property = CompletionProgressProperty, Value = currentProgress } }, + Cue = new Cue(0d) + }, + new KeyFrame + { + Setters = { new Setter { Property = CompletionProgressProperty, Value = _completionEndProgress } }, + Cue = new Cue(1d) + } + } + }; + + _isDragging = false; + _ = RunCompletionAnimation(animation, carousel, completionCts); + } + + private async Task RunCompletionAnimation( + Animation.Animation animation, + Carousel carousel, + CancellationTokenSource completionCts) + { + var cancellationToken = completionCts.Token; + + try + { + await animation.RunAsync(this, null, cancellationToken); + + if (cancellationToken.IsCancellationRequested) + return; + + if (GetTransition() is IProgressPageTransition progressive) + { + var swipeTarget = ReferenceEquals(_realized, _swipeTarget) ? null : _swipeTarget; + var size = _lockedAxis == PageSlide.SlideAxis.Horizontal ? Bounds.Width : Bounds.Height; + progressive.Update( + _completionEndProgress, + _realized, + swipeTarget, + _isForward, + size, + Array.Empty()); + } + + var commit = _completionEndProgress > 0.5; + + if (commit && _swipeTarget is not null) + { + var targetIndex = _swipeTargetIndex; + var targetElement = _swipeTarget; + + // Clear swipe target state before promoting it to the realized element so + // interactive transitions never receive the same control as both from/to. + _swipeTarget = null; + _swipeTargetIndex = -1; + + if (_realized != null) + RecycleElement(_realized); + + _realized = targetElement; + _realizedIndex = targetIndex; + + carousel.SelectedIndex = targetIndex; + } + else + { + ResetSwipeState(); + } + + _totalDelta = 0; + _swipeTarget = null; + _swipeTargetIndex = -1; + _isRubberBanding = false; + carousel.IsSwiping = false; + } + finally + { + if (ReferenceEquals(_completionCts, completionCts)) + _completionCts = null; + } + } + + /// + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == OffsetAnimationProgressProperty) + { + if (_offsetAnimationCts is { IsCancellationRequested: false }) + { + var animProgress = change.GetNewValue(); + var primaryOffset = _offsetAnimationStart + + ((_offsetAnimationTarget - _offsetAnimationStart) * animProgress); + SetOffset(WithPrimaryOffset(_offset, primaryOffset)); + + if (UsesViewportFractionLayout() && + GetTransition() is IProgressPageTransition progressive) + { + var transitionProgress = GetFractionalTransitionProgress(primaryOffset); + progressive.Update( + transitionProgress, + FindViewportControl(_progressFromIndex), + FindViewportControl(_progressToIndex), + _isForward, + GetViewportItemExtent(Bounds.Size), + BuildFractionalVisibleItems(primaryOffset)); + } + } + } + else if (change.Property == CompletionProgressProperty) + { + var isCompletionAnimating = _completionCts is { IsCancellationRequested: false }; + + if (!_isDragging && _swipeTarget is null && !isCompletionAnimating) + return; + + var progress = change.GetNewValue(); + if (GetTransition() is IProgressPageTransition progressive) + { + var swipeTarget = ReferenceEquals(_realized, _swipeTarget) ? null : _swipeTarget; + var size = _lockedAxis == PageSlide.SlideAxis.Horizontal ? Bounds.Width : Bounds.Height; + progressive.Update( + progress, + _realized, + swipeTarget, + _isForward, + size, + Array.Empty()); + } + } + } + + private IReadOnlyList BuildFractionalVisibleItems(double currentOffset) + { + var items = new PageTransitionItem[_viewportRealized.Count]; + var i = 0; + foreach (var entry in _viewportRealized.OrderBy(x => x.Key)) + { + items[i++] = new PageTransitionItem( + entry.Value.ItemIndex, + entry.Value.Control, + entry.Key - currentOffset); + } + + return items; + } + + private void ResetSwipeState() + { + if (ItemsControl is Carousel carousel) + carousel.IsSwiping = false; + + CancelOffsetAnimation(); + + ResetViewportTransitionState(); + ResetTransitionState(_realized); + + if (_swipeTarget is not null) + RecycleElement(_swipeTarget); + + _isDragging = false; + _totalDelta = 0; + _swipeTarget = null; + _swipeTargetIndex = -1; + _isRubberBanding = false; + ClearFractionalProgressContext(); + + if (UsesViewportFractionLayout() && ItemsControl is Carousel viewportCarousel) + SetOffset(WithPrimaryOffset(_offset, GetNearestLogicalOffset(viewportCarousel.SelectedIndex, GetPrimaryOffset(_offset)))); + } + + private void ResetTransitionState(Control? control) + { + if (control is null) + return; + + if (GetTransition() is IProgressPageTransition progressive) + { + progressive.Reset(control); + } + else + { + ResetVisualState(control); + } + } + + private static void ResetVisualState(Control? control) + { + if (control is null) + return; + control.RenderTransform = null; + control.Opacity = 1; + control.ZIndex = 0; + control.Clip = null; + } } } diff --git a/tests/Avalonia.Base.UnitTests/Input/SwipeGestureRecognizerTests.cs b/tests/Avalonia.Base.UnitTests/Input/SwipeGestureRecognizerTests.cs new file mode 100644 index 0000000000..d0821c91b1 --- /dev/null +++ b/tests/Avalonia.Base.UnitTests/Input/SwipeGestureRecognizerTests.cs @@ -0,0 +1,158 @@ +using System.Collections.Generic; +using System.Threading; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Input.GestureRecognizers; +using Avalonia.UnitTests; +using Xunit; + +namespace Avalonia.Base.UnitTests.Input; + +public class SwipeGestureRecognizerTests : ScopedTestBase +{ + [Fact] + public void Does_Not_Raise_Swipe_When_Both_Axes_Are_Disabled() + { + var (border, root) = CreateTarget(new SwipeGestureRecognizer { Threshold = 1 }); + var touch = new TouchTestHelper(); + var swipeRaised = false; + var endedRaised = false; + + root.AddHandler(InputElement.SwipeGestureEvent, (_, _) => swipeRaised = true); + root.AddHandler(InputElement.SwipeGestureEndedEvent, (_, _) => endedRaised = true); + + touch.Down(border, new Point(50, 50)); + touch.Move(border, new Point(20, 20)); + touch.Up(border, new Point(20, 20)); + + Assert.False(swipeRaised); + Assert.False(endedRaised); + } + + [Fact] + public void Defaults_Disable_Both_Axes() + { + var recognizer = new SwipeGestureRecognizer(); + + Assert.False(recognizer.CanHorizontallySwipe); + Assert.False(recognizer.CanVerticallySwipe); + } + + [Fact] + public void Starts_Only_After_Threshold_Is_Exceeded() + { + var (border, root) = CreateTarget(new SwipeGestureRecognizer + { + CanHorizontallySwipe = true, + Threshold = 50 + }); + var touch = new TouchTestHelper(); + var deltas = new List(); + + root.AddHandler(InputElement.SwipeGestureEvent, (_, e) => deltas.Add(e.Delta)); + + touch.Down(border, new Point(5, 5)); + touch.Move(border, new Point(40, 5)); + + Assert.Empty(deltas); + + touch.Move(border, new Point(80, 5)); + + Assert.Single(deltas); + Assert.NotEqual(Vector.Zero, deltas[0]); + } + + [Fact] + public void Ended_Event_Uses_Same_Id_And_Last_Velocity() + { + var (border, root) = CreateTarget(new SwipeGestureRecognizer + { + CanHorizontallySwipe = true, + Threshold = 1 + }); + var touch = new TouchTestHelper(); + var updateIds = new List(); + var velocities = new List(); + var endedId = 0; + var endedVelocity = Vector.Zero; + + root.AddHandler(InputElement.SwipeGestureEvent, (_, e) => + { + updateIds.Add(e.Id); + velocities.Add(e.Velocity); + }); + root.AddHandler(InputElement.SwipeGestureEndedEvent, (_, e) => + { + endedId = e.Id; + endedVelocity = e.Velocity; + }); + + touch.Down(border, new Point(50, 50)); + touch.Move(border, new Point(40, 50)); + touch.Move(border, new Point(30, 50)); + touch.Up(border, new Point(30, 50)); + + Assert.True(updateIds.Count >= 2); + Assert.All(updateIds, id => Assert.Equal(updateIds[0], id)); + Assert.Equal(updateIds[0], endedId); + Assert.Equal(velocities[^1], endedVelocity); + } + + [Fact] + public void Mouse_Swipe_Requires_IsMouseEnabled() + { + var mouse = new MouseTestHelper(); + var (border, root) = CreateTarget(new SwipeGestureRecognizer + { + CanHorizontallySwipe = true, + Threshold = 1 + }); + var swipeRaised = false; + + root.AddHandler(InputElement.SwipeGestureEvent, (_, _) => swipeRaised = true); + + mouse.Down(border, position: new Point(50, 50)); + mouse.Move(border, new Point(30, 50)); + mouse.Up(border, position: new Point(30, 50)); + + Assert.False(swipeRaised); + } + + [Fact] + public void Mouse_Swipe_Is_Raised_When_Enabled() + { + var mouse = new MouseTestHelper(); + var (border, root) = CreateTarget(new SwipeGestureRecognizer + { + CanHorizontallySwipe = true, + Threshold = 1, + IsMouseEnabled = true + }); + var swipeRaised = false; + + root.AddHandler(InputElement.SwipeGestureEvent, (_, _) => swipeRaised = true); + + mouse.Down(border, position: new Point(50, 50)); + mouse.Move(border, new Point(30, 50)); + mouse.Up(border, position: new Point(30, 50)); + + Assert.True(swipeRaised); + } + + private static (Border Border, TestRoot Root) CreateTarget(SwipeGestureRecognizer recognizer) + { + var border = new Border + { + Width = 100, + Height = 100 + }; + border.GestureRecognizers.Add(recognizer); + + var root = new TestRoot + { + Child = border + }; + + return (border, root); + } +} diff --git a/tests/Avalonia.Controls.UnitTests/CarouselTests.cs b/tests/Avalonia.Controls.UnitTests/CarouselTests.cs index ab93686966..11221eb7d1 100644 --- a/tests/Avalonia.Controls.UnitTests/CarouselTests.cs +++ b/tests/Avalonia.Controls.UnitTests/CarouselTests.cs @@ -2,10 +2,12 @@ using System; using System.Collections.ObjectModel; using System.Linq; using System.Reactive.Subjects; +using Avalonia.Animation; using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; using Avalonia.Data; +using Avalonia.Input; using Avalonia.Layout; using Avalonia.LogicalTree; using Avalonia.UnitTests; @@ -59,6 +61,28 @@ namespace Avalonia.Controls.UnitTests Assert.Equal("Foo", child.Text); } + [Fact] + public void ViewportFraction_Defaults_To_One() + { + using var app = Start(); + var target = new Carousel(); + + Assert.Equal(1d, target.ViewportFraction); + } + + [Fact] + public void ViewportFraction_Coerces_Invalid_Values_To_One() + { + using var app = Start(); + var target = new Carousel(); + + target.ViewportFraction = 0; + Assert.Equal(1d, target.ViewportFraction); + + target.ViewportFraction = double.NaN; + Assert.Equal(1d, target.ViewportFraction); + } + [Fact] public void Selected_Item_Changes_To_First_Item_When_Items_Property_Changes() { @@ -147,8 +171,7 @@ namespace Avalonia.Controls.UnitTests target.ItemsSource = null; Layout(target); - var numChildren = target.GetRealizedContainers().Count(); - + Assert.Empty(target.GetRealizedContainers()); Assert.Equal(-1, target.SelectedIndex); } @@ -326,6 +349,204 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(1, target.SelectedIndex); } + public class WrapSelectionTests : ScopedTestBase + { + [Fact] + public void Next_Loops_When_WrapSelection_Is_True() + { + using var app = Start(); + var items = new[] { "foo", "bar", "baz" }; + var target = new Carousel + { + Template = CarouselTemplate(), + ItemsSource = items, + WrapSelection = true, + SelectedIndex = 2 + }; + + Prepare(target); + + target.Next(); + Layout(target); + + Assert.Equal(0, target.SelectedIndex); + } + + [Fact] + public void Previous_Loops_When_WrapSelection_Is_True() + { + using var app = Start(); + var items = new[] { "foo", "bar", "baz" }; + var target = new Carousel + { + Template = CarouselTemplate(), + ItemsSource = items, + WrapSelection = true, + SelectedIndex = 0 + }; + + Prepare(target); + + target.Previous(); + Layout(target); + + Assert.Equal(2, target.SelectedIndex); + } + + [Fact] + public void Next_Does_Not_Loop_When_WrapSelection_Is_False() + { + using var app = Start(); + var items = new[] { "foo", "bar", "baz" }; + var target = new Carousel + { + Template = CarouselTemplate(), + ItemsSource = items, + WrapSelection = false, + SelectedIndex = 2 + }; + + Prepare(target); + + target.Next(); + Layout(target); + + Assert.Equal(2, target.SelectedIndex); + } + + [Fact] + public void Previous_Does_Not_Loop_When_WrapSelection_Is_False() + { + using var app = Start(); + var items = new[] { "foo", "bar", "baz" }; + var target = new Carousel + { + Template = CarouselTemplate(), + ItemsSource = items, + WrapSelection = false, + SelectedIndex = 0 + }; + + Prepare(target); + + target.Previous(); + Layout(target); + + Assert.Equal(0, target.SelectedIndex); + } + } + + + + [Fact] + public void Right_Arrow_Navigates_To_Next_With_Horizontal_PageSlide() + { + using var app = Start(); + var target = new Carousel + { + Template = CarouselTemplate(), + ItemsSource = new[] { "Foo", "Bar", "Baz" }, + PageTransition = new PageSlide(TimeSpan.FromMilliseconds(100), PageSlide.SlideAxis.Horizontal), + }; + + Prepare(target); + Assert.Equal(0, target.SelectedIndex); + + target.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Right }); + Assert.Equal(1, target.SelectedIndex); + } + + [Fact] + public void Down_Arrow_Navigates_To_Next_With_Vertical_PageSlide() + { + using var app = Start(); + var target = new Carousel + { + Template = CarouselTemplate(), + ItemsSource = new[] { "Foo", "Bar", "Baz" }, + PageTransition = new PageSlide(TimeSpan.FromMilliseconds(100), PageSlide.SlideAxis.Vertical), + }; + + Prepare(target); + Assert.Equal(0, target.SelectedIndex); + + target.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Down }); + Assert.Equal(1, target.SelectedIndex); + } + + [Fact] + public void Home_Navigates_To_First_Item() + { + using var app = Start(); + var target = new Carousel + { + Template = CarouselTemplate(), + ItemsSource = new[] { "Foo", "Bar", "Baz" }, + SelectedIndex = 2, + }; + + Prepare(target); + Layout(target); + Assert.Equal(2, target.SelectedIndex); + + target.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Home }); + Assert.Equal(0, target.SelectedIndex); + } + + [Fact] + public void End_Navigates_To_Last_Item() + { + using var app = Start(); + var target = new Carousel + { + Template = CarouselTemplate(), + ItemsSource = new[] { "Foo", "Bar", "Baz" }, + }; + + Prepare(target); + Assert.Equal(0, target.SelectedIndex); + + target.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.End }); + Assert.Equal(2, target.SelectedIndex); + } + + [Fact] + public void Wrong_Axis_Arrow_Is_Ignored() + { + using var app = Start(); + var target = new Carousel + { + Template = CarouselTemplate(), + ItemsSource = new[] { "Foo", "Bar", "Baz" }, + PageTransition = new PageSlide(TimeSpan.FromMilliseconds(100), PageSlide.SlideAxis.Horizontal), + }; + + Prepare(target); + Assert.Equal(0, target.SelectedIndex); + + target.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Down }); + Assert.Equal(0, target.SelectedIndex); + } + + [Fact] + public void Left_Arrow_Wraps_With_WrapSelection() + { + using var app = Start(); + var target = new Carousel + { + Template = CarouselTemplate(), + ItemsSource = new[] { "Foo", "Bar", "Baz" }, + PageTransition = new PageSlide(TimeSpan.FromMilliseconds(100), PageSlide.SlideAxis.Horizontal), + WrapSelection = true, + }; + + Prepare(target); + Assert.Equal(0, target.SelectedIndex); + + target.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Left }); + Assert.Equal(2, target.SelectedIndex); + } + private static IDisposable Start() => UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); private static void Prepare(Carousel target) diff --git a/tests/Avalonia.Controls.UnitTests/DrawerPageTests.cs b/tests/Avalonia.Controls.UnitTests/DrawerPageTests.cs index ca3b1267bd..d8f50b81de 100644 --- a/tests/Avalonia.Controls.UnitTests/DrawerPageTests.cs +++ b/tests/Avalonia.Controls.UnitTests/DrawerPageTests.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Avalonia.Input; +using Avalonia.Input.GestureRecognizers; using Avalonia.Interactivity; using Avalonia.Layout; using Avalonia.LogicalTree; @@ -1049,6 +1051,82 @@ public class DrawerPageTests } } + public class SwipeGestureTests : ScopedTestBase + { + [Fact] + public void HandledPointerPressedAtEdge_AllowsSwipeOpen() + { + var dp = new DrawerPage + { + DrawerPlacement = DrawerPlacement.Left, + DisplayMode = SplitViewDisplayMode.Overlay, + Width = 400, + Height = 300 + }; + dp.GestureRecognizers.OfType().First().IsMouseEnabled = true; + + var root = new TestRoot + { + ClientSize = new Size(400, 300), + Child = dp + }; + root.ExecuteInitialLayoutPass(); + + RaiseHandledPointerPressed(dp, new Point(5, 5)); + + var swipe = new SwipeGestureEventArgs(1, new Vector(-20, 0), default); + dp.RaiseEvent(swipe); + + Assert.True(swipe.Handled); + Assert.True(dp.IsOpen); + } + + [Fact] + public void MouseEdgeDrag_AllowsSwipeOpen() + { + var dp = new DrawerPage + { + DrawerPlacement = DrawerPlacement.Left, + DisplayMode = SplitViewDisplayMode.Overlay, + Width = 400, + Height = 300 + }; + dp.GestureRecognizers.OfType().First().IsMouseEnabled = true; + + var root = new TestRoot + { + ClientSize = new Size(400, 300), + Child = dp + }; + root.ExecuteInitialLayoutPass(); + + var mouse = new MouseTestHelper(); + mouse.Down(dp, position: new Point(5, 5)); + mouse.Move(dp, new Point(40, 5)); + mouse.Up(dp, position: new Point(40, 5)); + + Assert.True(dp.IsOpen); + } + + private static void RaiseHandledPointerPressed(Interactive target, Point position) + { + var pointer = new Pointer(Pointer.GetNextFreeId(), PointerType.Touch, true); + var args = new PointerPressedEventArgs( + target, + pointer, + (Visual)target, + position, + timestamp: 1, + new PointerPointProperties(RawInputModifiers.LeftMouseButton, PointerUpdateKind.LeftButtonPressed), + KeyModifiers.None) + { + Handled = true + }; + + target.RaiseEvent(args); + } + } + public class DetachmentTests : ScopedTestBase { [Fact] diff --git a/tests/Avalonia.Controls.UnitTests/InputElementGestureTests.cs b/tests/Avalonia.Controls.UnitTests/InputElementGestureTests.cs new file mode 100644 index 0000000000..20f5f2ec2e --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/InputElementGestureTests.cs @@ -0,0 +1,23 @@ +using Avalonia.Input; +using Avalonia.UnitTests; +using Xunit; + +namespace Avalonia.Controls.UnitTests; + +public class InputElementGestureTests : ScopedTestBase +{ + [Fact] + public void SwipeGestureEnded_PublicEvent_CanBeObserved() + { + var target = new Border(); + SwipeGestureEndedEventArgs? received = null; + + target.SwipeGestureEnded += (_, e) => received = e; + + var args = new SwipeGestureEndedEventArgs(42, new Vector(12, 34)); + target.RaiseEvent(args); + + Assert.Same(args, received); + Assert.Equal(InputElement.SwipeGestureEndedEvent, args.RoutedEvent); + } +} diff --git a/tests/Avalonia.Controls.UnitTests/NavigationPageTests.cs b/tests/Avalonia.Controls.UnitTests/NavigationPageTests.cs index 9602256fe8..2d15825f72 100644 --- a/tests/Avalonia.Controls.UnitTests/NavigationPageTests.cs +++ b/tests/Avalonia.Controls.UnitTests/NavigationPageTests.cs @@ -1,13 +1,18 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Avalonia.Animation; using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; +using Avalonia.Input; +using Avalonia.Input.GestureRecognizers; using Avalonia.Interactivity; using Avalonia.LogicalTree; +using Avalonia.Threading; +using Avalonia.VisualTree; using Avalonia.UnitTests; using Xunit; @@ -1578,6 +1583,116 @@ public class NavigationPageTests } } + public class SwipeGestureTests : ScopedTestBase + { + [Fact] + public async Task HandledPointerPressedAtEdge_AllowsSwipePop() + { + var nav = new NavigationPage(); + var rootPage = new ContentPage { Header = "Root" }; + var topPage = new ContentPage { Header = "Top" }; + + await nav.PushAsync(rootPage); + await nav.PushAsync(topPage); + + var root = new TestRoot { Child = nav }; + root.ExecuteInitialLayoutPass(); + + RaiseHandledPointerPressed(nav, new Point(5, 5)); + + var swipe = new SwipeGestureEventArgs(1, new Vector(-20, 0), default); + nav.RaiseEvent(swipe); + Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken); + + Assert.True(swipe.Handled); + Assert.Equal(1, nav.StackDepth); + Assert.Same(rootPage, nav.CurrentPage); + } + + [Fact] + public async Task MouseEdgeDrag_AllowsSwipePop() + { + var nav = new NavigationPage + { + Width = 400, + Height = 300 + }; + nav.GestureRecognizers.OfType().First().IsMouseEnabled = true; + var rootPage = new ContentPage { Header = "Root" }; + var topPage = new ContentPage { Header = "Top" }; + + await nav.PushAsync(rootPage); + await nav.PushAsync(topPage); + + var root = new TestRoot + { + ClientSize = new Size(400, 300), + Child = nav + }; + root.ExecuteInitialLayoutPass(); + + var mouse = new MouseTestHelper(); + mouse.Down(nav, position: new Point(5, 5)); + mouse.Move(nav, new Point(40, 5)); + mouse.Up(nav, position: new Point(40, 5)); + Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken); + + Assert.Equal(1, nav.StackDepth); + Assert.Same(rootPage, nav.CurrentPage); + } + + [Fact] + public async Task SameGestureId_OnlyPops_One_Page() + { + var nav = new NavigationPage + { + Width = 400, + Height = 300 + }; + var page1 = new ContentPage { Header = "1" }; + var page2 = new ContentPage { Header = "2" }; + var page3 = new ContentPage { Header = "3" }; + + await nav.PushAsync(page1); + await nav.PushAsync(page2); + await nav.PushAsync(page3); + + var root = new TestRoot + { + ClientSize = new Size(400, 300), + Child = nav + }; + root.ExecuteInitialLayoutPass(); + + RaiseHandledPointerPressed(nav, new Point(5, 5)); + + nav.RaiseEvent(new SwipeGestureEventArgs(42, new Vector(-20, 0), default)); + nav.RaiseEvent(new SwipeGestureEventArgs(42, new Vector(-30, 0), default)); + Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken); + + Assert.Equal(2, nav.StackDepth); + Assert.Same(page2, nav.CurrentPage); + } + + private static void RaiseHandledPointerPressed(Interactive target, Point position) + { + var pointer = new Pointer(Pointer.GetNextFreeId(), PointerType.Touch, true); + var args = new PointerPressedEventArgs( + target, + pointer, + (Visual)target, + position, + timestamp: 1, + new PointerPointProperties(RawInputModifiers.LeftMouseButton, PointerUpdateKind.LeftButtonPressed), + KeyModifiers.None) + { + Handled = true + }; + + target.RaiseEvent(args); + } + } + public class LifecycleAfterTransitionTests : ScopedTestBase { [Fact] diff --git a/tests/Avalonia.Controls.UnitTests/TabbedPageTests.cs b/tests/Avalonia.Controls.UnitTests/TabbedPageTests.cs index c6c567e315..9034161e39 100644 --- a/tests/Avalonia.Controls.UnitTests/TabbedPageTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TabbedPageTests.cs @@ -1,12 +1,16 @@ using System; using System.Collections.Generic; using System.Collections.Specialized; +using System.Linq; using Avalonia.Animation; using Avalonia.Collections; using Avalonia.Controls.Shapes; using Avalonia.Controls.Templates; +using Avalonia.Input; +using Avalonia.Input.GestureRecognizers; using Avalonia.LogicalTree; using Avalonia.Media; +using Avalonia.Threading; using Avalonia.UnitTests; using Xunit; @@ -809,6 +813,91 @@ public class TabbedPageTests } } + public class SwipeGestureTests : ScopedTestBase + { + [Fact] + public void SameGestureId_OnlyAdvancesOneTab() + { + var tp = CreateSwipeReadyTabbedPage(); + + var firstSwipe = new SwipeGestureEventArgs(7, new Vector(20, 0), default); + var repeatedSwipe = new SwipeGestureEventArgs(7, new Vector(20, 0), default); + + tp.RaiseEvent(firstSwipe); + tp.RaiseEvent(repeatedSwipe); + Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken); + + Assert.True(firstSwipe.Handled); + Assert.False(repeatedSwipe.Handled); + Assert.Equal(1, tp.SelectedIndex); + } + + [Fact] + public void NewGestureId_CanAdvanceAgain() + { + var tp = CreateSwipeReadyTabbedPage(); + + tp.RaiseEvent(new SwipeGestureEventArgs(7, new Vector(20, 0), default)); + tp.RaiseEvent(new SwipeGestureEventArgs(8, new Vector(20, 0), default)); + Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken); + + Assert.Equal(2, tp.SelectedIndex); + } + + [Fact] + public void MouseSwipe_Advances_Tab() + { + var tp = CreateSwipeReadyTabbedPage(); + var mouse = new MouseTestHelper(); + + mouse.Down(tp, position: new Point(200, 100)); + mouse.Move(tp, new Point(160, 100)); + mouse.Up(tp, position: new Point(160, 100)); + Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken); + + Assert.Equal(1, tp.SelectedIndex); + } + + private static TabbedPage CreateSwipeReadyTabbedPage() + { + var tp = new TabbedPage + { + IsGestureEnabled = true, + Width = 400, + Height = 300, + TabPlacement = TabPlacement.Top, + SelectedIndex = 0, + Pages = new AvaloniaList + { + new ContentPage { Header = "A" }, + new ContentPage { Header = "B" }, + new ContentPage { Header = "C" } + }, + Template = new FuncControlTemplate((parent, scope) => + { + var tabControl = new TabControl + { + Name = "PART_TabControl", + ItemsSource = parent.Pages + }; + scope.Register("PART_TabControl", tabControl); + return tabControl; + }) + }; + tp.GestureRecognizers.OfType().First().IsMouseEnabled = true; + + var root = new TestRoot + { + ClientSize = new Size(400, 300), + Child = tp + }; + tp.ApplyTemplate(); + Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken); + + return tp; + } + } + private sealed class TestableTabbedPage : TabbedPage { public void CallCommitSelection(int index, Page? page) => CommitSelection(index, page); diff --git a/tests/Avalonia.Controls.UnitTests/VirtualizingCarouselPanelTests.cs b/tests/Avalonia.Controls.UnitTests/VirtualizingCarouselPanelTests.cs index cc506dd7a9..11687fa81d 100644 --- a/tests/Avalonia.Controls.UnitTests/VirtualizingCarouselPanelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/VirtualizingCarouselPanelTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using System.Threading; @@ -9,7 +10,9 @@ using Avalonia.Collections; using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; +using Avalonia.Input; using Avalonia.Layout; +using Avalonia.Media; using Avalonia.Threading; using Avalonia.UnitTests; using Avalonia.VisualTree; @@ -135,6 +138,86 @@ namespace Avalonia.Controls.UnitTests }); } + [Fact] + public void ViewportFraction_Centers_Selected_Item_And_Peeks_Neighbors() + { + using var app = Start(); + var items = new[] { "foo", "bar", "baz" }; + var (target, _) = CreateTarget(items, viewportFraction: 0.8, clientSize: new Size(400, 300)); + + var realized = target.GetRealizedContainers()! + .OfType() + .ToDictionary(x => (string)x.Content!); + + Assert.Equal(2, realized.Count); + Assert.Equal(40d, realized["foo"].Bounds.X, 6); + Assert.Equal(320d, realized["foo"].Bounds.Width, 6); + Assert.Equal(360d, realized["bar"].Bounds.X, 6); + } + + [Fact] + public void ViewportFraction_OneThird_Shows_Three_Full_Items() + { + using var app = Start(); + var items = new[] { "foo", "bar", "baz", "qux" }; + var (target, carousel) = CreateTarget(items, viewportFraction: 1d / 3d, clientSize: new Size(300, 120)); + + carousel.SelectedIndex = 1; + Layout(target); + + var realized = target.GetRealizedContainers()! + .OfType() + .ToDictionary(x => (string)x.Content!); + + Assert.Equal(3, realized.Count); + Assert.Equal(0d, realized["foo"].Bounds.X, 6); + Assert.Equal(100d, realized["bar"].Bounds.X, 6); + Assert.Equal(200d, realized["baz"].Bounds.X, 6); + Assert.Equal(100d, realized["bar"].Bounds.Width, 6); + } + + [Fact] + public void Changing_SelectedIndex_Repositions_Fractional_Viewport() + { + using var app = Start(); + var items = new[] { "foo", "bar", "baz" }; + var (target, carousel) = CreateTarget(items, viewportFraction: 0.8, clientSize: new Size(400, 300)); + + carousel.SelectedIndex = 1; + Layout(target); + + var realized = target.GetRealizedContainers()! + .OfType() + .ToDictionary(x => (string)x.Content!); + + Assert.Equal(40d, realized["bar"].Bounds.X, 6); + Assert.Equal(-280d, realized["foo"].Bounds.X, 6); + } + + [Fact] + public void Changing_ViewportFraction_Does_Not_Change_Selected_Item() + { + using var app = Start(); + var items = new[] { "foo", "bar", "baz" }; + var (target, carousel) = CreateTarget(items, viewportFraction: 0.72, clientSize: new Size(400, 300)); + + carousel.WrapSelection = true; + carousel.SelectedIndex = 2; + Layout(target); + + carousel.ViewportFraction = 1d; + Layout(target); + + var visible = target.Children + .OfType() + .Where(x => x.IsVisible) + .ToList(); + + Assert.Single(visible); + Assert.Equal("baz", visible[0].Content); + Assert.Equal(2, carousel.SelectedIndex); + } + public class Transitions : ScopedTestBase { [Fact] @@ -292,22 +375,89 @@ namespace Avalonia.Controls.UnitTests Assert.True(cancelationToken!.Value.IsCancellationRequested); } + + [Fact] + public void Completed_Transition_Is_Flushed_Before_Starting_Next_Transition() + { + using var app = Start(); + using var sync = UnitTestSynchronizationContext.Begin(); + var items = new Control[] { new Button(), new Canvas(), new Label() }; + var transition = new Mock(); + + transition.Setup(x => x.Start( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + var (target, carousel) = CreateTarget(items, transition.Object); + + carousel.SelectedIndex = 1; + Layout(target); + + carousel.SelectedIndex = 2; + Layout(target); + + transition.Verify(x => x.Start( + items[0], + items[1], + true, + It.IsAny()), + Times.Once); + transition.Verify(x => x.Start( + items[1], + items[2], + true, + It.IsAny()), + Times.Once); + + sync.ExecutePostedCallbacks(); + } + + [Fact] + public void Interrupted_Transition_Resets_Current_Page_Before_Starting_Next_Transition() + { + using var app = Start(); + var items = new Control[] { new Button(), new Canvas(), new Label() }; + var transition = new DirtyStateTransition(); + var (target, carousel) = CreateTarget(items, transition); + + carousel.SelectedIndex = 1; + Layout(target); + + carousel.SelectedIndex = 2; + Layout(target); + + Assert.Equal(2, transition.Starts.Count); + Assert.Equal(1d, transition.Starts[1].FromOpacity); + Assert.Null(transition.Starts[1].FromTransform); + } } private static IDisposable Start() => UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); private static (VirtualizingCarouselPanel, Carousel) CreateTarget( IEnumerable items, - IPageTransition? transition = null) + IPageTransition? transition = null, + double viewportFraction = 1d, + Size? clientSize = null) { + var size = clientSize ?? new Size(400, 300); var carousel = new Carousel { ItemsSource = items, Template = CarouselTemplate(), PageTransition = transition, + ViewportFraction = viewportFraction, + Width = size.Width, + Height = size.Height, }; - var root = new TestRoot(carousel); + var root = new TestRoot(carousel) + { + ClientSize = size, + }; root.LayoutManager.ExecuteInitialLayoutPass(); return ((VirtualizingCarouselPanel)carousel.Presenter!.Panel!, carousel); } @@ -345,5 +495,619 @@ namespace Avalonia.Controls.UnitTests } private static void Layout(Control c) => c.GetLayoutManager()?.ExecuteLayoutPass(); + + private sealed class DirtyStateTransition : IPageTransition + { + public List<(double FromOpacity, ITransform? FromTransform)> Starts { get; } = new(); + + public Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken) + { + Starts.Add((from?.Opacity ?? 1d, from?.RenderTransform)); + + if (to is not null) + { + to.Opacity = 0.25; + to.RenderTransform = new TranslateTransform { X = 50 }; + } + + return Task.Delay(Timeout.Infinite, cancellationToken); + } + } + + public class WrapSelectionTests : ScopedTestBase + { + [Fact] + public void Next_Wraps_To_First_Item_When_WrapSelection_Enabled() + { + using var app = Start(); + var items = new[] { "foo", "bar", "baz" }; + var (target, carousel) = CreateTarget(items); + + carousel.WrapSelection = true; + carousel.SelectedIndex = 2; // Last item + Layout(target); + + carousel.Next(); + Layout(target); + + Assert.Equal(0, carousel.SelectedIndex); + } + + [Fact] + public void Next_Does_Not_Wrap_When_WrapSelection_Disabled() + { + using var app = Start(); + var items = new[] { "foo", "bar", "baz" }; + var (target, carousel) = CreateTarget(items); + + carousel.WrapSelection = false; + carousel.SelectedIndex = 2; // Last item + Layout(target); + + carousel.Next(); + Layout(target); + + Assert.Equal(2, carousel.SelectedIndex); // Should stay at last item + } + + [Fact] + public void Previous_Wraps_To_Last_Item_When_WrapSelection_Enabled() + { + using var app = Start(); + var items = new[] { "foo", "bar", "baz" }; + var (target, carousel) = CreateTarget(items); + + carousel.WrapSelection = true; + carousel.SelectedIndex = 0; // First item + Layout(target); + + carousel.Previous(); + Layout(target); + + Assert.Equal(2, carousel.SelectedIndex); // Should wrap to last item + } + + [Fact] + public void Previous_Does_Not_Wrap_When_WrapSelection_Disabled() + { + using var app = Start(); + var items = new[] { "foo", "bar", "baz" }; + var (target, carousel) = CreateTarget(items); + + carousel.WrapSelection = false; + carousel.SelectedIndex = 0; // First item + Layout(target); + + carousel.Previous(); + Layout(target); + + Assert.Equal(0, carousel.SelectedIndex); // Should stay at first item + } + + [Fact] + public void WrapSelection_Works_With_Two_Items() + { + using var app = Start(); + var items = new[] { "foo", "bar" }; + var (target, carousel) = CreateTarget(items); + + carousel.WrapSelection = true; + carousel.SelectedIndex = 1; + Layout(target); + + carousel.Next(); + Layout(target); + + Assert.Equal(0, carousel.SelectedIndex); + + carousel.Previous(); + Layout(target); + + Assert.Equal(1, carousel.SelectedIndex); + } + + [Fact] + public void WrapSelection_Does_Not_Apply_To_Single_Item() + { + using var app = Start(); + var items = new[] { "foo" }; + var (target, carousel) = CreateTarget(items); + + carousel.WrapSelection = true; + carousel.SelectedIndex = 0; + Layout(target); + + carousel.Next(); + Layout(target); + + Assert.Equal(0, carousel.SelectedIndex); + + carousel.Previous(); + Layout(target); + + Assert.Equal(0, carousel.SelectedIndex); + } + } + + public class Gestures : ScopedTestBase + { + [Fact] + public void Swiping_Forward_Realizes_Next_Item() + { + using var app = Start(); + var items = new[] { "foo", "bar" }; + var (panel, carousel) = CreateTarget(items); + carousel.IsSwipeEnabled = true; + + var e = new SwipeGestureEventArgs(1, new Vector(10, 0), default); + panel.RaiseEvent(e); + + Assert.True(carousel.IsSwiping); + Assert.Equal(2, panel.Children.Count); + var target = panel.Children[1] as Control; + Assert.NotNull(target); + Assert.True(target.IsVisible); + Assert.Equal("bar", ((target as ContentPresenter)?.Content)); + } + + [Fact] + public void Swiping_Backward_At_Start_RubberBands_When_WrapSelection_False() + { + using var app = Start(); + var items = new[] { "foo", "bar" }; + var (panel, carousel) = CreateTarget(items); + carousel.IsSwipeEnabled = true; + carousel.WrapSelection = false; + + var e = new SwipeGestureEventArgs(1, new Vector(-10, 0), default); + panel.RaiseEvent(e); + + Assert.True(carousel.IsSwiping); + Assert.Single(panel.Children); + } + + [Fact] + public void Swiping_Backward_At_Start_Wraps_When_WrapSelection_True() + { + using var app = Start(); + var items = new[] { "foo", "bar", "baz" }; + var (panel, carousel) = CreateTarget(items); + carousel.IsSwipeEnabled = true; + carousel.WrapSelection = true; + + var e = new SwipeGestureEventArgs(1, new Vector(-10, 0), default); + panel.RaiseEvent(e); + + Assert.True(carousel.IsSwiping); + Assert.Equal(2, panel.Children.Count); + var target = panel.Children[1] as Control; + Assert.Equal("baz", ((target as ContentPresenter)?.Content)); + } + + [Fact] + public void ViewportFraction_Swiping_Backward_At_Start_Wraps_When_WrapSelection_True() + { + var clock = new MockGlobalClock(); + + using var app = UnitTestApplication.Start( + TestServices.MockPlatformRenderInterface.With(globalClock: clock)); + using var sync = UnitTestSynchronizationContext.Begin(); + + var items = new[] { "foo", "bar", "baz" }; + var (panel, carousel) = CreateTarget(items, viewportFraction: 0.8); + carousel.IsSwipeEnabled = true; + carousel.WrapSelection = true; + Layout(panel); + + panel.RaiseEvent(new SwipeGestureEventArgs(1, new Vector(-120, 0), default)); + + Assert.True(carousel.IsSwiping); + Assert.Contains(panel.Children.OfType(), x => Equals(x.Content, "baz")); + + panel.RaiseEvent(new SwipeGestureEndedEventArgs(1, default)); + + clock.Pulse(TimeSpan.Zero); + clock.Pulse(TimeSpan.FromSeconds(1)); + sync.ExecutePostedCallbacks(); + Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken); + + Assert.Equal(2, carousel.SelectedIndex); + } + + [Fact] + public void Swiping_Forward_At_End_RubberBands_When_WrapSelection_False() + { + using var app = Start(); + var items = new[] { "foo", "bar" }; + var (panel, carousel) = CreateTarget(items); + carousel.IsSwipeEnabled = true; + carousel.WrapSelection = false; + carousel.SelectedIndex = 1; + + Layout(panel); + Layout(panel); + + Assert.Equal(2, ((IReadOnlyList?)carousel.ItemsSource)?.Count); + Assert.Equal(1, carousel.SelectedIndex); + Assert.False(carousel.WrapSelection, "WrapSelection should be false"); + + var container = Assert.IsType(panel.Children[0]); + Assert.Equal("bar", container.Content); + + var e = new SwipeGestureEventArgs(1, new Vector(10, 0), default); + panel.RaiseEvent(e); + + Assert.True(carousel.IsSwiping); + Assert.Single(panel.Children); + } + + [Fact] + public void Swiping_Locks_To_Dominant_Axis() + { + using var app = Start(); + var items = new[] { "foo", "bar" }; + var (panel, carousel) = CreateTarget(items, new CrossFade(TimeSpan.FromSeconds(1))); + carousel.IsSwipeEnabled = true; + + var e = new SwipeGestureEventArgs(1, new Vector(10, 2), default); + panel.RaiseEvent(e); + + Assert.True(carousel.IsSwiping); + } + + [Fact] + public void Swipe_Completion_Does_Not_Update_With_Same_From_And_To() + { + var clock = new MockGlobalClock(); + + using var app = UnitTestApplication.Start( + TestServices.MockPlatformRenderInterface.With(globalClock: clock)); + using var sync = UnitTestSynchronizationContext.Begin(); + + var items = new[] { "foo", "bar" }; + var transition = new TrackingInteractiveTransition(); + var (panel, carousel) = CreateTarget(items, transition); + carousel.IsSwipeEnabled = true; + + panel.RaiseEvent(new SwipeGestureEventArgs(1, new Vector(1000, 0), default)); + panel.RaiseEvent(new SwipeGestureEndedEventArgs(1, new Vector(1000, 0))); + + clock.Pulse(TimeSpan.Zero); + clock.Pulse(TimeSpan.FromSeconds(1)); + sync.ExecutePostedCallbacks(); + Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken); + + Assert.True(transition.UpdateCallCount > 0); + Assert.False(transition.SawAliasedUpdate); + Assert.Equal(1d, transition.LastProgress); + Assert.Equal(1, carousel.SelectedIndex); + } + + [Fact] + public void Swipe_Completion_Keeps_Target_Final_Interactive_Visual_State() + { + var clock = new MockGlobalClock(); + + using var app = UnitTestApplication.Start( + TestServices.MockPlatformRenderInterface.With(globalClock: clock)); + using var sync = UnitTestSynchronizationContext.Begin(); + + var items = new[] { "foo", "bar" }; + var transition = new TransformTrackingInteractiveTransition(); + var (panel, carousel) = CreateTarget(items, transition); + carousel.IsSwipeEnabled = true; + + panel.RaiseEvent(new SwipeGestureEventArgs(1, new Vector(1000, 0), default)); + panel.RaiseEvent(new SwipeGestureEndedEventArgs(1, new Vector(1000, 0))); + + clock.Pulse(TimeSpan.Zero); + clock.Pulse(TimeSpan.FromSeconds(1)); + sync.ExecutePostedCallbacks(); + Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken); + + Assert.Equal(1, carousel.SelectedIndex); + var realized = Assert.Single(panel.Children.OfType(), x => Equals(x.Content, "bar")); + Assert.NotNull(transition.LastTargetTransform); + Assert.Same(transition.LastTargetTransform, realized.RenderTransform); + } + + [Fact] + public void Swipe_Completion_Hides_Outgoing_Page_Before_Resetting_Visual_State() + { + var clock = new MockGlobalClock(); + + using var app = UnitTestApplication.Start( + TestServices.MockPlatformRenderInterface.With(globalClock: clock)); + using var sync = UnitTestSynchronizationContext.Begin(); + + var items = new[] { "foo", "bar" }; + var transition = new OutgoingTransformTrackingInteractiveTransition(); + var (panel, carousel) = CreateTarget(items, transition); + carousel.IsSwipeEnabled = true; + + var outgoing = Assert.Single(panel.Children.OfType(), x => Equals(x.Content, "foo")); + bool? hiddenWhenReset = null; + outgoing.PropertyChanged += (_, args) => + { + if (args.Property == Visual.RenderTransformProperty && + args.GetNewValue() is null) + { + hiddenWhenReset = !outgoing.IsVisible; + } + }; + + panel.RaiseEvent(new SwipeGestureEventArgs(1, new Vector(1000, 0), default)); + panel.RaiseEvent(new SwipeGestureEndedEventArgs(1, new Vector(1000, 0))); + + clock.Pulse(TimeSpan.Zero); + clock.Pulse(TimeSpan.FromSeconds(1)); + sync.ExecutePostedCallbacks(); + Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken); + + Assert.True(hiddenWhenReset); + } + + [Fact] + public void RubberBand_Swipe_Release_Animates_Back_Through_Intermediate_Progress() + { + var clock = new MockGlobalClock(); + + using var app = UnitTestApplication.Start( + TestServices.MockPlatformRenderInterface.With(globalClock: clock)); + using var sync = UnitTestSynchronizationContext.Begin(); + + var items = new[] { "foo", "bar" }; + var transition = new ProgressTrackingInteractiveTransition(); + var (panel, carousel) = CreateTarget(items, transition); + carousel.IsSwipeEnabled = true; + carousel.WrapSelection = false; + + panel.RaiseEvent(new SwipeGestureEventArgs(1, new Vector(-100, 0), default)); + + var releaseStartProgress = transition.Progresses[^1]; + var updatesBeforeRelease = transition.Progresses.Count; + + panel.RaiseEvent(new SwipeGestureEndedEventArgs(1, default)); + + clock.Pulse(TimeSpan.Zero); + clock.Pulse(TimeSpan.FromSeconds(0.1)); + sync.ExecutePostedCallbacks(); + Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken); + + var postReleaseProgresses = transition.Progresses.Skip(updatesBeforeRelease).ToArray(); + + Assert.Contains(postReleaseProgresses, p => p > 0 && p < releaseStartProgress); + + clock.Pulse(TimeSpan.FromSeconds(1)); + sync.ExecutePostedCallbacks(); + Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken); + + Assert.Equal(0d, transition.Progresses[^1]); + Assert.Equal(0, carousel.SelectedIndex); + } + + [Fact] + public void ViewportFraction_SelectedIndex_Change_Drives_Progress_Updates() + { + var clock = new MockGlobalClock(); + + using var app = UnitTestApplication.Start( + TestServices.MockPlatformRenderInterface.With(globalClock: clock)); + using var sync = UnitTestSynchronizationContext.Begin(); + + var items = new[] { "foo", "bar", "baz" }; + var transition = new ProgressTrackingInteractiveTransition(); + var (panel, carousel) = CreateTarget(items, transition, viewportFraction: 0.8); + + carousel.SelectedIndex = 1; + + clock.Pulse(TimeSpan.Zero); + clock.Pulse(TimeSpan.FromSeconds(0.1)); + clock.Pulse(TimeSpan.FromSeconds(1)); + sync.ExecutePostedCallbacks(); + Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken); + + Assert.NotEmpty(transition.Progresses); + Assert.Contains(transition.Progresses, p => p > 0 && p < 1); + Assert.Equal(1d, transition.Progresses[^1]); + Assert.Equal(1, carousel.SelectedIndex); + } + + private sealed class TrackingInteractiveTransition : IProgressPageTransition + { + public int UpdateCallCount { get; private set; } + public bool SawAliasedUpdate { get; private set; } + public double LastProgress { get; private set; } + + public Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken) + => Task.CompletedTask; + + public void Update( + double progress, + Visual? from, + Visual? to, + bool forward, + double pageLength, + IReadOnlyList visibleItems) + { + UpdateCallCount++; + LastProgress = progress; + + if (from is not null && ReferenceEquals(from, to)) + SawAliasedUpdate = true; + } + + public void Reset(Visual visual) + { + visual.RenderTransform = null; + visual.Opacity = 1; + visual.ZIndex = 0; + visual.Clip = null; + } + } + + private sealed class ProgressTrackingInteractiveTransition : IProgressPageTransition + { + public List Progresses { get; } = new(); + + public Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken) + => Task.CompletedTask; + + public void Update( + double progress, + Visual? from, + Visual? to, + bool forward, + double pageLength, + IReadOnlyList visibleItems) + { + Progresses.Add(progress); + } + + public void Reset(Visual visual) + { + visual.RenderTransform = null; + visual.Opacity = 1; + visual.ZIndex = 0; + visual.Clip = null; + } + } + + private sealed class TransformTrackingInteractiveTransition : IProgressPageTransition + { + public TransformGroup? LastTargetTransform { get; private set; } + + public Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken) + => Task.CompletedTask; + + public void Update( + double progress, + Visual? from, + Visual? to, + bool forward, + double pageLength, + IReadOnlyList visibleItems) + { + if (to is not Control target) + return; + + if (target.RenderTransform is not TransformGroup group) + { + group = new TransformGroup + { + Children = + { + new ScaleTransform(), + new TranslateTransform() + } + }; + target.RenderTransform = group; + } + + var scale = Assert.IsType(group.Children[0]); + var translate = Assert.IsType(group.Children[1]); + scale.ScaleX = scale.ScaleY = 0.9 + (0.1 * progress); + translate.X = 100 * (1 - progress); + LastTargetTransform = group; + } + + public void Reset(Visual visual) + { + visual.RenderTransform = null; + } + } + + private sealed class OutgoingTransformTrackingInteractiveTransition : IProgressPageTransition + { + public Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken) + => Task.CompletedTask; + + public void Update( + double progress, + Visual? from, + Visual? to, + bool forward, + double pageLength, + IReadOnlyList visibleItems) + { + if (from is Control source) + source.RenderTransform = new TranslateTransform(100 * progress, 0); + + if (to is Control target) + target.RenderTransform = new TranslateTransform(100 * (1 - progress), 0); + } + + public void Reset(Visual visual) + { + visual.RenderTransform = null; + } + } + + [Fact] + public void Vertical_Swipe_Forward_Realizes_Next_Item() + { + using var app = Start(); + var items = new[] { "foo", "bar" }; + var transition = new PageSlide(TimeSpan.FromSeconds(1), PageSlide.SlideAxis.Vertical); + var (panel, carousel) = CreateTarget(items, transition); + carousel.IsSwipeEnabled = true; + + var e = new SwipeGestureEventArgs(1, new Vector(0, 10), default); + panel.RaiseEvent(e); + + Assert.True(carousel.IsSwiping); + Assert.Equal(2, panel.Children.Count); + var target = panel.Children[1] as ContentPresenter; + Assert.NotNull(target); + Assert.Equal("bar", target.Content); + } + + [Fact] + public void New_Swipe_Interrupts_Active_Completion_Animation() + { + var clock = new MockGlobalClock(); + + using var app = UnitTestApplication.Start( + TestServices.MockPlatformRenderInterface.With(globalClock: clock)); + using var sync = UnitTestSynchronizationContext.Begin(); + + var items = new[] { "foo", "bar", "baz" }; + var transition = new TrackingInteractiveTransition(); + var (panel, carousel) = CreateTarget(items, transition); + carousel.IsSwipeEnabled = true; + + panel.RaiseEvent(new SwipeGestureEventArgs(1, new Vector(1000, 0), default)); + panel.RaiseEvent(new SwipeGestureEndedEventArgs(1, new Vector(1000, 0))); + + clock.Pulse(TimeSpan.Zero); + clock.Pulse(TimeSpan.FromMilliseconds(50)); + sync.ExecutePostedCallbacks(); + + Assert.Equal(0, carousel.SelectedIndex); + + panel.RaiseEvent(new SwipeGestureEventArgs(2, new Vector(10, 0), default)); + + Assert.True(carousel.IsSwiping); + Assert.Equal(1, carousel.SelectedIndex); + } + + [Fact] + public void Swipe_With_NonInteractive_Transition_Does_Not_Crash() + { + using var app = Start(); + var items = new[] { "foo", "bar" }; + var transition = new Mock(); + transition.Setup(x => x.Start(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + var (panel, carousel) = CreateTarget(items, transition.Object); + carousel.IsSwipeEnabled = true; + + var e = new SwipeGestureEventArgs(1, new Vector(10, 0), default); + panel.RaiseEvent(e); + + Assert.True(carousel.IsSwiping); + Assert.Equal(2, panel.Children.Count); + } + } } } diff --git a/tests/Avalonia.RenderTests/Controls/CarouselTests.cs b/tests/Avalonia.RenderTests/Controls/CarouselTests.cs new file mode 100644 index 0000000000..6e5c42d093 --- /dev/null +++ b/tests/Avalonia.RenderTests/Controls/CarouselTests.cs @@ -0,0 +1,127 @@ +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using Avalonia.Styling; +using Avalonia.Themes.Simple; +using Avalonia.UnitTests; +using Xunit; + +#if AVALONIA_SKIA +namespace Avalonia.Skia.RenderTests +#else +namespace Avalonia.Direct2D1.RenderTests.Controls +#endif +{ + public class CarouselRenderTests : TestBase + { + public CarouselRenderTests() + : base(@"Controls\Carousel") + { + } + + private static Style FontStyle => new Style(x => x.OfType()) + { + Setters = { new Setter(TextBlock.FontFamilyProperty, TestFontFamily) } + }; + + [Fact] + public async Task Carousel_ViewportFraction_MiddleItemSelected_ShowsSidePeeks() + { + var carousel = new Carousel + { + Background = Brushes.Transparent, + ViewportFraction = 0.8, + SelectedIndex = 1, + HorizontalAlignment = HorizontalAlignment.Stretch, + VerticalAlignment = VerticalAlignment.Stretch, + ItemsSource = new Control[] + { + CreateCard("One", "#D8574B", "#F7C5BE"), + CreateCard("Two", "#3E7AD9", "#BCD0F7"), + CreateCard("Three", "#3D9B67", "#BEE4CB"), + } + }; + + var target = new Border + { + Width = 520, + Height = 340, + Background = Brushes.White, + Padding = new Thickness(20), + Child = carousel + }; + + AvaloniaLocator.CurrentMutable.Bind().ToConstant(new CursorFactoryStub()); + target.Styles.Add(new SimpleTheme()); + target.Styles.Add(FontStyle); + await RenderToFile(target); + CompareImages(skipImmediate: true); + } + + private static Control CreateCard(string label, string background, string accent) + { + return new Border + { + Margin = new Thickness(14, 12), + CornerRadius = new CornerRadius(18), + ClipToBounds = true, + Background = Brush.Parse(background), + BorderBrush = Brushes.White, + BorderThickness = new Thickness(2), + Child = new Grid + { + Children = + { + new Border + { + Height = 56, + Background = Brush.Parse(accent), + VerticalAlignment = VerticalAlignment.Top + }, + new Border + { + Width = 88, + Height = 88, + CornerRadius = new CornerRadius(44), + Background = Brushes.White, + Opacity = 0.9, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + }, + new Border + { + Background = new SolidColorBrush(Color.Parse("#80000000")), + VerticalAlignment = VerticalAlignment.Bottom, + Padding = new Thickness(12), + Child = new TextBlock + { + Text = label, + Foreground = Brushes.White, + HorizontalAlignment = HorizontalAlignment.Center, + FontWeight = FontWeight.SemiBold + } + } + } + } + }; + } + + private sealed class CursorFactoryStub : ICursorFactory + { + public ICursorImpl GetCursor(StandardCursorType cursorType) => new CursorStub(); + + public ICursorImpl CreateCursor(Bitmap cursor, PixelPoint hotSpot) => new CursorStub(); + + private sealed class CursorStub : ICursorImpl + { + public void Dispose() + { + } + } + } + } +} diff --git a/tests/TestFiles/Skia/Controls/Carousel/Carousel_ViewportFraction_MiddleItemSelected_ShowsSidePeeks.expected.png b/tests/TestFiles/Skia/Controls/Carousel/Carousel_ViewportFraction_MiddleItemSelected_ShowsSidePeeks.expected.png new file mode 100644 index 0000000000..68fe675925 Binary files /dev/null and b/tests/TestFiles/Skia/Controls/Carousel/Carousel_ViewportFraction_MiddleItemSelected_ShowsSidePeeks.expected.png differ