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