Browse Source

[Feature] Add gestures and WrapSelection (loops) support to Carousel (#20659)

* Format Carousel sample UI

* Added WrapSelection property support

* Implement gestures

* Update sample adding custom page transitions

* More changes

* Added swipe velocity

* Optimize completion timer

* Verify gesture id

* Improve CrossFade animation

* Fix in swipe gesture getting direction

* More changes

* Fix mistake

* More protections

* Remove redundant ItemCount > 0 checks in OnKeyDown

* Renamed GestureId to Id in SwipeGestureEventArgs

* Remove size parameter from PageTransition Update method

* Changes based on feedback

* Update VirtualizingCarouselPanel.cs

* Refactor and complete swipe gesture (added more tests)

* Updated Avalonia.nupkg.xml

* Changes based on feedback

* Polish carousel snap-back animation

* Implement ViewportFractionProperty

* Fixed test

* Fix FillMode in Rotate3DTransition

* Updated comment

* Added vertical swipe tests

* More changes

* Fix interrupted carousel transition lifecycle
pull/20937/head
Javier Suárez 6 days ago
committed by GitHub
parent
commit
4e1b90b061
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 96
      api/Avalonia.nupkg.xml
  2. 117
      samples/ControlCatalog/Pages/CarouselPage.xaml
  3. 116
      samples/ControlCatalog/Pages/CarouselPage.xaml.cs
  4. 13
      samples/ControlCatalog/Pages/DrawerPage/DrawerPageCustomizationPage.xaml.cs
  5. 13
      samples/ControlCatalog/Pages/DrawerPage/DrawerPageFirstLookPage.xaml.cs
  6. 13
      samples/ControlCatalog/Pages/NavigationPage/NavigationPageGesturePage.xaml.cs
  7. 13
      samples/ControlCatalog/Pages/TabbedPage/TabbedPageGesturePage.xaml.cs
  8. 447
      samples/ControlCatalog/Pages/Transitions/CardStackPageTransition.cs
  9. 380
      samples/ControlCatalog/Pages/Transitions/WaveRevealPageTransition.cs
  10. 32
      src/Avalonia.Base/Animation/CompositePageTransition.cs
  11. 84
      src/Avalonia.Base/Animation/CrossFade.cs
  12. 39
      src/Avalonia.Base/Animation/IProgressPageTransition.cs
  13. 53
      src/Avalonia.Base/Animation/PageSlide.cs
  14. 12
      src/Avalonia.Base/Animation/PageTransitionItem.cs
  15. 151
      src/Avalonia.Base/Animation/Transitions/Rotate3DTransition.cs
  16. 18
      src/Avalonia.Base/Input/GestureRecognizers/GestureRecognizerCollection.cs
  17. 253
      src/Avalonia.Base/Input/GestureRecognizers/SwipeGestureRecognizer.cs
  18. 2
      src/Avalonia.Base/Input/Gestures.cs
  19. 16
      src/Avalonia.Base/Input/InputElement.Gestures.cs
  20. 28
      src/Avalonia.Base/Input/SwipeDirection.cs
  21. 71
      src/Avalonia.Base/Input/SwipeGestureEventArgs.cs
  22. 176
      src/Avalonia.Controls/Carousel.cs
  23. 23
      src/Avalonia.Controls/Page/DrawerPage.cs
  24. 24
      src/Avalonia.Controls/Page/NavigationPage.cs
  25. 17
      src/Avalonia.Controls/Page/TabbedPage.cs
  26. 1274
      src/Avalonia.Controls/VirtualizingCarouselPanel.cs
  27. 158
      tests/Avalonia.Base.UnitTests/Input/SwipeGestureRecognizerTests.cs
  28. 225
      tests/Avalonia.Controls.UnitTests/CarouselTests.cs
  29. 78
      tests/Avalonia.Controls.UnitTests/DrawerPageTests.cs
  30. 23
      tests/Avalonia.Controls.UnitTests/InputElementGestureTests.cs
  31. 115
      tests/Avalonia.Controls.UnitTests/NavigationPageTests.cs
  32. 89
      tests/Avalonia.Controls.UnitTests/TabbedPageTests.cs
  33. 768
      tests/Avalonia.Controls.UnitTests/VirtualizingCarouselPanelTests.cs
  34. 127
      tests/Avalonia.RenderTests/Controls/CarouselTests.cs
  35. BIN
      tests/TestFiles/Skia/Controls/Carousel/Carousel_ViewportFraction_MiddleItemSelected_ShowsSidePeeks.expected.png

96
api/Avalonia.nupkg.xml

@ -991,6 +991,18 @@
<Left>baseline/Avalonia/lib/net10.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net10.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>F:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.CrossAxisCancelThresholdProperty</Target>
<Left>baseline/Avalonia/lib/net10.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net10.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>F:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.EdgeSizeProperty</Target>
<Left>baseline/Avalonia/lib/net10.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net10.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>F:Avalonia.Input.HoldingState.Cancelled</Target>
@ -1147,6 +1159,30 @@
<Left>baseline/Avalonia/lib/net10.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net10.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.get_CrossAxisCancelThreshold</Target>
<Left>baseline/Avalonia/lib/net10.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net10.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.get_EdgeSize</Target>
<Left>baseline/Avalonia/lib/net10.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net10.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.set_CrossAxisCancelThreshold(System.Double)</Target>
<Left>baseline/Avalonia/lib/net10.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net10.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.set_EdgeSize(System.Double)</Target>
<Left>baseline/Avalonia/lib/net10.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net10.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Avalonia.Input.HoldingRoutedEventArgs.#ctor(Avalonia.Input.HoldingState,Avalonia.Point,Avalonia.Input.PointerType)</Target>
@ -1411,6 +1447,18 @@
<Left>baseline/Avalonia/lib/net10.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net10.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Avalonia.Input.SwipeGestureEventArgs.#ctor(System.Int32,Avalonia.Input.SwipeDirection,Avalonia.Vector,Avalonia.Point)</Target>
<Left>baseline/Avalonia/lib/net10.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net10.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Avalonia.Input.SwipeGestureEventArgs.get_StartPoint</Target>
<Left>baseline/Avalonia/lib/net10.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net10.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Avalonia.Input.TextInput.TextInputMethodClient.ShowInputPanel</Target>
@ -2545,6 +2593,18 @@
<Left>baseline/Avalonia/lib/net8.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net8.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>F:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.CrossAxisCancelThresholdProperty</Target>
<Left>baseline/Avalonia/lib/net8.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net8.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>F:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.EdgeSizeProperty</Target>
<Left>baseline/Avalonia/lib/net8.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net8.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>F:Avalonia.Input.HoldingState.Cancelled</Target>
@ -2701,6 +2761,30 @@
<Left>baseline/Avalonia/lib/net8.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net8.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.get_CrossAxisCancelThreshold</Target>
<Left>baseline/Avalonia/lib/net8.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net8.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.get_EdgeSize</Target>
<Left>baseline/Avalonia/lib/net8.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net8.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.set_CrossAxisCancelThreshold(System.Double)</Target>
<Left>baseline/Avalonia/lib/net8.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net8.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.set_EdgeSize(System.Double)</Target>
<Left>baseline/Avalonia/lib/net8.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net8.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Avalonia.Input.HoldingRoutedEventArgs.#ctor(Avalonia.Input.HoldingState,Avalonia.Point,Avalonia.Input.PointerType)</Target>
@ -2965,6 +3049,18 @@
<Left>baseline/Avalonia/lib/net8.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net8.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Avalonia.Input.SwipeGestureEventArgs.#ctor(System.Int32,Avalonia.Input.SwipeDirection,Avalonia.Vector,Avalonia.Point)</Target>
<Left>baseline/Avalonia/lib/net8.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net8.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Avalonia.Input.SwipeGestureEventArgs.get_StartPoint</Target>
<Left>baseline/Avalonia/lib/net8.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net8.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Avalonia.Input.TextInput.TextInputMethodClient.ShowInputPanel</Target>

117
samples/ControlCatalog/Pages/CarouselPage.xaml

@ -1,44 +1,117 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="ControlCatalog.Pages.CarouselPage">
<StackPanel Orientation="Vertical" Spacing="4">
<TextBlock Classes="h2">An items control that displays its items as pages that fill the control.</TextBlock>
<StackPanel Orientation="Vertical" Spacing="4" HorizontalAlignment="Stretch">
<TextBlock Classes="h2">A swipeable items control that can reveal adjacent pages with ViewportFraction.</TextBlock>
<Grid ColumnDefinitions="Auto,*,Auto"
MaxWidth="660"
<Grid Name="layoutGrid" ColumnDefinitions="Auto,*,Auto" RowDefinitions="*,Auto,*"
MaxWidth="760"
HorizontalAlignment="Stretch" Margin="0 16 0 0">
<Button Name="left" Grid.Column="0" VerticalAlignment="Center" Padding="10,20" Margin="4">
<Path Data="M20,11V13H8L13.5,18.5L12.08,19.92L4.16,12L12.08,4.08L13.5,5.5L8,11H20Z" Fill="Black"/>
<Button Name="left" Grid.Column="0" Grid.Row="1" VerticalAlignment="Center" HorizontalAlignment="Center" Padding="10,20" Margin="4">
<Path Name="leftArrow" Data="M20,11V13H8L13.5,18.5L12.08,19.92L4.16,12L12.08,4.08L13.5,5.5L8,11H20Z" Fill="Black"/>
</Button>
<Carousel Name="carousel" Grid.Column="1">
<Carousel Name="carousel" Grid.Column="1" Grid.Row="1" Background="Transparent" Height="400" Focusable="True" ViewportFraction="1.0">
<Carousel.PageTransition>
<PageSlide Duration="0.25" Orientation="Horizontal" />
</Carousel.PageTransition>
<Image Source="/Assets/delicate-arch-896885_640.jpg"/>
<Image Source="/Assets/hirsch-899118_640.jpg"/>
<Image Source="/Assets/maple-leaf-888807_640.jpg"/>
<Border Margin="14,12" CornerRadius="18" ClipToBounds="True">
<Grid>
<Image Source="/Assets/delicate-arch-896885_640.jpg" Stretch="UniformToFill"/>
<Border Background="#80000000" VerticalAlignment="Bottom" Padding="12">
<TextBlock Text="Item 1: Delicate Arch" Foreground="White" HorizontalAlignment="Center" FontWeight="SemiBold"/>
</Border>
</Grid>
</Border>
<Border Margin="14,12" CornerRadius="18" ClipToBounds="True">
<Grid>
<Image Source="/Assets/hirsch-899118_640.jpg" Stretch="UniformToFill"/>
<Border Background="#80000000" VerticalAlignment="Bottom" Padding="12">
<TextBlock Text="Item 2: Hirsch" Foreground="White" HorizontalAlignment="Center" FontWeight="SemiBold"/>
</Border>
</Grid>
</Border>
<Border Margin="14,12" CornerRadius="18" ClipToBounds="True">
<Grid>
<Image Source="/Assets/maple-leaf-888807_640.jpg" Stretch="UniformToFill"/>
<Border Background="#80000000" VerticalAlignment="Bottom" Padding="12">
<TextBlock Text="Item 3: Maple Leaf" Foreground="White" HorizontalAlignment="Center" FontWeight="SemiBold"/>
</Border>
</Grid>
</Border>
</Carousel>
<Button Name="right" Grid.Column="2" VerticalAlignment="Center" Padding="10,20" Margin="4">
<Path Data="M4,11V13H16L10.5,18.5L11.92,19.92L19.84,12L11.92,4.08L10.5,5.5L16,11H4Z" Fill="Black"/>
<Button Name="right" Grid.Column="2" Grid.Row="1" VerticalAlignment="Center" HorizontalAlignment="Center" Padding="10,20" Margin="4">
<Path Name="rightArrow" Data="M4,11V13H16L10.5,18.5L11.92,19.92L19.84,12L11.92,4.08L10.5,5.5L16,11H4Z" Fill="Black"/>
</Button>
</Grid>
<StackPanel Orientation="Horizontal" Spacing="4">
<TextBlock VerticalAlignment="Center">Transition</TextBlock>
<ComboBox Name="transition" SelectedIndex="1" VerticalAlignment="Center">
<Separator Margin="0 4"/>
<Grid ColumnDefinitions="160,420" RowDefinitions="Auto, Auto, Auto" RowSpacing="8"
Margin="0 4" MaxWidth="580" HorizontalAlignment="Left">
<TextBlock Grid.Row="0" Grid.Column="0" VerticalAlignment="Center">Transition</TextBlock>
<ComboBox SelectedIndex="1" Name="transition" Grid.Column="1" Grid.Row="0" HorizontalAlignment="Stretch">
<ComboBoxItem>None</ComboBoxItem>
<ComboBoxItem>Slide</ComboBoxItem>
<ComboBoxItem>Crossfade</ComboBoxItem>
<ComboBoxItem>3D Rotation</ComboBoxItem>
<ComboBoxItem>Page Slide</ComboBoxItem>
<ComboBoxItem>Cross Fade</ComboBoxItem>
<ComboBoxItem>Rotate 3D</ComboBoxItem>
<ComboBoxItem>Card Stack</ComboBoxItem>
<ComboBoxItem>Wave Reveal</ComboBoxItem>
<ComboBoxItem>Composite (Slide + Fade)</ComboBoxItem>
</ComboBox>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="4">
<TextBlock VerticalAlignment="Center">Orientation</TextBlock>
<ComboBox Name="orientation" SelectedIndex="0" VerticalAlignment="Center">
<TextBlock Grid.Row="1" Grid.Column="0" VerticalAlignment="Center">Orientation</TextBlock>
<ComboBox Name="orientation" Grid.Row="1" Grid.Column="1" SelectedIndex="0" HorizontalAlignment="Stretch">
<ComboBoxItem>Horizontal</ComboBoxItem>
<ComboBoxItem>Vertical</ComboBoxItem>
</ComboBox>
<TextBlock Grid.Row="2" Grid.Column="0" VerticalAlignment="Center">Viewport Fraction</TextBlock>
<StackPanel Grid.Row="2" Grid.Column="1" Spacing="4" HorizontalAlignment="Stretch">
<Grid ColumnDefinitions="*,56" ColumnSpacing="12">
<Slider Name="viewportFraction"
Minimum="0.33"
Maximum="1"
Value="1.0"
TickFrequency="0.01"
HorizontalAlignment="Stretch" />
<TextBlock Name="viewportFractionIndicator"
Grid.Column="1"
HorizontalAlignment="Stretch"
TextAlignment="Right"
VerticalAlignment="Center"
FontWeight="SemiBold">1.00</TextBlock>
</Grid>
<TextBlock Name="viewportFractionHint"
HorizontalAlignment="Stretch"
Opacity="0.75"
TextWrapping="Wrap"
Text="Values below 1 reveal adjacent pages." />
</StackPanel>
</Grid>
<Separator Margin="0 8"/>
<StackPanel Orientation="Horizontal" Spacing="24" Margin="0 4" MaxWidth="580" HorizontalAlignment="Left">
<CheckBox Name="wrapSelection">Wrap Selection</CheckBox>
<CheckBox Name="swipeEnabled">Swipe Enabled</CheckBox>
</StackPanel>
<Separator Margin="0 8"/>
<StackPanel Spacing="12" MaxWidth="580" HorizontalAlignment="Left">
<StackPanel Orientation="Horizontal" Spacing="24">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock>Total Items:</TextBlock>
<TextBlock Name="itemsCountIndicator" FontWeight="Bold">0</TextBlock>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock>Selected Index:</TextBlock>
<TextBlock Name="selectedIndexIndicator" FontWeight="Bold">0</TextBlock>
</StackPanel>
</StackPanel>
</StackPanel>
</StackPanel>

116
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;
}
}
}

13
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<SwipeGestureRecognizer>()
.FirstOrDefault();
if (recognizer is not null)
recognizer.IsMouseEnabled = true;
}
}
}

13
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<SwipeGestureRecognizer>()
.FirstOrDefault();
if (recognizer is not null)
recognizer.IsMouseEnabled = true;
}
}
}

13
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<SwipeGestureRecognizer>()
.FirstOrDefault();
if (recognizer is not null)
recognizer.IsMouseEnabled = true;
}
}
}

13
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<SwipeGestureRecognizer>()
.FirstOrDefault();
if (recognizer is not null)
recognizer.IsMouseEnabled = true;
}
}
}

447
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;
/// <summary>
/// Transitions between two pages with a card-stack effect:
/// the top page moves/rotates away while the next page scales up underneath.
/// </summary>
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;
/// <summary>
/// Initializes a new instance of the <see cref="CardStackPageTransition"/> class.
/// </summary>
public CardStackPageTransition()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="CardStackPageTransition"/> class.
/// </summary>
/// <param name="duration">The duration of the animation.</param>
/// <param name="orientation">The axis on which the animation should occur.</param>
public CardStackPageTransition(TimeSpan duration, PageSlide.SlideAxis orientation = PageSlide.SlideAxis.Horizontal)
: base(duration, orientation)
{
}
/// <summary>
/// Gets or sets the maximum rotation angle (degrees) applied to the top card.
/// </summary>
public double MaxSwipeAngle { get; set; } = 15.0;
/// <summary>
/// Gets or sets the scale reduction applied to the back card (0.05 = 5%).
/// </summary>
public double BackCardScale { get; set; } = 0.05;
/// <summary>
/// Gets or sets the vertical offset (pixels) applied to the back card.
/// </summary>
public double BackCardOffset { get; set; } = 0.0;
/// <inheritdoc />
public override async Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested)
{
return;
}
var tasks = new List<Task>();
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;
}
}
/// <inheritdoc />
public override void Update(
double progress,
Visual? from,
Visual? to,
bool forward,
double pageLength,
IReadOnlyList<PageTransitionItem> 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;
}
}
/// <inheritdoc />
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<PageTransitionItem> 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;
}
}

380
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;
/// <summary>
/// Transitions between two pages using a wave clip that reveals the next page.
/// </summary>
public class WaveRevealPageTransition : PageSlide
{
/// <summary>
/// Initializes a new instance of the <see cref="WaveRevealPageTransition"/> class.
/// </summary>
public WaveRevealPageTransition()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="WaveRevealPageTransition"/> class.
/// </summary>
/// <param name="duration">The duration of the animation.</param>
/// <param name="orientation">The axis on which the animation should occur.</param>
public WaveRevealPageTransition(TimeSpan duration, PageSlide.SlideAxis orientation = PageSlide.SlideAxis.Horizontal)
: base(duration, orientation)
{
}
/// <summary>
/// Gets or sets the maximum wave bulge (pixels) along the movement axis.
/// </summary>
public double MaxBulge { get; set; } = 120.0;
/// <summary>
/// Gets or sets the bulge factor along the movement axis (0-1).
/// </summary>
public double BulgeFactor { get; set; } = 0.35;
/// <summary>
/// Gets or sets the bulge factor along the cross axis (0-1).
/// </summary>
public double CrossBulgeFactor { get; set; } = 0.3;
/// <summary>
/// Gets or sets a cross-axis offset (pixels) to shift the wave center.
/// </summary>
public double WaveCenterOffset { get; set; } = 0.0;
/// <summary>
/// Gets or sets how strongly the wave center follows the provided offset.
/// </summary>
public double CenterSensitivity { get; set; } = 1.0;
/// <summary>
/// Gets or sets the bulge exponent used to shape the wave (1.0 = linear).
/// Higher values tighten the bulge; lower values broaden it.
/// </summary>
public double BulgeExponent { get; set; } = 1.0;
/// <summary>
/// Gets or sets the easing applied to the wave progress (clip only).
/// </summary>
public Easing WaveEasing { get; set; } = new CubicEaseOut();
/// <inheritdoc />
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;
}
}
/// <inheritdoc />
public override void Update(
double progress,
Visual? from,
Visual? to,
bool forward,
double pageLength,
IReadOnlyList<PageTransitionItem> 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<PageTransitionItem> 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);
}
/// <inheritdoc />
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<PageTransitionItem>());
if (t >= 1.0)
break;
await Task.Delay(16, cancellationToken);
}
if (!cancellationToken.IsCancellationRequested)
{
Update(to, fromVisual, toVisual, forward, pageLength, Array.Empty<PageTransitionItem>());
}
}
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;
}
}
}

32
src/Avalonia.Base/Animation/CompositePageTransition.cs

@ -28,7 +28,7 @@ namespace Avalonia.Animation
/// </code>
/// </para>
/// </remarks>
public class CompositePageTransition : IPageTransition
public class CompositePageTransition : IPageTransition, IProgressPageTransition
{
/// <summary>
/// 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);
}
/// <inheritdoc />
public void Update(
double progress,
Visual? from,
Visual? to,
bool forward,
double pageLength,
IReadOnlyList<PageTransitionItem> visibleItems)
{
foreach (var transition in PageTransitions)
{
if (transition is IProgressPageTransition progressive)
{
progressive.Update(progress, from, to, forward, pageLength, visibleItems);
}
}
}
/// <inheritdoc />
public void Reset(Visual visual)
{
foreach (var transition in PageTransitions)
{
if (transition is IProgressPageTransition progressive)
{
progressive.Reset(visual);
}
}
}
}
}

84
src/Avalonia.Base/Animation/CrossFade.cs

@ -12,8 +12,13 @@ namespace Avalonia.Animation
/// <summary>
/// Defines a cross-fade animation between two <see cref="Visual"/>s.
/// </summary>
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);
}
/// <inheritdoc/>
public void Update(
double progress,
Visual? from,
Visual? to,
bool forward,
double pageLength,
IReadOnlyList<PageTransitionItem> 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;
}
}
/// <inheritdoc/>
public void Reset(Visual visual)
{
visual.Opacity = 1;
}
private static void UpdateVisibleItems(
double progress,
Visual? from,
Visual? to,
IReadOnlyList<PageTransitionItem> 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));
}
}
}

39
src/Avalonia.Base/Animation/IProgressPageTransition.cs

@ -0,0 +1,39 @@
using System.Collections.Generic;
using Avalonia.VisualTree;
namespace Avalonia.Animation
{
/// <summary>
/// An <see cref="IPageTransition"/> that supports progress-driven updates.
/// </summary>
/// <remarks>
/// 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 <see cref="IPageTransition.Start"/>.
/// </remarks>
public interface IProgressPageTransition : IPageTransition
{
/// <summary>
/// Updates the transition to reflect the given progress.
/// </summary>
/// <param name="progress">The normalized progress value from 0.0 (start) to 1.0 (complete).</param>
/// <param name="from">The visual being transitioned away from. May be null.</param>
/// <param name="to">The visual being transitioned to. May be null.</param>
/// <param name="forward">Whether the transition direction is forward (next) or backward (previous).</param>
/// <param name="pageLength">The size of a page along the transition axis.</param>
/// <param name="visibleItems">The currently visible realized pages, if more than one page is visible.</param>
void Update(
double progress,
Visual? from,
Visual? to,
bool forward,
double pageLength,
IReadOnlyList<PageTransitionItem> visibleItems);
/// <summary>
/// Resets any visual state applied to the given visual by this transition.
/// </summary>
/// <param name="visual">The visual to reset.</param>
void Reset(Visual visual);
}
}

53
src/Avalonia.Base/Animation/PageSlide.cs

@ -12,7 +12,7 @@ namespace Avalonia.Animation
/// <summary>
/// Transitions between two pages by sliding them horizontally or vertically.
/// </summary>
public class PageSlide : IPageTransition
public class PageSlide : IPageTransition, IProgressPageTransition
{
/// <summary>
/// The axis on which the PageSlide should occur
@ -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;
}
/// <inheritdoc/>
public virtual void Update(
double progress,
Visual? from,
Visual? to,
bool forward,
double pageLength,
IReadOnlyList<PageTransitionItem> 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);
}
}
/// <inheritdoc/>
public virtual void Reset(Visual visual)
{
visual.RenderTransform = null;
}
/// <summary>
/// Gets the common visual parent of the two control.
/// </summary>

12
src/Avalonia.Base/Animation/PageTransitionItem.cs

@ -0,0 +1,12 @@
using Avalonia.VisualTree;
namespace Avalonia.Animation
{
/// <summary>
/// Describes a single visible page within a carousel viewport.
/// </summary>
public readonly record struct PageTransitionItem(
int Index,
Visual Visual,
double ViewportCenterOffset);
}

151
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;
/// <summary>
/// Creates a new instance of the <see cref="Rotate3DTransition"/>
@ -28,12 +31,12 @@ public class Rotate3DTransition: PageSlide
public double? Depth { get; set; }
/// <summary>
/// Creates a new instance of the <see cref="Rotate3DTransition"/>
/// Initializes a new instance of the <see cref="Rotate3DTransition"/> class.
/// </summary>
public Rotate3DTransition() { }
/// <inheritdoc />
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)
{
@ -53,7 +56,8 @@ public class Rotate3DTransition: PageSlide
var centerZSetter = new Setter { Property = Rotate3DTransform.CenterZProperty, Value = -center / 2 };
KeyFrame CreateKeyFrame(double cue, double rotation, int zIndex, bool isVisible = true) =>
new() {
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,9 +111,7 @@ public class Rotate3DTransition: PageSlide
if (!cancellationToken.IsCancellationRequested)
{
if (to != null)
{
to.ZIndex = 2;
}
if (from != null)
{
@ -118,4 +120,139 @@ public class Rotate3DTransition: PageSlide
}
}
}
/// <inheritdoc/>
public override void Update(
double progress,
Visual? from,
Visual? to,
bool forward,
double pageLength,
IReadOnlyList<PageTransitionItem> 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<PageTransitionItem> 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));
}
/// <inheritdoc/>
public override void Reset(Visual visual)
{
visual.RenderTransform = null;
visual.ZIndex = 0;
}
}

18
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<GestureRecognizer> s_Empty = new List<GestureRecognizer>();
public IEnumerator<GestureRecognizer> GetEnumerator()

253
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
{
/// <summary>
/// A gesture recognizer that detects swipe gestures and raises
/// <see cref="InputElement.SwipeGestureEvent"/> on the target element when a swipe is confirmed.
/// A gesture recognizer that detects swipe gestures for paging interactions.
/// </summary>
/// <remarks>
/// Unlike <see cref="ScrollGestureRecognizer"/>, this recognizer is optimized for discrete
/// paging interactions (e.g., carousel navigation) rather than continuous scrolling.
/// It does not include inertia or friction physics.
/// </remarks>
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;
/// <summary>
/// Defines the <see cref="Threshold"/> property.
/// Defines the <see cref="CanHorizontallySwipe"/> property.
/// </summary>
public static readonly StyledProperty<double> ThresholdProperty =
AvaloniaProperty.Register<SwipeGestureRecognizer, double>(nameof(Threshold), 30d);
public static readonly StyledProperty<bool> CanHorizontallySwipeProperty =
AvaloniaProperty.Register<SwipeGestureRecognizer, bool>(nameof(CanHorizontallySwipe));
/// <summary>
/// Defines the <see cref="CanVerticallySwipe"/> property.
/// </summary>
public static readonly StyledProperty<bool> CanVerticallySwipeProperty =
AvaloniaProperty.Register<SwipeGestureRecognizer, bool>(nameof(CanVerticallySwipe));
/// <summary>
/// Defines the <see cref="CrossAxisCancelThreshold"/> property.
/// Defines the <see cref="Threshold"/> property.
/// </summary>
public static readonly StyledProperty<double> CrossAxisCancelThresholdProperty =
AvaloniaProperty.Register<SwipeGestureRecognizer, double>(
nameof(CrossAxisCancelThreshold), 8d);
/// <remarks>
/// A value of 0 (the default) causes the distance to be read from
/// <see cref="IPlatformSettings"/> at the time of the first gesture.
/// </remarks>
public static readonly StyledProperty<double> ThresholdProperty =
AvaloniaProperty.Register<SwipeGestureRecognizer, double>(nameof(Threshold), defaultValue: 0d);
/// <summary>
/// Defines the <see cref="EdgeSize"/> property.
/// Leading-edge start zone in px. 0 (default) = full area.
/// When &gt; 0, only starts tracking if the pointer is within this many px
/// of the leading edge (LTR: left; RTL: right).
/// Defines the <see cref="IsMouseEnabled"/> property.
/// </summary>
public static readonly StyledProperty<double> EdgeSizeProperty =
AvaloniaProperty.Register<SwipeGestureRecognizer, double>(nameof(EdgeSize), 0d);
public static readonly StyledProperty<bool> IsMouseEnabledProperty =
AvaloniaProperty.Register<SwipeGestureRecognizer, bool>(nameof(IsMouseEnabled), defaultValue: false);
/// <summary>
/// Defines the <see cref="IsEnabled"/> 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.
/// </summary>
public static readonly StyledProperty<bool> IsEnabledProperty =
AvaloniaProperty.Register<SwipeGestureRecognizer, bool>(nameof(IsEnabled), true);
AvaloniaProperty.Register<SwipeGestureRecognizer, bool>(nameof(IsEnabled), defaultValue: true);
/// <summary>
/// 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.
/// </summary>
public double Threshold
public bool CanHorizontallySwipe
{
get => GetValue(ThresholdProperty);
set => SetValue(ThresholdProperty, value);
get => GetValue(CanHorizontallySwipeProperty);
set => SetValue(CanHorizontallySwipeProperty, value);
}
/// <summary>
/// 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.
/// </summary>
public double CrossAxisCancelThreshold
public bool CanVerticallySwipe
{
get => GetValue(CrossAxisCancelThresholdProperty);
set => SetValue(CrossAxisCancelThresholdProperty, value);
get => GetValue(CanVerticallySwipeProperty);
set => SetValue(CanVerticallySwipeProperty, value);
}
/// <summary>
/// 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 <see cref="IPlatformSettings"/> at gesture time.
/// </summary>
public double EdgeSize
public double Threshold
{
get => GetValue(EdgeSizeProperty);
set => SetValue(EdgeSizeProperty, value);
get => GetValue(ThresholdProperty);
set => SetValue(ThresholdProperty, value);
}
/// <summary>
/// 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 mouse pointer events trigger swipe gestures.
/// Defaults to <see langword="false"/>; touch and pen are always enabled.
/// </summary>
public bool IsMouseEnabled
{
get => GetValue(IsMouseEnabledProperty);
set => SetValue(IsMouseEnabledProperty, value);
}
/// <summary>
/// Gets or sets a value indicating whether this recognizer responds to pointer events.
/// Defaults to <see langword="true"/>.
/// </summary>
public bool IsEnabled
{
@ -89,104 +104,122 @@ namespace Avalonia.Input.GestureRecognizers
set => SetValue(IsEnabledProperty, value);
}
/// <inheritdoc/>
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)
{
bool isRtl = visual.FlowDirection == FlowDirection.RightToLeft;
bool inEdge = isRtl
? pos.X >= visual.Bounds.Width - edgeSize
: pos.X <= edgeSize;
if (!inEdge)
if ((e.Pointer.Type is PointerType.Touch or PointerType.Pen ||
(IsMouseEnabled && e.Pointer.Type == PointerType.Mouse))
&& point.Properties.IsLeftButtonPressed)
{
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;
/// <inheritdoc/>
protected override void PointerMoved(PointerEventArgs e)
{
if (e.Pointer == _tracking)
{
var rootPoint = e.GetPosition(null);
var threshold = GetEffectiveThreshold();
Logger.TryGet(LogEventLevel.Verbose, LogArea.Control)?.Log(
this, "SwipeGestureRecognizer: tracking started at {Pos} (pointer={PointerType})",
pos, e.Pointer.Type);
}
if (!_swiping)
{
var horizontalTriggered = CanHorizontallySwipe && Math.Abs(_trackedRootPoint.X - rootPoint.X) > threshold;
var verticalTriggered = CanVerticallySwipe && Math.Abs(_trackedRootPoint.Y - rootPoint.Y) > threshold;
protected override void PointerMoved(PointerEventArgs e)
if (horizontalTriggered || verticalTriggered)
{
if (_tracking != e.Pointer || Target is not Visual visual) return;
_swiping = true;
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;
_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);
if (absDx < threshold && absDy < threshold)
return;
Capture(e.Pointer);
}
}
SwipeDirection dir;
Vector delta;
if (absDx >= absDy)
if (_swiping)
{
dir = dx > 0 ? SwipeDirection.Right : SwipeDirection.Left;
delta = new Vector(dx, 0);
}
else
var delta = _trackedRootPoint - rootPoint;
var now = Stopwatch.GetTimestamp();
if (_lastTimestamp > 0)
{
var elapsedSeconds = (double)(now - _lastTimestamp) / Stopwatch.Frequency;
if (elapsedSeconds > 0)
{
dir = dy > 0 ? SwipeDirection.Down : SwipeDirection.Up;
delta = new Vector(0, dy);
var instantVelocity = delta / elapsedSeconds;
_velocity = _velocity * 0.5 + instantVelocity * 0.5;
}
}
_lastTimestamp = now;
Logger.TryGet(LogEventLevel.Verbose, LogArea.Control)?.Log(
this, "SwipeGestureRecognizer: swipe recognized — direction={Direction}, delta={Delta}",
dir, delta);
_tracking = null;
_captured = e.Pointer;
Capture(e.Pointer);
Target!.RaiseEvent(new SwipeGestureEventArgs(_id, delta, _velocity));
_trackedRootPoint = rootPoint;
e.Handled = true;
}
}
}
var args = new SwipeGestureEventArgs(_gestureId, dir, delta, _initialPosition);
Target?.RaiseEvent(args);
/// <inheritdoc/>
protected override void PointerCaptureLost(IPointer pointer)
{
if (pointer == _tracking)
EndGesture();
}
/// <inheritdoc/>
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)
{
if (_tracking == pointer)
private const double DefaultTapSize = 10;
private double GetEffectiveThreshold()
{
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<IPlatformSettings>()
?.GetTapSize(PointerType.Touch).Height ?? DefaultTapSize;
return tapSize / 2;
}
}
}

2
src/Avalonia.Base/Input/Gestures.cs

@ -30,14 +30,12 @@ namespace Avalonia.Input
private static readonly WeakReference<object?> s_lastPress = new WeakReference<object?>(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)

16
src/Avalonia.Base/Input/InputElement.Gestures.cs

@ -54,6 +54,13 @@ namespace Avalonia.Input
RoutedEvent.Register<InputElement, SwipeGestureEventArgs>(
nameof(SwipeGesture), RoutingStrategies.Bubble);
/// <summary>
/// Defines the <see cref="SwipeGestureEnded"/> event.
/// </summary>
public static readonly RoutedEvent<SwipeGestureEndedEventArgs> SwipeGestureEndedEvent =
RoutedEvent.Register<InputElement, SwipeGestureEndedEventArgs>(
nameof(SwipeGestureEnded), RoutingStrategies.Bubble);
/// <summary>
/// Defines the <see cref="ScrollGesture"/> event.
/// </summary>
@ -238,6 +245,15 @@ namespace Avalonia.Input
remove { RemoveHandler(SwipeGestureEvent, value); }
}
/// <summary>
/// Occurs when a swipe gesture ends on the control.
/// </summary>
public event EventHandler<SwipeGestureEndedEventArgs>? SwipeGestureEnded
{
add { AddHandler(SwipeGestureEndedEvent, value); }
remove { RemoveHandler(SwipeGestureEndedEvent, value); }
}
/// <summary>
/// Occurs when the user performs a rapid dragging motion in a single direction on a touchpad.
/// </summary>

28
src/Avalonia.Base/Input/SwipeDirection.cs

@ -0,0 +1,28 @@
namespace Avalonia.Input
{
/// <summary>
/// Specifies the direction of a swipe gesture.
/// </summary>
public enum SwipeDirection
{
/// <summary>
/// The swipe moved to the left.
/// </summary>
Left,
/// <summary>
/// The swipe moved to the right.
/// </summary>
Right,
/// <summary>
/// The swipe moved upward.
/// </summary>
Up,
/// <summary>
/// The swipe moved downward.
/// </summary>
Down
}
}

71
src/Avalonia.Base/Input/SwipeGestureEventArgs.cs

@ -1,50 +1,81 @@
using System;
using System.Threading;
using Avalonia.Interactivity;
namespace Avalonia.Input
{
/// <summary>
/// Specifies the direction of a swipe gesture.
/// Provides data for swipe gesture events.
/// </summary>
public enum SwipeDirection { Left, Right, Up, Down }
public class SwipeGestureEventArgs : RoutedEventArgs
{
/// <summary>
/// Provides data for the <see cref="InputElement.SwipeGestureEvent"/> routed event.
/// Initializes a new instance of the <see cref="SwipeGestureEventArgs"/> class.
/// </summary>
public class SwipeGestureEventArgs : RoutedEventArgs
/// <param name="id">The unique identifier for this gesture.</param>
/// <param name="delta">The pixel delta since the last event.</param>
/// <param name="velocity">The current swipe velocity in pixels per second.</param>
public SwipeGestureEventArgs(int id, Vector delta, Vector velocity)
: base(InputElement.SwipeGestureEvent)
{
private static int _nextId = 1;
internal static int GetNextFreeId() => _nextId++;
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);
}
/// <summary>
/// Gets the unique identifier for this swipe gesture instance.
/// Gets the unique identifier for this gesture sequence.
/// </summary>
public int Id { get; }
/// <summary>
/// Gets the direction of the swipe gesture.
/// Gets the pixel delta since the last event.
/// </summary>
public SwipeDirection SwipeDirection { get; }
public Vector Delta { get; }
/// <summary>
/// Gets the total translation vector of the swipe gesture.
/// Gets the current swipe velocity in pixels per second.
/// </summary>
public Vector Delta { get; }
public Vector Velocity { get; }
/// <summary>
/// Gets the position, relative to the target element, where the swipe started.
/// Gets the direction of the dominant swipe axis.
/// </summary>
public Point StartPoint { get; }
public SwipeDirection SwipeDirection { get; }
private static int s_nextId;
internal static int GetNextFreeId() => Interlocked.Increment(ref s_nextId);
}
/// <summary>
/// Initializes a new instance of <see cref="SwipeGestureEventArgs"/>.
/// Provides data for the swipe gesture ended event.
/// </summary>
public SwipeGestureEventArgs(int id, SwipeDirection direction, Vector delta, Point startPoint)
: base(InputElement.SwipeGestureEvent)
public class SwipeGestureEndedEventArgs : RoutedEventArgs
{
/// <summary>
/// Initializes a new instance of the <see cref="SwipeGestureEndedEventArgs"/> class.
/// </summary>
/// <param name="id">The unique identifier for this gesture.</param>
/// <param name="velocity">The swipe velocity at release in pixels per second.</param>
public SwipeGestureEndedEventArgs(int id, Vector velocity)
: base(InputElement.SwipeGestureEndedEvent)
{
Id = id;
SwipeDirection = direction;
Delta = delta;
StartPoint = startPoint;
Velocity = velocity;
}
/// <summary>
/// Gets the unique identifier for this gesture sequence.
/// </summary>
public int Id { get; }
/// <summary>
/// Gets the swipe velocity at release in pixels per second.
/// </summary>
public Vector Velocity { get; }
}
}

176
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
{
/// <summary>
/// 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 <see cref="ViewportFraction"/>.
/// </summary>
public class Carousel : SelectingItemsControl
{
@ -15,6 +17,28 @@ namespace Avalonia.Controls
public static readonly StyledProperty<IPageTransition?> PageTransitionProperty =
AvaloniaProperty.Register<Carousel, IPageTransition?>(nameof(PageTransition));
/// <summary>
/// Defines the <see cref="IsSwipeEnabled"/> property.
/// </summary>
public static readonly StyledProperty<bool> IsSwipeEnabledProperty =
AvaloniaProperty.Register<Carousel, bool>(nameof(IsSwipeEnabled), defaultValue: false);
/// <summary>
/// Defines the <see cref="ViewportFraction"/> property.
/// </summary>
public static readonly StyledProperty<double> ViewportFractionProperty =
AvaloniaProperty.Register<Carousel, double>(
nameof(ViewportFraction),
defaultValue: 1d,
coerce: (_, value) => double.IsFinite(value) && value > 0 ? value : 1d);
/// <summary>
/// Defines the <see cref="IsSwiping"/> property.
/// </summary>
public static readonly DirectProperty<Carousel, bool> IsSwipingProperty =
AvaloniaProperty.RegisterDirect<Carousel, bool>(nameof(IsSwiping),
o => o.IsSwiping);
/// <summary>
/// The default value of <see cref="ItemsControl.ItemsPanelProperty"/> for
/// <see cref="Carousel"/>.
@ -23,6 +47,7 @@ namespace Avalonia.Controls
new(() => new VirtualizingCarouselPanel());
private IScrollable? _scroller;
private bool _isSwiping;
/// <summary>
/// Initializes static members of the <see cref="Carousel"/> class.
@ -42,15 +67,51 @@ namespace Avalonia.Controls
set => SetValue(PageTransitionProperty, value);
}
/// <summary>
/// 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.
/// </summary>
public bool IsSwipeEnabled
{
get => GetValue(IsSwipeEnabledProperty);
set => SetValue(IsSwipeEnabledProperty, value);
}
/// <summary>
/// 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.
/// </summary>
public double ViewportFraction
{
get => GetValue(ViewportFractionProperty);
set => SetValue(ViewportFractionProperty, value);
}
/// <summary>
/// Gets a value indicating whether a swipe gesture is currently in progress.
/// </summary>
public bool IsSwiping
{
get => _isSwiping;
internal set => SetAndRaise(IsSwipingProperty, ref _isSwiping, value);
}
/// <summary>
/// Moves to the next item in the carousel.
/// </summary>
public void Next()
{
if (ItemCount == 0)
return;
if (SelectedIndex < ItemCount - 1)
{
++SelectedIndex;
}
else if (WrapSelection)
{
SelectedIndex = 0;
}
}
/// <summary>
@ -58,18 +119,78 @@ namespace Avalonia.Controls
/// </summary>
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)
{
var value = change.GetNewValue<int>();
_scroller.Offset = new(value, 0);
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)
{
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);
}
}
}

23
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();
}
/// <summary>
@ -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)
{

24
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<object> _pageSet = new(ReferenceEqualityComparer.Instance);
@ -257,7 +259,12 @@ namespace Avalonia.Controls
public NavigationPage()
{
SetCurrentValue(PagesProperty, new Stack<Page>());
GestureRecognizers.Add(new SwipeGestureRecognizer { EdgeSize = EdgeGestureWidth });
GestureRecognizers.Add(new SwipeGestureRecognizer
{
CanHorizontallySwipe = true,
CanVerticallySwipe = false
});
AddHandler(PointerPressedEvent, OnSwipePointerPressed, handledEventsToo: true);
}
/// <summary>
@ -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);

17
src/Avalonia.Controls/Page/TabbedPage.cs

@ -26,6 +26,7 @@ namespace Avalonia.Controls
private TabControl? _tabControl;
private readonly Dictionary<TabItem, Page> _containerPageMap = new();
private readonly Dictionary<Page, TabItem> _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();
}
/// <summary>
@ -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<IPageTransition?>();
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;
}
}

1274
src/Avalonia.Controls/VirtualizingCarouselPanel.cs

File diff suppressed because it is too large

158
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<Vector>();
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<int>();
var velocities = new List<Vector>();
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);
}
}

225
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)

78
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<SwipeGestureRecognizer>().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<SwipeGestureRecognizer>().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]

23
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);
}
}

115
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<SwipeGestureRecognizer>().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]

89
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<Page>
{
new ContentPage { Header = "A" },
new ContentPage { Header = "B" },
new ContentPage { Header = "C" }
},
Template = new FuncControlTemplate<TabbedPage>((parent, scope) =>
{
var tabControl = new TabControl
{
Name = "PART_TabControl",
ItemsSource = parent.Pages
};
scope.Register("PART_TabControl", tabControl);
return tabControl;
})
};
tp.GestureRecognizers.OfType<SwipeGestureRecognizer>().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);

768
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<ContentPresenter>()
.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<ContentPresenter>()
.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<ContentPresenter>()
.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<ContentPresenter>()
.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<IPageTransition>();
transition.Setup(x => x.Start(
It.IsAny<Visual>(),
It.IsAny<Visual>(),
It.IsAny<bool>(),
It.IsAny<CancellationToken>()))
.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<CancellationToken>()),
Times.Once);
transition.Verify(x => x.Start(
items[1],
items[2],
true,
It.IsAny<CancellationToken>()),
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<ContentPresenter>(), 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<string>?)carousel.ItemsSource)?.Count);
Assert.Equal(1, carousel.SelectedIndex);
Assert.False(carousel.WrapSelection, "WrapSelection should be false");
var container = Assert.IsType<ContentPresenter>(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<ContentPresenter>(), 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<ContentPresenter>(), x => Equals(x.Content, "foo"));
bool? hiddenWhenReset = null;
outgoing.PropertyChanged += (_, args) =>
{
if (args.Property == Visual.RenderTransformProperty &&
args.GetNewValue<ITransform?>() 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<PageTransitionItem> 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<double> 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<PageTransitionItem> 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<PageTransitionItem> 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<ScaleTransform>(group.Children[0]);
var translate = Assert.IsType<TranslateTransform>(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<PageTransitionItem> 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<IPageTransition>();
transition.Setup(x => x.Start(It.IsAny<Visual>(), It.IsAny<Visual>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.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);
}
}
}
}

127
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<TextBlock>())
{
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<ICursorFactory>().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()
{
}
}
}
}
}

BIN
tests/TestFiles/Skia/Controls/Carousel/Carousel_ViewportFraction_MiddleItemSelected_ShowsSidePeeks.expected.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Loading…
Cancel
Save