diff --git a/api/Avalonia.nupkg.xml b/api/Avalonia.nupkg.xml
index 44617ccf64..63e8234919 100644
--- a/api/Avalonia.nupkg.xml
+++ b/api/Avalonia.nupkg.xml
@@ -409,6 +409,12 @@
baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
+ CP0001
+ T:Avalonia.Controls.Primitives.SelectionHandleType
+ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
CP0001
T:Avalonia.Controls.Remote.RemoteServer
@@ -883,6 +889,12 @@
baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
+ CP0001
+ T:Avalonia.Controls.Primitives.SelectionHandleType
+ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
CP0001
T:Avalonia.Controls.Remote.RemoteServer
@@ -979,6 +991,18 @@
baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+ CP0002
+ F:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.CrossAxisCancelThresholdProperty
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+
+ CP0002
+ F:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.EdgeSizeProperty
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
CP0002
F:Avalonia.Input.HoldingState.Cancelled
@@ -1105,12 +1129,72 @@
baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+ CP0002
+ M:Avalonia.Input.FocusManager.#ctor(Avalonia.Input.IInputElement)
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+
+ CP0002
+ M:Avalonia.Input.FocusManager.ClearFocus
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+
+ CP0002
+ M:Avalonia.Input.FocusManager.ClearFocusOnElementRemoved(Avalonia.Input.IInputElement,Avalonia.Visual)
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+
+ CP0002
+ M:Avalonia.Input.FocusManager.FindNextElement(Avalonia.Input.NavigationDirection)
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+
+ CP0002
+ M:Avalonia.Input.FocusManager.TryMoveFocus(Avalonia.Input.NavigationDirection)
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+
+ CP0002
+ M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.get_CrossAxisCancelThreshold
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+
+ CP0002
+ M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.get_EdgeSize
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+
+ CP0002
+ M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.set_CrossAxisCancelThreshold(System.Double)
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+
+ CP0002
+ M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.set_EdgeSize(System.Double)
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
CP0002
M:Avalonia.Input.HoldingRoutedEventArgs.#ctor(Avalonia.Input.HoldingState,Avalonia.Point,Avalonia.Input.PointerType)
baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+ CP0002
+ M:Avalonia.Input.IFocusManager.ClearFocus
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
CP0002
M:Avalonia.Input.IInputRoot.get_KeyboardNavigationHandler
@@ -1363,6 +1447,18 @@
baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+ CP0002
+ M:Avalonia.Input.SwipeGestureEventArgs.#ctor(System.Int32,Avalonia.Input.SwipeDirection,Avalonia.Vector,Avalonia.Point)
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+
+ CP0002
+ M:Avalonia.Input.SwipeGestureEventArgs.get_StartPoint
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
CP0002
M:Avalonia.Input.TextInput.TextInputMethodClient.ShowInputPanel
@@ -2077,12 +2173,36 @@
baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
+ CP0002
+ M:Avalonia.Controls.Primitives.TextSearch.GetText(Avalonia.Interactivity.Interactive)
+ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
+
+ CP0002
+ M:Avalonia.Controls.Primitives.TextSearch.GetTextBinding(Avalonia.Interactivity.Interactive)
+ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
CP0002
M:Avalonia.Controls.Primitives.TextSearch.SetText(Avalonia.Controls.Control,System.String)
baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
+ CP0002
+ M:Avalonia.Controls.Primitives.TextSearch.SetText(Avalonia.Interactivity.Interactive,System.String)
+ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
+
+ CP0002
+ M:Avalonia.Controls.Primitives.TextSearch.SetTextBinding(Avalonia.Interactivity.Interactive,Avalonia.Data.BindingBase)
+ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
CP0002
M:Avalonia.Controls.Primitives.ToggleButton.add_Checked(System.EventHandler{Avalonia.Interactivity.RoutedEventArgs})
@@ -2149,6 +2269,12 @@
baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
+ CP0002
+ M:Avalonia.Controls.Primitives.VisualLayerManager.get_IsPopup
+ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
CP0002
M:Avalonia.Controls.Primitives.VisualLayerManager.get_LightDismissOverlayLayer
@@ -2167,6 +2293,12 @@
baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
+ CP0002
+ M:Avalonia.Controls.Primitives.VisualLayerManager.set_IsPopup(System.Boolean)
+ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
CP0002
M:Avalonia.Controls.Screens.ScreenFromWindow(Avalonia.Platform.IWindowBaseImpl)
@@ -2473,6 +2605,18 @@
baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
+ CP0002
+ F:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.CrossAxisCancelThresholdProperty
+ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
+ current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
+
+ CP0002
+ F:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.EdgeSizeProperty
+ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
+ current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
CP0002
F:Avalonia.Input.HoldingState.Cancelled
@@ -2599,12 +2743,72 @@
baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
+ CP0002
+ M:Avalonia.Input.FocusManager.#ctor(Avalonia.Input.IInputElement)
+ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
+ current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
+
+ CP0002
+ M:Avalonia.Input.FocusManager.ClearFocus
+ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
+ current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
+
+ CP0002
+ M:Avalonia.Input.FocusManager.ClearFocusOnElementRemoved(Avalonia.Input.IInputElement,Avalonia.Visual)
+ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
+ current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
+
+ CP0002
+ M:Avalonia.Input.FocusManager.FindNextElement(Avalonia.Input.NavigationDirection)
+ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
+ current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
+
+ CP0002
+ M:Avalonia.Input.FocusManager.TryMoveFocus(Avalonia.Input.NavigationDirection)
+ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
+ current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
+
+ CP0002
+ M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.get_CrossAxisCancelThreshold
+ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
+ current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
+
+ CP0002
+ M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.get_EdgeSize
+ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
+ current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
+
+ CP0002
+ M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.set_CrossAxisCancelThreshold(System.Double)
+ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
+ current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
+
+ CP0002
+ M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.set_EdgeSize(System.Double)
+ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
+ current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
CP0002
M:Avalonia.Input.HoldingRoutedEventArgs.#ctor(Avalonia.Input.HoldingState,Avalonia.Point,Avalonia.Input.PointerType)
baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
+ CP0002
+ M:Avalonia.Input.IFocusManager.ClearFocus
+ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
+ current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
CP0002
M:Avalonia.Input.IInputRoot.get_KeyboardNavigationHandler
@@ -2857,6 +3061,18 @@
baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
+ CP0002
+ M:Avalonia.Input.SwipeGestureEventArgs.#ctor(System.Int32,Avalonia.Input.SwipeDirection,Avalonia.Vector,Avalonia.Point)
+ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
+ current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
+
+ CP0002
+ M:Avalonia.Input.SwipeGestureEventArgs.get_StartPoint
+ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
+ current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
CP0002
M:Avalonia.Input.TextInput.TextInputMethodClient.ShowInputPanel
@@ -3571,12 +3787,36 @@
baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
+ CP0002
+ M:Avalonia.Controls.Primitives.TextSearch.GetText(Avalonia.Interactivity.Interactive)
+ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
+
+ CP0002
+ M:Avalonia.Controls.Primitives.TextSearch.GetTextBinding(Avalonia.Interactivity.Interactive)
+ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
CP0002
M:Avalonia.Controls.Primitives.TextSearch.SetText(Avalonia.Controls.Control,System.String)
baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
+ CP0002
+ M:Avalonia.Controls.Primitives.TextSearch.SetText(Avalonia.Interactivity.Interactive,System.String)
+ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
+
+ CP0002
+ M:Avalonia.Controls.Primitives.TextSearch.SetTextBinding(Avalonia.Interactivity.Interactive,Avalonia.Data.BindingBase)
+ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
CP0002
M:Avalonia.Controls.Primitives.ToggleButton.add_Checked(System.EventHandler{Avalonia.Interactivity.RoutedEventArgs})
@@ -3643,6 +3883,12 @@
baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
+ CP0002
+ M:Avalonia.Controls.Primitives.VisualLayerManager.get_IsPopup
+ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
CP0002
M:Avalonia.Controls.Primitives.VisualLayerManager.get_LightDismissOverlayLayer
@@ -3661,6 +3907,12 @@
baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
+ CP0002
+ M:Avalonia.Controls.Primitives.VisualLayerManager.set_IsPopup(System.Boolean)
+ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
CP0002
M:Avalonia.Controls.Screens.ScreenFromWindow(Avalonia.Platform.IWindowBaseImpl)
@@ -4015,6 +4267,36 @@
baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+ CP0006
+ M:Avalonia.Input.IFocusManager.FindFirstFocusableElement
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+
+ CP0006
+ M:Avalonia.Input.IFocusManager.FindLastFocusableElement
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+
+ CP0006
+ M:Avalonia.Input.IFocusManager.FindNextElement(Avalonia.Input.NavigationDirection,Avalonia.Input.FindNextElementOptions)
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+
+ CP0006
+ M:Avalonia.Input.IFocusManager.Focus(Avalonia.Input.IInputElement,Avalonia.Input.NavigationMethod,Avalonia.Input.KeyModifiers)
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+
+ CP0006
+ M:Avalonia.Input.IFocusManager.TryMoveFocus(Avalonia.Input.NavigationDirection,Avalonia.Input.FindNextElementOptions)
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
CP0006
M:Avalonia.Input.IKeyboardNavigationHandler.Move(Avalonia.Input.IInputElement,Avalonia.Input.NavigationDirection,Avalonia.Input.KeyModifiers,System.Nullable{Avalonia.Input.KeyDeviceType})
@@ -4303,6 +4585,36 @@
baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
+ CP0006
+ M:Avalonia.Input.IFocusManager.FindFirstFocusableElement
+ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
+ current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
+
+ CP0006
+ M:Avalonia.Input.IFocusManager.FindLastFocusableElement
+ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
+ current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
+
+ CP0006
+ M:Avalonia.Input.IFocusManager.FindNextElement(Avalonia.Input.NavigationDirection,Avalonia.Input.FindNextElementOptions)
+ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
+ current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
+
+ CP0006
+ M:Avalonia.Input.IFocusManager.Focus(Avalonia.Input.IInputElement,Avalonia.Input.NavigationMethod,Avalonia.Input.KeyModifiers)
+ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
+ current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
+
+ CP0006
+ M:Avalonia.Input.IFocusManager.TryMoveFocus(Avalonia.Input.NavigationDirection,Avalonia.Input.FindNextElementOptions)
+ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
+ current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
CP0006
M:Avalonia.Input.IKeyboardNavigationHandler.Move(Avalonia.Input.IInputElement,Avalonia.Input.NavigationDirection,Avalonia.Input.KeyModifiers,System.Nullable{Avalonia.Input.KeyDeviceType})
diff --git a/global.json b/global.json
index 3773c7d736..f6ed3dfdfb 100644
--- a/global.json
+++ b/global.json
@@ -1,6 +1,6 @@
{
"sdk": {
- "version": "10.0.101",
+ "version": "10.0.201",
"rollForward": "latestFeature"
},
"test": {
diff --git a/samples/ControlCatalog/MainView.xaml b/samples/ControlCatalog/MainView.xaml
index b6249fe17f..2a0de7a114 100644
--- a/samples/ControlCatalog/MainView.xaml
+++ b/samples/ControlCatalog/MainView.xaml
@@ -54,8 +54,10 @@
ScrollViewer.VerticalScrollBarVisibility="Disabled">
-
-
+
+
diff --git a/samples/ControlCatalog/Pages/CarouselDemoPage.xaml b/samples/ControlCatalog/Pages/CarouselDemoPage.xaml
new file mode 100644
index 0000000000..df4317fcad
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselDemoPage.xaml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/CarouselDemoPage.xaml.cs b/samples/ControlCatalog/Pages/CarouselDemoPage.xaml.cs
new file mode 100644
index 0000000000..64753b9fc4
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselDemoPage.xaml.cs
@@ -0,0 +1,53 @@
+using System;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+
+namespace ControlCatalog.Pages
+{
+ public partial class CarouselDemoPage : UserControl
+ {
+ private static readonly (string Group, string Title, string Description, Func Factory)[] Demos =
+ {
+ // Overview
+ ("Overview", "Getting Started",
+ "Basic Carousel with image items and previous/next navigation buttons.",
+ () => new CarouselGettingStartedPage()),
+
+ // Features
+ ("Features", "Transitions",
+ "Configure page transitions: PageSlide, CrossFade, 3D Rotation, or None.",
+ () => new CarouselTransitionsPage()),
+ ("Features", "Customization",
+ "Adjust orientation and transition type to tailor the carousel layout.",
+ () => new CarouselCustomizationPage()),
+ ("Features", "Gestures & Keyboard",
+ "Navigate items via swipe gesture and arrow keys. Toggle each input mode on and off.",
+ () => new CarouselGesturesPage()),
+ ("Features", "Vertical Orientation",
+ "Carousel with Orientation set to Vertical, navigated with Up/Down keys, swipe, or buttons.",
+ () => new CarouselVerticalPage()),
+ ("Features", "Multi-Item Peek",
+ "Adjust ViewportFraction to show multiple items simultaneously with adjacent cards peeking.",
+ () => new CarouselMultiItemPage()),
+ ("Features", "Data Binding",
+ "Bind Carousel to an ObservableCollection and add, remove, or shuffle items at runtime.",
+ () => new CarouselDataBindingPage()),
+
+ // Showcases
+ ("Showcases", "Curated Gallery",
+ "Editorial art gallery app with DrawerPage navigation, hero Carousel with PipsPager dots, and a horizontal peek carousel for collection highlights.",
+ () => new CarouselGalleryAppPage()),
+ };
+
+ public CarouselDemoPage()
+ {
+ InitializeComponent();
+ Loaded += OnLoaded;
+ }
+
+ private async void OnLoaded(object? sender, RoutedEventArgs e)
+ {
+ await SampleNav.PushAsync(NavigationDemoHelper.CreateGalleryHomePage(SampleNav, Demos), null);
+ }
+ }
+}
diff --git a/samples/ControlCatalog/Pages/CarouselPage.xaml b/samples/ControlCatalog/Pages/CarouselPage.xaml
index 352fa32e30..c6e20fec5b 100644
--- a/samples/ControlCatalog/Pages/CarouselPage.xaml
+++ b/samples/ControlCatalog/Pages/CarouselPage.xaml
@@ -1,44 +1,117 @@
-
- An items control that displays its items as pages that fill the control.
+
+ A swipeable items control that can reveal adjacent pages with ViewportFraction.
-
-
-
- Transition
-
+
+
+
+ Transition
+
None
- Slide
- Crossfade
- 3D Rotation
+ Page Slide
+ Cross Fade
+ Rotate 3D
+ Card Stack
+ Wave Reveal
+ Composite (Slide + Fade)
-
-
- Orientation
-
+ Orientation
+
Horizontal
Vertical
+
+ Viewport Fraction
+
+
+
+ 1.00
+
+
+
+
+
+
+
+
+ Wrap Selection
+ Swipe Enabled
+
+
+
+
+
+
+
+ Total Items:
+ 0
+
+
+ Selected Index:
+ 0
+
+
diff --git a/samples/ControlCatalog/Pages/CarouselPage.xaml.cs b/samples/ControlCatalog/Pages/CarouselPage.xaml.cs
index 713da34051..0a0c973b90 100644
--- a/samples/ControlCatalog/Pages/CarouselPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/CarouselPage.xaml.cs
@@ -1,6 +1,9 @@
using System;
+using Avalonia;
using Avalonia.Animation;
using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using ControlCatalog.Pages.Transitions;
namespace ControlCatalog.Pages
{
@@ -9,28 +12,137 @@ namespace ControlCatalog.Pages
public CarouselPage()
{
InitializeComponent();
+
left.Click += (s, e) => carousel.Previous();
right.Click += (s, e) => carousel.Next();
transition.SelectionChanged += TransitionChanged;
orientation.SelectionChanged += TransitionChanged;
+ viewportFraction.ValueChanged += ViewportFractionChanged;
+
+ wrapSelection.IsChecked = carousel.WrapSelection;
+ wrapSelection.IsCheckedChanged += (s, e) =>
+ {
+ carousel.WrapSelection = wrapSelection.IsChecked ?? false;
+ UpdateButtonState();
+ };
+
+ swipeEnabled.IsChecked = carousel.IsSwipeEnabled;
+ swipeEnabled.IsCheckedChanged += (s, e) =>
+ {
+ carousel.IsSwipeEnabled = swipeEnabled.IsChecked ?? false;
+ };
+
+ carousel.PropertyChanged += (s, e) =>
+ {
+ if (e.Property == SelectingItemsControl.SelectedIndexProperty)
+ {
+ UpdateButtonState();
+ }
+ else if (e.Property == Carousel.ViewportFractionProperty)
+ {
+ UpdateViewportFractionDisplay();
+ }
+ };
+
+ carousel.ViewportFraction = viewportFraction.Value;
+ UpdateButtonState();
+ UpdateViewportFractionDisplay();
+ }
+
+ private void UpdateButtonState()
+ {
+ itemsCountIndicator.Text = carousel.ItemCount.ToString();
+ selectedIndexIndicator.Text = carousel.SelectedIndex.ToString();
+
+ var wrap = carousel.WrapSelection;
+ left.IsEnabled = wrap || carousel.SelectedIndex > 0;
+ right.IsEnabled = wrap || carousel.SelectedIndex < carousel.ItemCount - 1;
+ }
+
+ private void ViewportFractionChanged(object? sender, RangeBaseValueChangedEventArgs e)
+ {
+ carousel.ViewportFraction = Math.Round(e.NewValue, 2);
+ UpdateViewportFractionDisplay();
+ }
+
+ private void UpdateViewportFractionDisplay()
+ {
+ var value = carousel.ViewportFraction;
+ viewportFractionIndicator.Text = value.ToString("0.00");
+
+ var pagesInView = 1d / value;
+ viewportFractionHint.Text = value >= 1d
+ ? "1.00 shows a single full page."
+ : $"{pagesInView:0.##} pages fit in view. Try 0.80 for peeking or 0.33 for three full items.";
}
private void TransitionChanged(object? sender, SelectionChangedEventArgs e)
{
+ var isVertical = orientation.SelectedIndex == 1;
+ var axis = isVertical ? PageSlide.SlideAxis.Vertical : PageSlide.SlideAxis.Horizontal;
+
switch (transition.SelectedIndex)
{
case 0:
carousel.PageTransition = null;
break;
case 1:
- carousel.PageTransition = new PageSlide(TimeSpan.FromSeconds(0.25), orientation.SelectedIndex == 0 ? PageSlide.SlideAxis.Horizontal : PageSlide.SlideAxis.Vertical);
+ carousel.PageTransition = new PageSlide(TimeSpan.FromSeconds(0.25), axis);
break;
case 2:
carousel.PageTransition = new CrossFade(TimeSpan.FromSeconds(0.25));
break;
case 3:
- carousel.PageTransition = new Rotate3DTransition(TimeSpan.FromSeconds(0.5), orientation.SelectedIndex == 0 ? PageSlide.SlideAxis.Horizontal : PageSlide.SlideAxis.Vertical);
+ carousel.PageTransition = new Rotate3DTransition(TimeSpan.FromSeconds(0.5), axis);
+ break;
+ case 4:
+ carousel.PageTransition = new CardStackPageTransition(TimeSpan.FromSeconds(0.5), axis);
+ break;
+ case 5:
+ carousel.PageTransition = new WaveRevealPageTransition(TimeSpan.FromSeconds(0.8), axis);
break;
+ case 6:
+ carousel.PageTransition = new CompositePageTransition
+ {
+ PageTransitions =
+ {
+ new PageSlide(TimeSpan.FromSeconds(0.25), axis),
+ new CrossFade(TimeSpan.FromSeconds(0.25)),
+ }
+ };
+ break;
+ }
+
+ UpdateLayoutForOrientation(isVertical);
+ }
+
+ private void UpdateLayoutForOrientation(bool isVertical)
+ {
+ if (isVertical)
+ {
+ Grid.SetColumn(left, 1);
+ Grid.SetRow(left, 0);
+ Grid.SetColumn(right, 1);
+ Grid.SetRow(right, 2);
+
+ left.Padding = new Thickness(20, 10);
+ right.Padding = new Thickness(20, 10);
+
+ leftArrow.RenderTransform = new Avalonia.Media.RotateTransform(90);
+ rightArrow.RenderTransform = new Avalonia.Media.RotateTransform(90);
+ }
+ else
+ {
+ Grid.SetColumn(left, 0);
+ Grid.SetRow(left, 1);
+ Grid.SetColumn(right, 2);
+ Grid.SetRow(right, 1);
+
+ left.Padding = new Thickness(10, 20);
+ right.Padding = new Thickness(10, 20);
+
+ leftArrow.RenderTransform = null;
+ rightArrow.RenderTransform = null;
}
}
}
diff --git a/samples/ControlCatalog/Pages/CarouselPage/CarouselCustomizationPage.xaml b/samples/ControlCatalog/Pages/CarouselPage/CarouselCustomizationPage.xaml
new file mode 100644
index 0000000000..add442e7a1
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselPage/CarouselCustomizationPage.xaml
@@ -0,0 +1,119 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Horizontal
+ Vertical
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/CarouselPage/CarouselCustomizationPage.xaml.cs b/samples/ControlCatalog/Pages/CarouselPage/CarouselCustomizationPage.xaml.cs
new file mode 100644
index 0000000000..c3b919d65d
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselPage/CarouselCustomizationPage.xaml.cs
@@ -0,0 +1,48 @@
+using System;
+using Avalonia.Animation;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Interactivity;
+
+namespace ControlCatalog.Pages
+{
+ public partial class CarouselCustomizationPage : UserControl
+ {
+ public CarouselCustomizationPage()
+ {
+ InitializeComponent();
+ PreviousButton.Click += (_, _) => DemoCarousel.Previous();
+ NextButton.Click += (_, _) => DemoCarousel.Next();
+ OrientationCombo.SelectionChanged += (_, _) => ApplyOrientation();
+ ViewportSlider.ValueChanged += OnViewportFractionChanged;
+ }
+
+ private void ApplyOrientation()
+ {
+ var horizontal = OrientationCombo.SelectedIndex == 0;
+ var axis = horizontal ? PageSlide.SlideAxis.Horizontal : PageSlide.SlideAxis.Vertical;
+ DemoCarousel.PageTransition = new PageSlide(TimeSpan.FromSeconds(0.25), axis);
+ StatusText.Text = $"Orientation: {(horizontal ? "Horizontal" : "Vertical")}";
+ }
+
+ private void OnViewportFractionChanged(object? sender, RangeBaseValueChangedEventArgs e)
+ {
+ var value = Math.Round(e.NewValue, 2);
+ DemoCarousel.ViewportFraction = value;
+ ViewportLabel.Text = value.ToString("0.00");
+ ViewportHint.Text = value >= 1d ?
+ "1.00 shows a single full page." :
+ $"{1d / value:0.##} pages fit in view. Try 0.80 for peeking.";
+ }
+
+ private void OnWrapSelectionChanged(object? sender, RoutedEventArgs e)
+ {
+ DemoCarousel.WrapSelection = WrapSelectionCheck.IsChecked == true;
+ }
+
+ private void OnSwipeEnabledChanged(object? sender, RoutedEventArgs e)
+ {
+ DemoCarousel.IsSwipeEnabled = SwipeEnabledCheck.IsChecked == true;
+ }
+ }
+}
diff --git a/samples/ControlCatalog/Pages/CarouselPage/CarouselDataBindingPage.xaml b/samples/ControlCatalog/Pages/CarouselPage/CarouselDataBindingPage.xaml
new file mode 100644
index 0000000000..fcb7e96f52
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselPage/CarouselDataBindingPage.xaml
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/CarouselPage/CarouselDataBindingPage.xaml.cs b/samples/ControlCatalog/Pages/CarouselPage/CarouselDataBindingPage.xaml.cs
new file mode 100644
index 0000000000..5a7b6e46da
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselPage/CarouselDataBindingPage.xaml.cs
@@ -0,0 +1,95 @@
+using System;
+using System.Collections.ObjectModel;
+using System.Linq;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Media;
+
+namespace ControlCatalog.Pages
+{
+ public class CarouselCardItem
+ {
+ public string Number { get; set; } = "";
+ public string Title { get; set; } = "";
+ public IBrush Background { get; set; } = Brushes.Gray;
+ public IBrush Accent { get; set; } = Brushes.White;
+ }
+
+ public partial class CarouselDataBindingPage : UserControl
+ {
+ private static readonly (string Title, string Color, string Accent)[] Palette =
+ {
+ ("Neon Pulse", "#3525CD", "#C3C0FF"), ("Ephemeral Blue", "#0891B2", "#BAF0FA"),
+ ("Forest Forms", "#059669", "#A7F3D0"), ("Golden Hour", "#D97706", "#FDE68A"),
+ ("Crimson Wave", "#BE185D", "#FBCFE8"), ("Stone Age", "#57534E", "#D6D3D1"),
+ };
+
+ private readonly ObservableCollection _items = new();
+ private int _addCounter;
+
+ public CarouselDataBindingPage()
+ {
+ InitializeComponent();
+ DemoCarousel.ItemsSource = _items;
+ DemoCarousel.SelectionChanged += OnSelectionChanged;
+
+ for (var i = 0; i < 4; i++)
+ AppendItem();
+
+ PreviousButton.Click += (_, _) => DemoCarousel.Previous();
+ NextButton.Click += (_, _) => DemoCarousel.Next();
+ AddButton.Click += OnAddItem;
+ RemoveButton.Click += OnRemoveCurrent;
+ ShuffleButton.Click += OnShuffle;
+ UpdateStatus();
+ }
+
+ private void AppendItem()
+ {
+ var (title, color, accent) = Palette[_addCounter % Palette.Length];
+ _items.Add(new CarouselCardItem
+ {
+ Number = $"{_items.Count + 1:D2}",
+ Title = title,
+ Background = new SolidColorBrush(Color.Parse(color)),
+ Accent = new SolidColorBrush(Color.Parse(accent)),
+ });
+ _addCounter++;
+ }
+
+ private void OnAddItem(object? sender, RoutedEventArgs e)
+ {
+ AppendItem();
+ UpdateStatus();
+ }
+
+ private void OnRemoveCurrent(object? sender, RoutedEventArgs e)
+ {
+ if (_items.Count == 0)
+ return;
+ var idx = Math.Clamp(DemoCarousel.SelectedIndex, 0, _items.Count - 1);
+ _items.RemoveAt(idx);
+ UpdateStatus();
+ }
+
+ private void OnShuffle(object? sender, RoutedEventArgs e)
+ {
+ var rng = new Random();
+ var shuffled = _items.OrderBy(_ => rng.Next()).ToList();
+ _items.Clear();
+ foreach (var item in shuffled)
+ _items.Add(item);
+ UpdateStatus();
+ }
+
+ private void OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ UpdateStatus();
+ }
+
+ private void UpdateStatus()
+ {
+ StatusText.Text = $"Item: {DemoCarousel.SelectedIndex + 1} / {_items.Count}";
+ }
+ }
+}
diff --git a/samples/ControlCatalog/Pages/CarouselPage/CarouselGalleryAppPage.xaml b/samples/ControlCatalog/Pages/CarouselPage/CarouselGalleryAppPage.xaml
new file mode 100644
index 0000000000..9c7c8e0eab
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselPage/CarouselGalleryAppPage.xaml
@@ -0,0 +1,557 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/CarouselPage/CarouselGalleryAppPage.xaml.cs b/samples/ControlCatalog/Pages/CarouselPage/CarouselGalleryAppPage.xaml.cs
new file mode 100644
index 0000000000..5c637b5454
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselPage/CarouselGalleryAppPage.xaml.cs
@@ -0,0 +1,101 @@
+using System;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+
+namespace ControlCatalog.Pages
+{
+ public partial class CarouselGalleryAppPage : UserControl
+ {
+ private bool _syncing;
+ private Point _dragStart;
+ private bool _isDragging;
+ private const double SwipeThreshold = 50;
+
+ private ScrollViewer? _infoPanel;
+
+ public CarouselGalleryAppPage()
+ {
+ InitializeComponent();
+ _infoPanel = this.FindControl("InfoPanel");
+ HeroCarousel.SelectionChanged += OnHeroSelectionChanged;
+ HeroPager.SelectedIndexChanged += OnPagerIndexChanged;
+ }
+
+ protected override void OnLoaded(RoutedEventArgs e)
+ {
+ base.OnLoaded(e);
+ UpdateInfoPanelVisibility();
+ }
+
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+ if (change.Property == BoundsProperty)
+ UpdateInfoPanelVisibility();
+ }
+
+ private void UpdateInfoPanelVisibility()
+ {
+ if (_infoPanel != null)
+ _infoPanel.IsVisible = Bounds.Width >= 640;
+ }
+
+ private void OnHamburgerClick(object? sender, RoutedEventArgs e)
+ {
+ RootDrawer.IsOpen = !RootDrawer.IsOpen;
+ }
+
+ private void OnHeroSelectionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ if (_syncing)
+ return;
+ _syncing = true;
+ HeroPager.SelectedPageIndex = HeroCarousel.SelectedIndex;
+ _syncing = false;
+ }
+
+ private void OnPagerIndexChanged(object? sender, PipsPagerSelectedIndexChangedEventArgs e)
+ {
+ if (_syncing)
+ return;
+ _syncing = true;
+ HeroCarousel.SelectedIndex = e.NewIndex;
+ _syncing = false;
+ }
+
+ private void OnDrawerMenuSelectionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ RootDrawer.IsOpen = false;
+ DrawerMenu.SelectedItem = null;
+ }
+
+ private void OnHeroPointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ if (!e.GetCurrentPoint(null).Properties.IsLeftButtonPressed)
+ return;
+ _dragStart = e.GetPosition((Visual?)sender);
+ _isDragging = true;
+ }
+
+ private void OnHeroPointerReleased(object? sender, PointerReleasedEventArgs e)
+ {
+ if (!_isDragging)
+ return;
+ _isDragging = false;
+ var delta = e.GetPosition((Visual?)sender).X - _dragStart.X;
+ if (Math.Abs(delta) < SwipeThreshold)
+ return;
+ if (delta < 0)
+ HeroCarousel.Next();
+ else
+ HeroCarousel.Previous();
+ }
+
+ private void OnHeroPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e)
+ {
+ _isDragging = false;
+ }
+ }
+}
diff --git a/samples/ControlCatalog/Pages/CarouselPage/CarouselGesturesPage.xaml b/samples/ControlCatalog/Pages/CarouselPage/CarouselGesturesPage.xaml
new file mode 100644
index 0000000000..7786168d4a
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselPage/CarouselGesturesPage.xaml
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/CarouselPage/CarouselGesturesPage.xaml.cs b/samples/ControlCatalog/Pages/CarouselPage/CarouselGesturesPage.xaml.cs
new file mode 100644
index 0000000000..097dd54966
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselPage/CarouselGesturesPage.xaml.cs
@@ -0,0 +1,59 @@
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+
+namespace ControlCatalog.Pages
+{
+ public partial class CarouselGesturesPage : UserControl
+ {
+ private bool _keyboardEnabled = true;
+
+ public CarouselGesturesPage()
+ {
+ InitializeComponent();
+ DemoCarousel.AddHandler(InputElement.KeyDownEvent, OnKeyDown, handledEventsToo: true);
+ DemoCarousel.SelectionChanged += OnSelectionChanged;
+ DemoCarousel.Loaded += (_, _) => DemoCarousel.Focus();
+ }
+
+ private void OnKeyDown(object? sender, KeyEventArgs e)
+ {
+ if (!_keyboardEnabled)
+ return;
+
+ switch (e.Key)
+ {
+ case Key.Left:
+ case Key.Up:
+ LastActionText.Text = $"Action: Key {e.Key} (Previous)";
+ break;
+ case Key.Right:
+ case Key.Down:
+ LastActionText.Text = $"Action: Key {e.Key} (Next)";
+ break;
+ }
+ }
+
+ private void OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ StatusText.Text = $"Item: {DemoCarousel.SelectedIndex + 1} / {DemoCarousel.ItemCount}";
+ if (DemoCarousel.IsSwiping)
+ LastActionText.Text = "Action: Swipe";
+ }
+
+ private void OnSwipeEnabledChanged(object? sender, RoutedEventArgs e)
+ {
+ DemoCarousel.IsSwipeEnabled = SwipeCheck.IsChecked == true;
+ }
+
+ private void OnWrapSelectionChanged(object? sender, RoutedEventArgs e)
+ {
+ DemoCarousel.WrapSelection = WrapCheck.IsChecked == true;
+ }
+
+ private void OnKeyboardEnabledChanged(object? sender, RoutedEventArgs e)
+ {
+ _keyboardEnabled = KeyboardCheck.IsChecked == true;
+ }
+ }
+}
diff --git a/samples/ControlCatalog/Pages/CarouselPage/CarouselGettingStartedPage.xaml b/samples/ControlCatalog/Pages/CarouselPage/CarouselGettingStartedPage.xaml
new file mode 100644
index 0000000000..680a65f204
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselPage/CarouselGettingStartedPage.xaml
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/CarouselPage/CarouselGettingStartedPage.xaml.cs b/samples/ControlCatalog/Pages/CarouselPage/CarouselGettingStartedPage.xaml.cs
new file mode 100644
index 0000000000..61aad36df3
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselPage/CarouselGettingStartedPage.xaml.cs
@@ -0,0 +1,40 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+
+namespace ControlCatalog.Pages
+{
+ public partial class CarouselGettingStartedPage : UserControl
+ {
+ public CarouselGettingStartedPage()
+ {
+ InitializeComponent();
+ PreviousButton.Click += OnPrevious;
+ NextButton.Click += OnNext;
+ DemoCarousel.SelectionChanged += OnSelectionChanged;
+ }
+
+ private void OnPrevious(object? sender, RoutedEventArgs e)
+ {
+ DemoCarousel.Previous();
+ UpdateStatus();
+ }
+
+ private void OnNext(object? sender, RoutedEventArgs e)
+ {
+ DemoCarousel.Next();
+ UpdateStatus();
+ }
+
+ private void OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ UpdateStatus();
+ }
+
+ private void UpdateStatus()
+ {
+ var index = DemoCarousel.SelectedIndex + 1;
+ var count = DemoCarousel.ItemCount;
+ StatusText.Text = $"Item: {index} / {count}";
+ }
+ }
+}
diff --git a/samples/ControlCatalog/Pages/CarouselPage/CarouselMultiItemPage.xaml b/samples/ControlCatalog/Pages/CarouselPage/CarouselMultiItemPage.xaml
new file mode 100644
index 0000000000..23e60f35f2
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselPage/CarouselMultiItemPage.xaml
@@ -0,0 +1,140 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/CarouselPage/CarouselMultiItemPage.xaml.cs b/samples/ControlCatalog/Pages/CarouselPage/CarouselMultiItemPage.xaml.cs
new file mode 100644
index 0000000000..13182e7654
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselPage/CarouselMultiItemPage.xaml.cs
@@ -0,0 +1,47 @@
+using System;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Interactivity;
+
+namespace ControlCatalog.Pages
+{
+ public partial class CarouselMultiItemPage : UserControl
+ {
+ public CarouselMultiItemPage()
+ {
+ InitializeComponent();
+ PreviousButton.Click += (_, _) => DemoCarousel.Previous();
+ NextButton.Click += (_, _) => DemoCarousel.Next();
+ DemoCarousel.SelectionChanged += OnSelectionChanged;
+ }
+
+ private void OnViewportFractionChanged(object? sender, RangeBaseValueChangedEventArgs e)
+ {
+ if (DemoCarousel is null)
+ return;
+ var value = Math.Round(e.NewValue, 2);
+ DemoCarousel.ViewportFraction = value;
+ ViewportLabel.Text = value.ToString("0.00");
+ ViewportHint.Text = value >= 1d ? "1.00 — single full item." : $"~{1d / value:0.#} items visible.";
+ }
+
+ private void OnWrapChanged(object? sender, RoutedEventArgs e)
+ {
+ if (DemoCarousel is null)
+ return;
+ DemoCarousel.WrapSelection = WrapCheck.IsChecked == true;
+ }
+
+ private void OnSwipeChanged(object? sender, RoutedEventArgs e)
+ {
+ if (DemoCarousel is null)
+ return;
+ DemoCarousel.IsSwipeEnabled = SwipeCheck.IsChecked == true;
+ }
+
+ private void OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ StatusText.Text = $"Item: {DemoCarousel.SelectedIndex + 1} / {DemoCarousel.ItemCount}";
+ }
+ }
+}
diff --git a/samples/ControlCatalog/Pages/CarouselPage/CarouselTransitionsPage.xaml b/samples/ControlCatalog/Pages/CarouselPage/CarouselTransitionsPage.xaml
new file mode 100644
index 0000000000..b04ea78ed2
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselPage/CarouselTransitionsPage.xaml
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ None
+ Page Slide
+ Cross Fade
+ Rotate 3D
+ Card Stack
+ Wave Reveal
+ Composite (Slide + Fade)
+
+
+
+
+ Horizontal
+ Vertical
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/CarouselPage/CarouselTransitionsPage.xaml.cs b/samples/ControlCatalog/Pages/CarouselPage/CarouselTransitionsPage.xaml.cs
new file mode 100644
index 0000000000..2d69ecd4af
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselPage/CarouselTransitionsPage.xaml.cs
@@ -0,0 +1,66 @@
+using System;
+using Avalonia.Animation;
+using Avalonia.Controls;
+using ControlCatalog.Pages.Transitions;
+
+namespace ControlCatalog.Pages
+{
+ public partial class CarouselTransitionsPage : UserControl
+ {
+ public CarouselTransitionsPage()
+ {
+ InitializeComponent();
+ PreviousButton.Click += (_, _) => DemoCarousel.Previous();
+ NextButton.Click += (_, _) => DemoCarousel.Next();
+ TransitionCombo.SelectionChanged += (_, _) => ApplyTransition();
+ OrientationCombo.SelectionChanged += (_, _) => ApplyTransition();
+ }
+
+ private void ApplyTransition()
+ {
+ var axis = OrientationCombo.SelectedIndex == 0 ?
+ PageSlide.SlideAxis.Horizontal :
+ PageSlide.SlideAxis.Vertical;
+ var label = axis == PageSlide.SlideAxis.Horizontal ? "Horizontal" : "Vertical";
+
+ switch (TransitionCombo.SelectedIndex)
+ {
+ case 0:
+ DemoCarousel.PageTransition = null;
+ StatusText.Text = "Transition: None";
+ break;
+ case 1:
+ DemoCarousel.PageTransition = new PageSlide(TimeSpan.FromSeconds(0.25), axis);
+ StatusText.Text = $"Transition: Page Slide ({label})";
+ break;
+ case 2:
+ DemoCarousel.PageTransition = new CrossFade(TimeSpan.FromSeconds(0.25));
+ StatusText.Text = "Transition: Cross Fade";
+ break;
+ case 3:
+ DemoCarousel.PageTransition = new Rotate3DTransition(TimeSpan.FromSeconds(0.5), axis);
+ StatusText.Text = $"Transition: Rotate 3D ({label})";
+ break;
+ case 4:
+ DemoCarousel.PageTransition = new CardStackPageTransition(TimeSpan.FromSeconds(0.5), axis);
+ StatusText.Text = $"Transition: Card Stack ({label})";
+ break;
+ case 5:
+ DemoCarousel.PageTransition = new WaveRevealPageTransition(TimeSpan.FromSeconds(0.8), axis);
+ StatusText.Text = $"Transition: Wave Reveal ({label})";
+ break;
+ case 6:
+ DemoCarousel.PageTransition = new CompositePageTransition
+ {
+ PageTransitions =
+ {
+ new PageSlide(TimeSpan.FromSeconds(0.25), axis),
+ new CrossFade(TimeSpan.FromSeconds(0.25)),
+ }
+ };
+ StatusText.Text = "Transition: Composite (Slide + Fade)";
+ break;
+ }
+ }
+ }
+}
diff --git a/samples/ControlCatalog/Pages/CarouselPage/CarouselVerticalPage.xaml b/samples/ControlCatalog/Pages/CarouselPage/CarouselVerticalPage.xaml
new file mode 100644
index 0000000000..f916e4f526
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselPage/CarouselVerticalPage.xaml
@@ -0,0 +1,132 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PageSlide
+ CrossFade
+ None
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/CarouselPage/CarouselVerticalPage.xaml.cs b/samples/ControlCatalog/Pages/CarouselPage/CarouselVerticalPage.xaml.cs
new file mode 100644
index 0000000000..ac964047c0
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselPage/CarouselVerticalPage.xaml.cs
@@ -0,0 +1,39 @@
+using Avalonia.Animation;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+
+namespace ControlCatalog.Pages
+{
+ public partial class CarouselVerticalPage : UserControl
+ {
+ public CarouselVerticalPage()
+ {
+ InitializeComponent();
+ PreviousButton.Click += (_, _) => DemoCarousel.Previous();
+ NextButton.Click += (_, _) => DemoCarousel.Next();
+ DemoCarousel.SelectionChanged += OnSelectionChanged;
+ TransitionCombo.SelectionChanged += OnTransitionChanged;
+ DemoCarousel.Loaded += (_, _) => DemoCarousel.Focus();
+ }
+
+ private void OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ StatusText.Text = $"Item: {DemoCarousel.SelectedIndex + 1} / {DemoCarousel.ItemCount}";
+ }
+
+ private void OnTransitionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ DemoCarousel.PageTransition = TransitionCombo.SelectedIndex switch
+ {
+ 1 => new CrossFade(System.TimeSpan.FromSeconds(0.3)),
+ 2 => null,
+ _ => new PageSlide(System.TimeSpan.FromSeconds(0.3), PageSlide.SlideAxis.Vertical),
+ };
+ }
+
+ private void OnWrapSelectionChanged(object? sender, RoutedEventArgs e)
+ {
+ DemoCarousel.WrapSelection = WrapCheck.IsChecked == true;
+ }
+ }
+}
diff --git a/samples/ControlCatalog/Pages/DrawerPage/DrawerPageCustomizationPage.xaml.cs b/samples/ControlCatalog/Pages/DrawerPage/DrawerPageCustomizationPage.xaml.cs
index 697e67f0f4..243bc5868b 100644
--- a/samples/ControlCatalog/Pages/DrawerPage/DrawerPageCustomizationPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/DrawerPage/DrawerPageCustomizationPage.xaml.cs
@@ -1,5 +1,7 @@
+using System.Linq;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
+using Avalonia.Input.GestureRecognizers;
using Avalonia.Interactivity;
using Avalonia.Media;
@@ -22,6 +24,7 @@ namespace ControlCatalog.Pages
public DrawerPageCustomizationPage()
{
InitializeComponent();
+ EnableMouseSwipeGesture(DemoDrawer);
}
protected override void OnLoaded(RoutedEventArgs e)
@@ -188,5 +191,15 @@ namespace ControlCatalog.Pages
if (DemoDrawer.DrawerBehavior != DrawerBehavior.Locked)
DemoDrawer.IsOpen = false;
}
+
+ private static void EnableMouseSwipeGesture(Control control)
+ {
+ var recognizer = control.GestureRecognizers
+ .OfType()
+ .FirstOrDefault();
+
+ if (recognizer is not null)
+ recognizer.IsMouseEnabled = true;
+ }
}
}
diff --git a/samples/ControlCatalog/Pages/DrawerPage/DrawerPageFirstLookPage.xaml.cs b/samples/ControlCatalog/Pages/DrawerPage/DrawerPageFirstLookPage.xaml.cs
index 58a981f640..de72957d73 100644
--- a/samples/ControlCatalog/Pages/DrawerPage/DrawerPageFirstLookPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/DrawerPage/DrawerPageFirstLookPage.xaml.cs
@@ -1,4 +1,6 @@
+using System.Linq;
using Avalonia.Controls;
+using Avalonia.Input.GestureRecognizers;
using Avalonia.Interactivity;
namespace ControlCatalog.Pages
@@ -8,6 +10,7 @@ namespace ControlCatalog.Pages
public DrawerPageFirstLookPage()
{
InitializeComponent();
+ EnableMouseSwipeGesture(DemoDrawer);
}
protected override void OnLoaded(RoutedEventArgs e)
@@ -61,5 +64,15 @@ namespace ControlCatalog.Pages
{
StatusText.Text = $"Drawer: {(DemoDrawer.IsOpen ? "Open" : "Closed")}";
}
+
+ private static void EnableMouseSwipeGesture(Control control)
+ {
+ var recognizer = control.GestureRecognizers
+ .OfType()
+ .FirstOrDefault();
+
+ if (recognizer is not null)
+ recognizer.IsMouseEnabled = true;
+ }
}
}
diff --git a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageGesturePage.xaml.cs b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageGesturePage.xaml.cs
index ff711f3a63..c18cfebc7e 100644
--- a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageGesturePage.xaml.cs
+++ b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageGesturePage.xaml.cs
@@ -1,4 +1,6 @@
+using System.Linq;
using Avalonia.Controls;
+using Avalonia.Input.GestureRecognizers;
using Avalonia.Interactivity;
namespace ControlCatalog.Pages
@@ -8,6 +10,7 @@ namespace ControlCatalog.Pages
public NavigationPageGesturePage()
{
InitializeComponent();
+ EnableMouseSwipeGesture(DemoNav);
Loaded += OnLoaded;
}
@@ -43,5 +46,15 @@ namespace ControlCatalog.Pages
{
StatusText.Text = $"Depth: {DemoNav.StackDepth}";
}
+
+ private static void EnableMouseSwipeGesture(Control control)
+ {
+ var recognizer = control.GestureRecognizers
+ .OfType()
+ .FirstOrDefault();
+
+ if (recognizer is not null)
+ recognizer.IsMouseEnabled = true;
+ }
}
}
diff --git a/samples/ControlCatalog/Pages/TabbedPage/TabbedPageGesturePage.xaml.cs b/samples/ControlCatalog/Pages/TabbedPage/TabbedPageGesturePage.xaml.cs
index bee2c43efd..e17ebc5ed8 100644
--- a/samples/ControlCatalog/Pages/TabbedPage/TabbedPageGesturePage.xaml.cs
+++ b/samples/ControlCatalog/Pages/TabbedPage/TabbedPageGesturePage.xaml.cs
@@ -1,4 +1,6 @@
+using System.Linq;
using Avalonia.Controls;
+using Avalonia.Input.GestureRecognizers;
namespace ControlCatalog.Pages
{
@@ -7,6 +9,7 @@ namespace ControlCatalog.Pages
public TabbedPageGesturePage()
{
InitializeComponent();
+ EnableMouseSwipeGesture(DemoTabs);
}
private void OnGestureEnabledChanged(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
@@ -26,5 +29,15 @@ namespace ControlCatalog.Pages
_ => TabPlacement.Top
};
}
+
+ private static void EnableMouseSwipeGesture(Control control)
+ {
+ var recognizer = control.GestureRecognizers
+ .OfType()
+ .FirstOrDefault();
+
+ if (recognizer is not null)
+ recognizer.IsMouseEnabled = true;
+ }
}
}
diff --git a/samples/ControlCatalog/Pages/Transitions/CardStackPageTransition.cs b/samples/ControlCatalog/Pages/Transitions/CardStackPageTransition.cs
new file mode 100644
index 0000000000..89ae1e5e8a
--- /dev/null
+++ b/samples/ControlCatalog/Pages/Transitions/CardStackPageTransition.cs
@@ -0,0 +1,447 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Animation;
+using Avalonia.Animation.Easings;
+using Avalonia.Media;
+using Avalonia.Styling;
+
+namespace ControlCatalog.Pages.Transitions;
+
+///
+/// Transitions between two pages with a card-stack effect:
+/// the top page moves/rotates away while the next page scales up underneath.
+///
+public class CardStackPageTransition : PageSlide
+{
+ private const double ViewportLiftScale = 0.03;
+ private const double ViewportPromotionScale = 0.02;
+ private const double ViewportDepthOpacityFalloff = 0.08;
+ private const double SidePeekAngle = 4.0;
+ private const double FarPeekAngle = 7.0;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public CardStackPageTransition()
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The duration of the animation.
+ /// The axis on which the animation should occur.
+ public CardStackPageTransition(TimeSpan duration, PageSlide.SlideAxis orientation = PageSlide.SlideAxis.Horizontal)
+ : base(duration, orientation)
+ {
+ }
+
+ ///
+ /// Gets or sets the maximum rotation angle (degrees) applied to the top card.
+ ///
+ public double MaxSwipeAngle { get; set; } = 15.0;
+
+ ///
+ /// Gets or sets the scale reduction applied to the back card (0.05 = 5%).
+ ///
+ public double BackCardScale { get; set; } = 0.05;
+
+ ///
+ /// Gets or sets the vertical offset (pixels) applied to the back card.
+ ///
+ public double BackCardOffset { get; set; } = 0.0;
+
+ ///
+ public override async Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken)
+ {
+ if (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+
+ var tasks = new List();
+ var parent = GetVisualParent(from, to);
+ var distance = Orientation == PageSlide.SlideAxis.Horizontal ? parent.Bounds.Width : parent.Bounds.Height;
+ var translateProperty = Orientation == PageSlide.SlideAxis.Horizontal ? TranslateTransform.XProperty : TranslateTransform.YProperty;
+ var rotationTarget = Orientation == PageSlide.SlideAxis.Horizontal ? (forward ? -MaxSwipeAngle : MaxSwipeAngle) : 0.0;
+ var startScale = 1.0 - BackCardScale;
+
+ if (from != null)
+ {
+ var (rotate, translate) = EnsureTopTransforms(from);
+ rotate.Angle = 0;
+ translate.X = 0;
+ translate.Y = 0;
+ from.Opacity = 1;
+ from.ZIndex = 1;
+
+ var animation = new Animation
+ {
+ Easing = SlideOutEasing,
+ Duration = Duration,
+ FillMode = FillMode,
+ Children =
+ {
+ new KeyFrame
+ {
+ Setters =
+ {
+ new Setter(translateProperty, 0d),
+ new Setter(RotateTransform.AngleProperty, 0d)
+ },
+ Cue = new Cue(0d)
+ },
+ new KeyFrame
+ {
+ Setters =
+ {
+ new Setter(translateProperty, forward ? -distance : distance),
+ new Setter(RotateTransform.AngleProperty, rotationTarget)
+ },
+ Cue = new Cue(1d)
+ }
+ }
+ };
+ tasks.Add(animation.RunAsync(from, cancellationToken));
+ }
+
+ if (to != null)
+ {
+ var (scale, translate) = EnsureBackTransforms(to);
+ scale.ScaleX = startScale;
+ scale.ScaleY = startScale;
+ translate.X = 0;
+ translate.Y = BackCardOffset;
+ to.IsVisible = true;
+ to.Opacity = 1;
+ to.ZIndex = 0;
+
+ var animation = new Animation
+ {
+ Easing = SlideInEasing,
+ Duration = Duration,
+ FillMode = FillMode,
+ Children =
+ {
+ new KeyFrame
+ {
+ Setters =
+ {
+ new Setter(ScaleTransform.ScaleXProperty, startScale),
+ new Setter(ScaleTransform.ScaleYProperty, startScale),
+ new Setter(TranslateTransform.YProperty, BackCardOffset)
+ },
+ Cue = new Cue(0d)
+ },
+ new KeyFrame
+ {
+ Setters =
+ {
+ new Setter(ScaleTransform.ScaleXProperty, 1d),
+ new Setter(ScaleTransform.ScaleYProperty, 1d),
+ new Setter(TranslateTransform.YProperty, 0d)
+ },
+ Cue = new Cue(1d)
+ }
+ }
+ };
+
+ tasks.Add(animation.RunAsync(to, cancellationToken));
+ }
+
+ await Task.WhenAll(tasks);
+
+ if (from != null && !cancellationToken.IsCancellationRequested)
+ {
+ from.IsVisible = false;
+ }
+
+ if (!cancellationToken.IsCancellationRequested && to != null)
+ {
+ var (scale, translate) = EnsureBackTransforms(to);
+ scale.ScaleX = 1;
+ scale.ScaleY = 1;
+ translate.X = 0;
+ translate.Y = 0;
+ }
+ }
+
+ ///
+ public override void Update(
+ double progress,
+ Visual? from,
+ Visual? to,
+ bool forward,
+ double pageLength,
+ IReadOnlyList visibleItems)
+ {
+ if (visibleItems.Count > 0)
+ {
+ UpdateVisibleItems(progress, from, to, forward, pageLength, visibleItems);
+ return;
+ }
+
+ if (from is null && to is null)
+ return;
+
+ var parent = GetVisualParent(from, to);
+ var size = parent.Bounds.Size;
+ var isHorizontal = Orientation == PageSlide.SlideAxis.Horizontal;
+ var distance = pageLength > 0
+ ? pageLength
+ : (isHorizontal ? size.Width : size.Height);
+ var rotationTarget = isHorizontal ? (forward ? -MaxSwipeAngle : MaxSwipeAngle) : 0.0;
+ var startScale = 1.0 - BackCardScale;
+
+ if (from != null)
+ {
+ var (rotate, translate) = EnsureTopTransforms(from);
+ if (isHorizontal)
+ {
+ translate.X = forward ? -distance * progress : distance * progress;
+ translate.Y = 0;
+ }
+ else
+ {
+ translate.X = 0;
+ translate.Y = forward ? -distance * progress : distance * progress;
+ }
+
+ rotate.Angle = rotationTarget * progress;
+ from.IsVisible = true;
+ from.Opacity = 1;
+ from.ZIndex = 1;
+ }
+
+ if (to != null)
+ {
+ var (scale, translate) = EnsureBackTransforms(to);
+ var currentScale = startScale + (1.0 - startScale) * progress;
+ var currentOffset = BackCardOffset * (1.0 - progress);
+
+ scale.ScaleX = currentScale;
+ scale.ScaleY = currentScale;
+ if (isHorizontal)
+ {
+ translate.X = 0;
+ translate.Y = currentOffset;
+ }
+ else
+ {
+ translate.X = currentOffset;
+ translate.Y = 0;
+ }
+
+ to.IsVisible = true;
+ to.Opacity = 1;
+ to.ZIndex = 0;
+ }
+ }
+
+ ///
+ public override void Reset(Visual visual)
+ {
+ visual.RenderTransform = null;
+ visual.RenderTransformOrigin = default;
+ visual.Opacity = 1;
+ visual.ZIndex = 0;
+ }
+
+ private void UpdateVisibleItems(
+ double progress,
+ Visual? from,
+ Visual? to,
+ bool forward,
+ double pageLength,
+ IReadOnlyList visibleItems)
+ {
+ var isHorizontal = Orientation == PageSlide.SlideAxis.Horizontal;
+ var rotationTarget = isHorizontal
+ ? (forward ? -MaxSwipeAngle : MaxSwipeAngle)
+ : 0.0;
+ var stackOffset = GetViewportStackOffset(pageLength);
+ var lift = Math.Sin(Math.Clamp(progress, 0.0, 1.0) * Math.PI);
+
+ foreach (var item in visibleItems)
+ {
+ var visual = item.Visual;
+ var (rotate, scale, translate) = EnsureViewportTransforms(visual);
+ var depth = GetViewportDepth(item.ViewportCenterOffset);
+ var scaleValue = Math.Max(0.84, 1.0 - (BackCardScale * depth));
+ var stackValue = stackOffset * depth;
+ var baseOpacity = Math.Max(0.8, 1.0 - (ViewportDepthOpacityFalloff * depth));
+ var restingAngle = isHorizontal ? GetViewportRestingAngle(item.ViewportCenterOffset) : 0.0;
+
+ rotate.Angle = restingAngle;
+ scale.ScaleX = scaleValue;
+ scale.ScaleY = scaleValue;
+ translate.X = 0;
+ translate.Y = 0;
+
+ if (ReferenceEquals(visual, from))
+ {
+ rotate.Angle = restingAngle + (rotationTarget * progress);
+ stackValue -= stackOffset * 0.2 * lift;
+ baseOpacity = Math.Min(1.0, baseOpacity + 0.08);
+ }
+
+ if (ReferenceEquals(visual, to))
+ {
+ var promotedScale = Math.Min(1.0, scaleValue + (ViewportLiftScale * lift) + (ViewportPromotionScale * progress));
+ scale.ScaleX = promotedScale;
+ scale.ScaleY = promotedScale;
+ rotate.Angle = restingAngle * (1.0 - progress);
+ stackValue = Math.Max(0.0, stackValue - (stackOffset * (0.45 + (0.2 * lift)) * progress));
+ baseOpacity = Math.Min(1.0, baseOpacity + (0.12 * lift));
+ }
+
+ if (isHorizontal)
+ translate.Y = stackValue;
+ else
+ translate.X = stackValue;
+
+ visual.IsVisible = true;
+ visual.Opacity = baseOpacity;
+ visual.ZIndex = GetViewportZIndex(item.ViewportCenterOffset, visual, from, to);
+ }
+ }
+
+ private static (RotateTransform rotate, TranslateTransform translate) EnsureTopTransforms(Visual visual)
+ {
+ if (visual.RenderTransform is TransformGroup group &&
+ group.Children.Count == 2 &&
+ group.Children[0] is RotateTransform rotateTransform &&
+ group.Children[1] is TranslateTransform translateTransform)
+ {
+ visual.RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative);
+ return (rotateTransform, translateTransform);
+ }
+
+ var rotate = new RotateTransform();
+ var translate = new TranslateTransform();
+ visual.RenderTransform = new TransformGroup
+ {
+ Children =
+ {
+ rotate,
+ translate
+ }
+ };
+ visual.RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative);
+ return (rotate, translate);
+ }
+
+ private static (ScaleTransform scale, TranslateTransform translate) EnsureBackTransforms(Visual visual)
+ {
+ if (visual.RenderTransform is TransformGroup group &&
+ group.Children.Count == 2 &&
+ group.Children[0] is ScaleTransform scaleTransform &&
+ group.Children[1] is TranslateTransform translateTransform)
+ {
+ visual.RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative);
+ return (scaleTransform, translateTransform);
+ }
+
+ var scale = new ScaleTransform();
+ var translate = new TranslateTransform();
+ visual.RenderTransform = new TransformGroup
+ {
+ Children =
+ {
+ scale,
+ translate
+ }
+ };
+ visual.RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative);
+ return (scale, translate);
+ }
+
+ private static (RotateTransform rotate, ScaleTransform scale, TranslateTransform translate) EnsureViewportTransforms(Visual visual)
+ {
+ if (visual.RenderTransform is TransformGroup group &&
+ group.Children.Count == 3 &&
+ group.Children[0] is RotateTransform rotateTransform &&
+ group.Children[1] is ScaleTransform scaleTransform &&
+ group.Children[2] is TranslateTransform translateTransform)
+ {
+ visual.RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative);
+ return (rotateTransform, scaleTransform, translateTransform);
+ }
+
+ var rotate = new RotateTransform();
+ var scale = new ScaleTransform(1, 1);
+ var translate = new TranslateTransform();
+ visual.RenderTransform = new TransformGroup
+ {
+ Children =
+ {
+ rotate,
+ scale,
+ translate
+ }
+ };
+ visual.RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative);
+ return (rotate, scale, translate);
+ }
+
+ private double GetViewportStackOffset(double pageLength)
+ {
+ if (BackCardOffset > 0)
+ return BackCardOffset;
+
+ return Math.Clamp(pageLength * 0.045, 10.0, 18.0);
+ }
+
+ private static double GetViewportDepth(double offsetFromCenter)
+ {
+ var distance = Math.Abs(offsetFromCenter);
+
+ if (distance <= 1.0)
+ return distance;
+
+ if (distance <= 2.0)
+ return 1.0 + ((distance - 1.0) * 0.8);
+
+ return 1.8;
+ }
+
+ private static double GetViewportRestingAngle(double offsetFromCenter)
+ {
+ var sign = Math.Sign(offsetFromCenter);
+ if (sign == 0)
+ return 0;
+
+ var distance = Math.Abs(offsetFromCenter);
+ if (distance <= 1.0)
+ return sign * Lerp(0.0, SidePeekAngle, distance);
+
+ if (distance <= 2.0)
+ return sign * Lerp(SidePeekAngle, FarPeekAngle, distance - 1.0);
+
+ return sign * FarPeekAngle;
+ }
+
+ private static double Lerp(double from, double to, double t)
+ {
+ return from + ((to - from) * Math.Clamp(t, 0.0, 1.0));
+ }
+
+ private static int GetViewportZIndex(double offsetFromCenter, Visual visual, Visual? from, Visual? to)
+ {
+ if (ReferenceEquals(visual, from))
+ return 5;
+
+ if (ReferenceEquals(visual, to))
+ return 4;
+
+ var distance = Math.Abs(offsetFromCenter);
+ if (distance < 0.5)
+ return 4;
+ if (distance < 1.5)
+ return 3;
+ return 2;
+ }
+}
diff --git a/samples/ControlCatalog/Pages/Transitions/WaveRevealPageTransition.cs b/samples/ControlCatalog/Pages/Transitions/WaveRevealPageTransition.cs
new file mode 100644
index 0000000000..9d8e80bf9c
--- /dev/null
+++ b/samples/ControlCatalog/Pages/Transitions/WaveRevealPageTransition.cs
@@ -0,0 +1,380 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Animation;
+using Avalonia.Animation.Easings;
+using Avalonia.Media;
+
+namespace ControlCatalog.Pages.Transitions;
+
+///
+/// Transitions between two pages using a wave clip that reveals the next page.
+///
+public class WaveRevealPageTransition : PageSlide
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public WaveRevealPageTransition()
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The duration of the animation.
+ /// The axis on which the animation should occur.
+ public WaveRevealPageTransition(TimeSpan duration, PageSlide.SlideAxis orientation = PageSlide.SlideAxis.Horizontal)
+ : base(duration, orientation)
+ {
+ }
+
+ ///
+ /// Gets or sets the maximum wave bulge (pixels) along the movement axis.
+ ///
+ public double MaxBulge { get; set; } = 120.0;
+
+ ///
+ /// Gets or sets the bulge factor along the movement axis (0-1).
+ ///
+ public double BulgeFactor { get; set; } = 0.35;
+
+ ///
+ /// Gets or sets the bulge factor along the cross axis (0-1).
+ ///
+ public double CrossBulgeFactor { get; set; } = 0.3;
+
+ ///
+ /// Gets or sets a cross-axis offset (pixels) to shift the wave center.
+ ///
+ public double WaveCenterOffset { get; set; } = 0.0;
+
+ ///
+ /// Gets or sets how strongly the wave center follows the provided offset.
+ ///
+ public double CenterSensitivity { get; set; } = 1.0;
+
+ ///
+ /// Gets or sets the bulge exponent used to shape the wave (1.0 = linear).
+ /// Higher values tighten the bulge; lower values broaden it.
+ ///
+ public double BulgeExponent { get; set; } = 1.0;
+
+ ///
+ /// Gets or sets the easing applied to the wave progress (clip only).
+ ///
+ public Easing WaveEasing { get; set; } = new CubicEaseOut();
+
+ ///
+ public override async Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken)
+ {
+ if (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+
+ if (to != null)
+ {
+ to.IsVisible = true;
+ to.ZIndex = 1;
+ }
+
+ if (from != null)
+ {
+ from.ZIndex = 0;
+ }
+
+ await AnimateProgress(0.0, 1.0, from, to, forward, cancellationToken);
+
+ if (to != null && !cancellationToken.IsCancellationRequested)
+ {
+ to.Clip = null;
+ }
+
+ if (from != null && !cancellationToken.IsCancellationRequested)
+ {
+ from.IsVisible = false;
+ }
+ }
+
+ ///
+ public override void Update(
+ double progress,
+ Visual? from,
+ Visual? to,
+ bool forward,
+ double pageLength,
+ IReadOnlyList visibleItems)
+ {
+ if (visibleItems.Count > 0)
+ {
+ UpdateVisibleItems(from, to, forward, pageLength, visibleItems);
+ return;
+ }
+
+ if (from is null && to is null)
+ return;
+ var parent = GetVisualParent(from, to);
+ var size = parent.Bounds.Size;
+ var centerOffset = WaveCenterOffset * CenterSensitivity;
+ var isHorizontal = Orientation == PageSlide.SlideAxis.Horizontal;
+
+ if (to != null)
+ {
+ to.IsVisible = progress > 0.0;
+ to.ZIndex = 1;
+ to.Opacity = 1;
+
+ if (progress >= 1.0)
+ {
+ to.Clip = null;
+ }
+ else
+ {
+ var waveProgress = WaveEasing?.Ease(progress) ?? progress;
+ var clip = LiquidSwipeClipper.CreateWavePath(
+ waveProgress,
+ size,
+ centerOffset,
+ forward,
+ isHorizontal,
+ MaxBulge,
+ BulgeFactor,
+ CrossBulgeFactor,
+ BulgeExponent);
+ to.Clip = clip;
+ }
+ }
+
+ if (from != null)
+ {
+ from.IsVisible = true;
+ from.ZIndex = 0;
+ from.Opacity = 1;
+ }
+ }
+
+ private void UpdateVisibleItems(
+ Visual? from,
+ Visual? to,
+ bool forward,
+ double pageLength,
+ IReadOnlyList visibleItems)
+ {
+ if (from is null && to is null)
+ return;
+
+ var parent = GetVisualParent(from, to);
+ var size = parent.Bounds.Size;
+ var centerOffset = WaveCenterOffset * CenterSensitivity;
+ var isHorizontal = Orientation == PageSlide.SlideAxis.Horizontal;
+ var resolvedPageLength = pageLength > 0
+ ? pageLength
+ : (isHorizontal ? size.Width : size.Height);
+ foreach (var item in visibleItems)
+ {
+ var visual = item.Visual;
+ visual.IsVisible = true;
+ visual.Opacity = 1;
+ visual.Clip = null;
+ visual.ZIndex = ReferenceEquals(visual, to) ? 1 : 0;
+
+ if (!ReferenceEquals(visual, to))
+ continue;
+
+ var visibleFraction = GetVisibleFraction(item.ViewportCenterOffset, size, resolvedPageLength, isHorizontal);
+ if (visibleFraction >= 1.0)
+ continue;
+
+ visual.Clip = LiquidSwipeClipper.CreateWavePath(
+ visibleFraction,
+ size,
+ centerOffset,
+ forward,
+ isHorizontal,
+ MaxBulge,
+ BulgeFactor,
+ CrossBulgeFactor,
+ BulgeExponent);
+ }
+ }
+
+ private static double GetVisibleFraction(double offsetFromCenter, Size viewportSize, double pageLength, bool isHorizontal)
+ {
+ if (pageLength <= 0)
+ return 1.0;
+
+ var viewportLength = isHorizontal ? viewportSize.Width : viewportSize.Height;
+ if (viewportLength <= 0)
+ return 0.0;
+
+ var viewportUnits = viewportLength / pageLength;
+ var edgePeek = Math.Max(0.0, (viewportUnits - 1.0) / 2.0);
+ return Math.Clamp(1.0 + edgePeek - Math.Abs(offsetFromCenter), 0.0, 1.0);
+ }
+
+ ///
+ public override void Reset(Visual visual)
+ {
+ visual.Clip = null;
+ visual.ZIndex = 0;
+ visual.Opacity = 1;
+ }
+
+ private async Task AnimateProgress(
+ double from,
+ double to,
+ Visual? fromVisual,
+ Visual? toVisual,
+ bool forward,
+ CancellationToken cancellationToken)
+ {
+ var parent = GetVisualParent(fromVisual, toVisual);
+ var pageLength = Orientation == PageSlide.SlideAxis.Horizontal
+ ? parent.Bounds.Width
+ : parent.Bounds.Height;
+ var durationMs = Math.Max(Duration.TotalMilliseconds * Math.Abs(to - from), 50);
+ var startTicks = Stopwatch.GetTimestamp();
+ var tickFreq = Stopwatch.Frequency;
+
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ var elapsedMs = (Stopwatch.GetTimestamp() - startTicks) * 1000.0 / tickFreq;
+ var t = Math.Clamp(elapsedMs / durationMs, 0.0, 1.0);
+ var eased = SlideInEasing?.Ease(t) ?? t;
+ var progress = from + (to - from) * eased;
+
+ Update(progress, fromVisual, toVisual, forward, pageLength, Array.Empty());
+
+ if (t >= 1.0)
+ break;
+
+ await Task.Delay(16, cancellationToken);
+ }
+
+ if (!cancellationToken.IsCancellationRequested)
+ {
+ Update(to, fromVisual, toVisual, forward, pageLength, Array.Empty());
+ }
+ }
+
+ private static class LiquidSwipeClipper
+ {
+ public static Geometry CreateWavePath(
+ double progress,
+ Size size,
+ double waveCenterOffset,
+ bool forward,
+ bool isHorizontal,
+ double maxBulge,
+ double bulgeFactor,
+ double crossBulgeFactor,
+ double bulgeExponent)
+ {
+ var width = size.Width;
+ var height = size.Height;
+
+ if (progress <= 0)
+ return new RectangleGeometry(new Rect(0, 0, 0, 0));
+
+ if (progress >= 1)
+ return new RectangleGeometry(new Rect(0, 0, width, height));
+
+ if (width <= 0 || height <= 0)
+ return new RectangleGeometry(new Rect(0, 0, 0, 0));
+
+ var mainLength = isHorizontal ? width : height;
+ var crossLength = isHorizontal ? height : width;
+
+ var wavePhase = Math.Sin(progress * Math.PI);
+ var bulgeProgress = bulgeExponent == 1.0 ? wavePhase : Math.Pow(wavePhase, bulgeExponent);
+ var revealedLength = mainLength * progress;
+ var bulgeMain = Math.Min(mainLength * bulgeFactor, maxBulge) * bulgeProgress;
+ bulgeMain = Math.Min(bulgeMain, revealedLength * 0.45);
+ var bulgeCross = crossLength * crossBulgeFactor;
+
+ var waveCenter = crossLength / 2 + waveCenterOffset;
+ waveCenter = Math.Clamp(waveCenter, bulgeCross, crossLength - bulgeCross);
+
+ var geometry = new StreamGeometry();
+ using (var context = geometry.Open())
+ {
+ if (isHorizontal)
+ {
+ if (forward)
+ {
+ var waveX = width * (1 - progress);
+ context.BeginFigure(new Point(width, 0), true);
+ context.LineTo(new Point(waveX, 0));
+ context.CubicBezierTo(
+ new Point(waveX, waveCenter - bulgeCross),
+ new Point(waveX - bulgeMain, waveCenter - bulgeCross * 0.5),
+ new Point(waveX - bulgeMain, waveCenter));
+ context.CubicBezierTo(
+ new Point(waveX - bulgeMain, waveCenter + bulgeCross * 0.5),
+ new Point(waveX, waveCenter + bulgeCross),
+ new Point(waveX, height));
+ context.LineTo(new Point(width, height));
+ context.EndFigure(true);
+ }
+ else
+ {
+ var waveX = width * progress;
+ context.BeginFigure(new Point(0, 0), true);
+ context.LineTo(new Point(waveX, 0));
+ context.CubicBezierTo(
+ new Point(waveX, waveCenter - bulgeCross),
+ new Point(waveX + bulgeMain, waveCenter - bulgeCross * 0.5),
+ new Point(waveX + bulgeMain, waveCenter));
+ context.CubicBezierTo(
+ new Point(waveX + bulgeMain, waveCenter + bulgeCross * 0.5),
+ new Point(waveX, waveCenter + bulgeCross),
+ new Point(waveX, height));
+ context.LineTo(new Point(0, height));
+ context.EndFigure(true);
+ }
+ }
+ else
+ {
+ if (forward)
+ {
+ var waveY = height * (1 - progress);
+ context.BeginFigure(new Point(0, height), true);
+ context.LineTo(new Point(0, waveY));
+ context.CubicBezierTo(
+ new Point(waveCenter - bulgeCross, waveY),
+ new Point(waveCenter - bulgeCross * 0.5, waveY - bulgeMain),
+ new Point(waveCenter, waveY - bulgeMain));
+ context.CubicBezierTo(
+ new Point(waveCenter + bulgeCross * 0.5, waveY - bulgeMain),
+ new Point(waveCenter + bulgeCross, waveY),
+ new Point(width, waveY));
+ context.LineTo(new Point(width, height));
+ context.EndFigure(true);
+ }
+ else
+ {
+ var waveY = height * progress;
+ context.BeginFigure(new Point(0, 0), true);
+ context.LineTo(new Point(0, waveY));
+ context.CubicBezierTo(
+ new Point(waveCenter - bulgeCross, waveY),
+ new Point(waveCenter - bulgeCross * 0.5, waveY + bulgeMain),
+ new Point(waveCenter, waveY + bulgeMain));
+ context.CubicBezierTo(
+ new Point(waveCenter + bulgeCross * 0.5, waveY + bulgeMain),
+ new Point(waveCenter + bulgeCross, waveY),
+ new Point(width, waveY));
+ context.LineTo(new Point(width, 0));
+ context.EndFigure(true);
+ }
+ }
+ }
+
+ return geometry;
+ }
+ }
+}
diff --git a/samples/interop/WindowsInteropTest/EmbedToWinFormsDemo.Designer.cs b/samples/interop/WindowsInteropTest/EmbedToWinFormsDemo.Designer.cs
index d8b0724520..48087a9058 100644
--- a/samples/interop/WindowsInteropTest/EmbedToWinFormsDemo.Designer.cs
+++ b/samples/interop/WindowsInteropTest/EmbedToWinFormsDemo.Designer.cs
@@ -30,87 +30,88 @@ namespace WindowsInteropTest
///
private void InitializeComponent()
{
- this.button1 = new System.Windows.Forms.Button();
- this.monthCalendar1 = new System.Windows.Forms.MonthCalendar();
- this.groupBox1 = new System.Windows.Forms.GroupBox();
- this.groupBox2 = new System.Windows.Forms.GroupBox();
- this.avaloniaHost = new WinFormsAvaloniaControlHost();
- this.groupBox1.SuspendLayout();
- this.groupBox2.SuspendLayout();
- this.SuspendLayout();
- //
- // button1
- //
- this.button1.Location = new System.Drawing.Point(28, 29);
- this.button1.Name = "button1";
- this.button1.Size = new System.Drawing.Size(164, 73);
- this.button1.TabIndex = 0;
- this.button1.Text = "button1";
- this.button1.UseVisualStyleBackColor = true;
- //
+ OpenWindowButton = new System.Windows.Forms.Button();
+ monthCalendar1 = new System.Windows.Forms.MonthCalendar();
+ groupBox1 = new System.Windows.Forms.GroupBox();
+ groupBox2 = new System.Windows.Forms.GroupBox();
+ avaloniaHost = new Avalonia.Win32.Interoperability.WinFormsAvaloniaControlHost();
+ groupBox1.SuspendLayout();
+ groupBox2.SuspendLayout();
+ SuspendLayout();
+ //
+ // OpenWindowButton
+ //
+ OpenWindowButton.Location = new System.Drawing.Point(33, 33);
+ OpenWindowButton.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
+ OpenWindowButton.Name = "OpenWindowButton";
+ OpenWindowButton.Size = new System.Drawing.Size(191, 84);
+ OpenWindowButton.TabIndex = 0;
+ OpenWindowButton.Text = "Open Avalonia Window";
+ OpenWindowButton.UseVisualStyleBackColor = true;
+ OpenWindowButton.Click += OpenWindowButton_Click;
+ //
// monthCalendar1
- //
- this.monthCalendar1.Location = new System.Drawing.Point(28, 114);
- this.monthCalendar1.Name = "monthCalendar1";
- this.monthCalendar1.TabIndex = 1;
- //
+ //
+ monthCalendar1.Location = new System.Drawing.Point(33, 132);
+ monthCalendar1.Margin = new System.Windows.Forms.Padding(10);
+ monthCalendar1.Name = "monthCalendar1";
+ monthCalendar1.TabIndex = 1;
+ //
// groupBox1
- //
- this.groupBox1.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
- | System.Windows.Forms.AnchorStyles.Left)));
- this.groupBox1.Controls.Add(this.button1);
- this.groupBox1.Controls.Add(this.monthCalendar1);
- this.groupBox1.Location = new System.Drawing.Point(12, 12);
- this.groupBox1.Name = "groupBox1";
- this.groupBox1.Size = new System.Drawing.Size(227, 418);
- this.groupBox1.TabIndex = 2;
- this.groupBox1.TabStop = false;
- this.groupBox1.Text = "WinForms";
- //
+ //
+ groupBox1.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) | System.Windows.Forms.AnchorStyles.Left));
+ groupBox1.Controls.Add(OpenWindowButton);
+ groupBox1.Controls.Add(monthCalendar1);
+ groupBox1.Location = new System.Drawing.Point(14, 14);
+ groupBox1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
+ groupBox1.Name = "groupBox1";
+ groupBox1.Padding = new System.Windows.Forms.Padding(4, 3, 4, 3);
+ groupBox1.Size = new System.Drawing.Size(265, 482);
+ groupBox1.TabIndex = 2;
+ groupBox1.TabStop = false;
+ groupBox1.Text = "WinForms";
+ //
// groupBox2
- //
- this.groupBox2.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
- | System.Windows.Forms.AnchorStyles.Left)
- | System.Windows.Forms.AnchorStyles.Right)));
- this.groupBox2.Controls.Add(this.avaloniaHost);
- this.groupBox2.Location = new System.Drawing.Point(245, 12);
- this.groupBox2.Name = "groupBox2";
- this.groupBox2.Size = new System.Drawing.Size(501, 418);
- this.groupBox2.TabIndex = 3;
- this.groupBox2.TabStop = false;
- this.groupBox2.Text = "Avalonia";
- //
+ //
+ groupBox2.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right));
+ groupBox2.Controls.Add(avaloniaHost);
+ groupBox2.Location = new System.Drawing.Point(286, 14);
+ groupBox2.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
+ groupBox2.Name = "groupBox2";
+ groupBox2.Padding = new System.Windows.Forms.Padding(4, 3, 4, 3);
+ groupBox2.Size = new System.Drawing.Size(584, 482);
+ groupBox2.TabIndex = 3;
+ groupBox2.TabStop = false;
+ groupBox2.Text = "Avalonia";
+ //
// avaloniaHost
- //
- this.avaloniaHost.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
- | System.Windows.Forms.AnchorStyles.Left)
- | System.Windows.Forms.AnchorStyles.Right)));
- this.avaloniaHost.Content = null;
- this.avaloniaHost.Location = new System.Drawing.Point(6, 19);
- this.avaloniaHost.Name = "avaloniaHost";
- this.avaloniaHost.Size = new System.Drawing.Size(489, 393);
- this.avaloniaHost.TabIndex = 0;
- this.avaloniaHost.Text = "avaloniaHost";
- //
+ //
+ avaloniaHost.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right));
+ avaloniaHost.Location = new System.Drawing.Point(7, 22);
+ avaloniaHost.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
+ avaloniaHost.Name = "avaloniaHost";
+ avaloniaHost.Size = new System.Drawing.Size(570, 453);
+ avaloniaHost.TabIndex = 0;
+ avaloniaHost.Text = "avaloniaHost";
+ //
// EmbedToWinFormsDemo
- //
- this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
- this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
- this.ClientSize = new System.Drawing.Size(758, 442);
- this.Controls.Add(this.groupBox2);
- this.Controls.Add(this.groupBox1);
- this.MinimumSize = new System.Drawing.Size(600, 400);
- this.Name = "EmbedToWinFormsDemo";
- this.Text = "EmbedToWinFormsDemo";
- this.groupBox1.ResumeLayout(false);
- this.groupBox2.ResumeLayout(false);
- this.ResumeLayout(false);
-
+ //
+ AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
+ AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
+ ClientSize = new System.Drawing.Size(884, 510);
+ Controls.Add(groupBox2);
+ Controls.Add(groupBox1);
+ Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
+ MinimumSize = new System.Drawing.Size(697, 456);
+ Text = "EmbedToWinFormsDemo";
+ groupBox1.ResumeLayout(false);
+ groupBox2.ResumeLayout(false);
+ ResumeLayout(false);
}
#endregion
- private System.Windows.Forms.Button button1;
+ private System.Windows.Forms.Button OpenWindowButton;
private System.Windows.Forms.MonthCalendar monthCalendar1;
private System.Windows.Forms.GroupBox groupBox1;
private System.Windows.Forms.GroupBox groupBox2;
diff --git a/samples/interop/WindowsInteropTest/EmbedToWinFormsDemo.cs b/samples/interop/WindowsInteropTest/EmbedToWinFormsDemo.cs
index d37ed13559..69dfcb1bbc 100644
--- a/samples/interop/WindowsInteropTest/EmbedToWinFormsDemo.cs
+++ b/samples/interop/WindowsInteropTest/EmbedToWinFormsDemo.cs
@@ -1,5 +1,10 @@
-using System.Windows.Forms;
+using System;
+using System.Windows.Forms;
using ControlCatalog;
+using AvaloniaButton = Avalonia.Controls.Button;
+using AvaloniaStackPanel = Avalonia.Controls.StackPanel;
+using AvaloniaTextBox = Avalonia.Controls.TextBox;
+using AvaloniaWindow = Avalonia.Controls.Window;
namespace WindowsInteropTest
{
@@ -10,5 +15,23 @@ namespace WindowsInteropTest
InitializeComponent();
avaloniaHost.Content = new MainView();
}
+
+ private void OpenWindowButton_Click(object sender, EventArgs e)
+ {
+ var window = new AvaloniaWindow
+ {
+ Width = 300,
+ Height = 300,
+ Content = new AvaloniaStackPanel
+ {
+ Children =
+ {
+ new AvaloniaButton { Content = "Button" },
+ new AvaloniaTextBox { Text = "Text" }
+ }
+ }
+ };
+ window.Show();
+ }
}
}
diff --git a/samples/interop/WindowsInteropTest/Program.cs b/samples/interop/WindowsInteropTest/Program.cs
index 4ebb88642b..8ef01523d9 100644
--- a/samples/interop/WindowsInteropTest/Program.cs
+++ b/samples/interop/WindowsInteropTest/Program.cs
@@ -1,6 +1,7 @@
using System;
using ControlCatalog;
using Avalonia;
+using Avalonia.Win32.Interoperability;
namespace WindowsInteropTest
{
@@ -14,9 +15,11 @@ namespace WindowsInteropTest
{
System.Windows.Forms.Application.EnableVisualStyles();
System.Windows.Forms.Application.SetCompatibleTextRenderingDefault(false);
+ System.Windows.Forms.Application.AddMessageFilter(new WinFormsAvaloniaMessageFilter());
AppBuilder.Configure()
.UseWin32()
.UseSkia()
+ .UseHarfBuzz()
.SetupWithoutStarting();
System.Windows.Forms.Application.Run(new EmbedToWinFormsDemo());
}
diff --git a/samples/interop/WindowsInteropTest/WindowsInteropTest.csproj b/samples/interop/WindowsInteropTest/WindowsInteropTest.csproj
index 576910ca3d..e282d93121 100644
--- a/samples/interop/WindowsInteropTest/WindowsInteropTest.csproj
+++ b/samples/interop/WindowsInteropTest/WindowsInteropTest.csproj
@@ -8,6 +8,7 @@
+
diff --git a/src/Avalonia.Base/Animation/CompositePageTransition.cs b/src/Avalonia.Base/Animation/CompositePageTransition.cs
index 62119a0051..e5e3511337 100644
--- a/src/Avalonia.Base/Animation/CompositePageTransition.cs
+++ b/src/Avalonia.Base/Animation/CompositePageTransition.cs
@@ -28,7 +28,7 @@ namespace Avalonia.Animation
///
///
///
- public class CompositePageTransition : IPageTransition
+ public class CompositePageTransition : IPageTransition, IProgressPageTransition
{
///
/// Gets or sets the transitions to be executed. Can be defined from XAML.
@@ -44,5 +44,35 @@ namespace Avalonia.Animation
.ToArray();
return Task.WhenAll(transitionTasks);
}
+
+ ///
+ public void Update(
+ double progress,
+ Visual? from,
+ Visual? to,
+ bool forward,
+ double pageLength,
+ IReadOnlyList visibleItems)
+ {
+ foreach (var transition in PageTransitions)
+ {
+ if (transition is IProgressPageTransition progressive)
+ {
+ progressive.Update(progress, from, to, forward, pageLength, visibleItems);
+ }
+ }
+ }
+
+ ///
+ public void Reset(Visual visual)
+ {
+ foreach (var transition in PageTransitions)
+ {
+ if (transition is IProgressPageTransition progressive)
+ {
+ progressive.Reset(visual);
+ }
+ }
+ }
}
}
diff --git a/src/Avalonia.Base/Animation/CrossFade.cs b/src/Avalonia.Base/Animation/CrossFade.cs
index f00d835020..45a4300e5b 100644
--- a/src/Avalonia.Base/Animation/CrossFade.cs
+++ b/src/Avalonia.Base/Animation/CrossFade.cs
@@ -12,8 +12,13 @@ namespace Avalonia.Animation
///
/// Defines a cross-fade animation between two s.
///
- public class CrossFade : IPageTransition
+ public class CrossFade : IPageTransition, IProgressPageTransition
{
+ private const double SidePeekOpacity = 0.72;
+ private const double FarPeekOpacity = 0.42;
+ private const double OutgoingDip = 0.22;
+ private const double IncomingBoost = 0.12;
+ private const double PassiveDip = 0.05;
private readonly Animation _fadeOutAnimation;
private readonly Animation _fadeInAnimation;
@@ -182,5 +187,82 @@ namespace Avalonia.Animation
{
return Start(from, to, cancellationToken);
}
+
+ ///
+ public void Update(
+ double progress,
+ Visual? from,
+ Visual? to,
+ bool forward,
+ double pageLength,
+ IReadOnlyList visibleItems)
+ {
+ if (visibleItems.Count > 0)
+ {
+ UpdateVisibleItems(progress, from, to, visibleItems);
+ return;
+ }
+
+ if (from != null)
+ from.Opacity = 1 - progress;
+ if (to != null)
+ {
+ to.IsVisible = true;
+ to.Opacity = progress;
+ }
+ }
+
+ ///
+ public void Reset(Visual visual)
+ {
+ visual.Opacity = 1;
+ }
+
+ private static void UpdateVisibleItems(
+ double progress,
+ Visual? from,
+ Visual? to,
+ IReadOnlyList visibleItems)
+ {
+ var emphasis = Math.Sin(Math.Clamp(progress, 0.0, 1.0) * Math.PI);
+ foreach (var item in visibleItems)
+ {
+ item.Visual.IsVisible = true;
+ var opacity = GetOpacityForOffset(item.ViewportCenterOffset);
+
+ if (ReferenceEquals(item.Visual, from))
+ {
+ opacity = Math.Max(FarPeekOpacity, opacity - (OutgoingDip * emphasis));
+ }
+ else if (ReferenceEquals(item.Visual, to))
+ {
+ opacity = Math.Min(1.0, opacity + (IncomingBoost * emphasis));
+ }
+ else
+ {
+ opacity = Math.Max(FarPeekOpacity, opacity - (PassiveDip * emphasis));
+ }
+
+ item.Visual.Opacity = opacity;
+ }
+ }
+
+ private static double GetOpacityForOffset(double offsetFromCenter)
+ {
+ var distance = Math.Abs(offsetFromCenter);
+
+ if (distance <= 1.0)
+ return Lerp(1.0, SidePeekOpacity, distance);
+
+ if (distance <= 2.0)
+ return Lerp(SidePeekOpacity, FarPeekOpacity, distance - 1.0);
+
+ return FarPeekOpacity;
+ }
+
+ private static double Lerp(double from, double to, double t)
+ {
+ return from + ((to - from) * Math.Clamp(t, 0.0, 1.0));
+ }
}
}
diff --git a/src/Avalonia.Base/Animation/IProgressPageTransition.cs b/src/Avalonia.Base/Animation/IProgressPageTransition.cs
new file mode 100644
index 0000000000..01f892d1fd
--- /dev/null
+++ b/src/Avalonia.Base/Animation/IProgressPageTransition.cs
@@ -0,0 +1,39 @@
+using System.Collections.Generic;
+using Avalonia.VisualTree;
+
+namespace Avalonia.Animation
+{
+ ///
+ /// An that supports progress-driven updates.
+ ///
+ ///
+ /// Transitions implementing this interface can be driven by a normalized progress value
+ /// (0.0 to 1.0) during swipe gestures or programmatic animations, rather than running
+ /// as a timed animation via .
+ ///
+ public interface IProgressPageTransition : IPageTransition
+ {
+ ///
+ /// Updates the transition to reflect the given progress.
+ ///
+ /// The normalized progress value from 0.0 (start) to 1.0 (complete).
+ /// The visual being transitioned away from. May be null.
+ /// The visual being transitioned to. May be null.
+ /// Whether the transition direction is forward (next) or backward (previous).
+ /// The size of a page along the transition axis.
+ /// The currently visible realized pages, if more than one page is visible.
+ void Update(
+ double progress,
+ Visual? from,
+ Visual? to,
+ bool forward,
+ double pageLength,
+ IReadOnlyList visibleItems);
+
+ ///
+ /// Resets any visual state applied to the given visual by this transition.
+ ///
+ /// The visual to reset.
+ void Reset(Visual visual);
+ }
+}
diff --git a/src/Avalonia.Base/Animation/PageSlide.cs b/src/Avalonia.Base/Animation/PageSlide.cs
index 24797a6d80..d75f391c79 100644
--- a/src/Avalonia.Base/Animation/PageSlide.cs
+++ b/src/Avalonia.Base/Animation/PageSlide.cs
@@ -12,7 +12,7 @@ namespace Avalonia.Animation
///
/// Transitions between two pages by sliding them horizontally or vertically.
///
- public class PageSlide : IPageTransition
+ public class PageSlide : IPageTransition, IProgressPageTransition
{
///
/// The axis on which the PageSlide should occur
@@ -50,12 +50,12 @@ namespace Avalonia.Animation
/// Gets the orientation of the animation.
///
public SlideAxis Orientation { get; set; }
-
+
///
/// Gets or sets element entrance easing.
///
public Easing SlideInEasing { get; set; } = new LinearEasing();
-
+
///
/// Gets or sets element exit easing.
///
@@ -152,8 +152,6 @@ namespace Avalonia.Animation
if (from != null)
{
- // Hide BEFORE resetting transform so there is no single-frame flash
- // where the element snaps back to position 0 while still visible.
from.IsVisible = false;
if (FillMode != FillMode.None)
from.RenderTransform = null;
@@ -163,6 +161,55 @@ namespace Avalonia.Animation
to.RenderTransform = null;
}
+ ///
+ public virtual void Update(
+ double progress,
+ Visual? from,
+ Visual? to,
+ bool forward,
+ double pageLength,
+ IReadOnlyList visibleItems)
+ {
+ if (visibleItems.Count > 0)
+ return;
+
+ if (from is null && to is null)
+ return;
+
+ var parent = GetVisualParent(from, to);
+ var distance = pageLength > 0
+ ? pageLength
+ : (Orientation == SlideAxis.Horizontal ? parent.Bounds.Width : parent.Bounds.Height);
+ var offset = distance * progress;
+
+ if (from != null)
+ {
+ if (from.RenderTransform is not TranslateTransform ft)
+ from.RenderTransform = ft = new TranslateTransform();
+ if (Orientation == SlideAxis.Horizontal)
+ ft.X = forward ? -offset : offset;
+ else
+ ft.Y = forward ? -offset : offset;
+ }
+
+ if (to != null)
+ {
+ to.IsVisible = true;
+ if (to.RenderTransform is not TranslateTransform tt)
+ to.RenderTransform = tt = new TranslateTransform();
+ if (Orientation == SlideAxis.Horizontal)
+ tt.X = forward ? distance - offset : -(distance - offset);
+ else
+ tt.Y = forward ? distance - offset : -(distance - offset);
+ }
+ }
+
+ ///
+ public virtual void Reset(Visual visual)
+ {
+ visual.RenderTransform = null;
+ }
+
///
/// Gets the common visual parent of the two control.
///
diff --git a/src/Avalonia.Base/Animation/PageTransitionItem.cs b/src/Avalonia.Base/Animation/PageTransitionItem.cs
new file mode 100644
index 0000000000..fed0145a2a
--- /dev/null
+++ b/src/Avalonia.Base/Animation/PageTransitionItem.cs
@@ -0,0 +1,12 @@
+using Avalonia.VisualTree;
+
+namespace Avalonia.Animation
+{
+ ///
+ /// Describes a single visible page within a carousel viewport.
+ ///
+ public readonly record struct PageTransitionItem(
+ int Index,
+ Visual Visual,
+ double ViewportCenterOffset);
+}
diff --git a/src/Avalonia.Base/Animation/Transitions/Rotate3DTransition.cs b/src/Avalonia.Base/Animation/Transitions/Rotate3DTransition.cs
index 239f3aea08..1075198881 100644
--- a/src/Avalonia.Base/Animation/Transitions/Rotate3DTransition.cs
+++ b/src/Avalonia.Base/Animation/Transitions/Rotate3DTransition.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Media;
@@ -8,6 +9,8 @@ namespace Avalonia.Animation;
public class Rotate3DTransition: PageSlide
{
+ private const double SidePeekAngle = 24.0;
+ private const double FarPeekAngle = 38.0;
///
/// Creates a new instance of the
@@ -20,7 +23,7 @@ public class Rotate3DTransition: PageSlide
{
Depth = depth;
}
-
+
///
/// Defines the depth of the 3D Effect. If null, depth will be calculated automatically from the width or height
/// of the common parent of the visual being rotated.
@@ -28,12 +31,12 @@ public class Rotate3DTransition: PageSlide
public double? Depth { get; set; }
///
- /// Creates a new instance of the
+ /// Initializes a new instance of the class.
///
public Rotate3DTransition() { }
///
- public override async Task Start(Visual? @from, Visual? to, bool forward, CancellationToken cancellationToken)
+ public override async Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested)
{
@@ -49,11 +52,12 @@ public class Rotate3DTransition: PageSlide
_ => throw new ArgumentOutOfRangeException()
};
- var depthSetter = new Setter {Property = Rotate3DTransform.DepthProperty, Value = Depth ?? center};
- var centerZSetter = new Setter {Property = Rotate3DTransform.CenterZProperty, Value = -center / 2};
+ var depthSetter = new Setter { Property = Rotate3DTransform.DepthProperty, Value = Depth ?? center };
+ var centerZSetter = new Setter { Property = Rotate3DTransform.CenterZProperty, Value = -center / 2 };
- KeyFrame CreateKeyFrame(double cue, double rotation, int zIndex, bool isVisible = true) =>
- new() {
+ KeyFrame CreateKeyFrame(double cue, double rotation, int zIndex, bool isVisible = true) =>
+ new()
+ {
Setters =
{
new Setter { Property = rotateProperty, Value = rotation },
@@ -71,7 +75,7 @@ public class Rotate3DTransition: PageSlide
{
Easing = SlideOutEasing,
Duration = Duration,
- FillMode = FillMode.Forward,
+ FillMode = FillMode,
Children =
{
CreateKeyFrame(0d, 0d, 2),
@@ -90,7 +94,7 @@ public class Rotate3DTransition: PageSlide
{
Easing = SlideInEasing,
Duration = Duration,
- FillMode = FillMode.Forward,
+ FillMode = FillMode,
Children =
{
CreateKeyFrame(0d, 90d * (forward ? 1 : -1), 1),
@@ -107,10 +111,8 @@ public class Rotate3DTransition: PageSlide
if (!cancellationToken.IsCancellationRequested)
{
if (to != null)
- {
to.ZIndex = 2;
- }
-
+
if (from != null)
{
from.IsVisible = false;
@@ -118,4 +120,139 @@ public class Rotate3DTransition: PageSlide
}
}
}
+
+ ///
+ public override void Update(
+ double progress,
+ Visual? from,
+ Visual? to,
+ bool forward,
+ double pageLength,
+ IReadOnlyList visibleItems)
+ {
+ if (visibleItems.Count > 0)
+ {
+ UpdateVisibleItems(progress, from, to, pageLength, visibleItems);
+ return;
+ }
+
+ if (from is null && to is null)
+ return;
+
+ var parent = GetVisualParent(from, to);
+ var center = pageLength > 0
+ ? pageLength
+ : (Orientation == SlideAxis.Vertical ? parent.Bounds.Height : parent.Bounds.Width);
+ var depth = Depth ?? center;
+ var sign = forward ? 1.0 : -1.0;
+
+ if (from != null)
+ {
+ if (from.RenderTransform is not Rotate3DTransform ft)
+ from.RenderTransform = ft = new Rotate3DTransform();
+ ft.Depth = depth;
+ ft.CenterZ = -center / 2;
+ from.ZIndex = progress < 0.5 ? 2 : 1;
+ if (Orientation == SlideAxis.Horizontal)
+ ft.AngleY = -sign * 90.0 * progress;
+ else
+ ft.AngleX = -sign * 90.0 * progress;
+ }
+
+ if (to != null)
+ {
+ to.IsVisible = true;
+ if (to.RenderTransform is not Rotate3DTransform tt)
+ to.RenderTransform = tt = new Rotate3DTransform();
+ tt.Depth = depth;
+ tt.CenterZ = -center / 2;
+ to.ZIndex = progress < 0.5 ? 1 : 2;
+ if (Orientation == SlideAxis.Horizontal)
+ tt.AngleY = sign * 90.0 * (1.0 - progress);
+ else
+ tt.AngleX = sign * 90.0 * (1.0 - progress);
+ }
+ }
+
+ private void UpdateVisibleItems(
+ double progress,
+ Visual? from,
+ Visual? to,
+ double pageLength,
+ IReadOnlyList visibleItems)
+ {
+ var anchor = from ?? to ?? visibleItems[0].Visual;
+ if (anchor.VisualParent is not Visual parent)
+ return;
+
+ var center = pageLength > 0
+ ? pageLength
+ : (Orientation == SlideAxis.Vertical ? parent.Bounds.Height : parent.Bounds.Width);
+ var depth = Depth ?? center;
+ var angleStrength = Math.Sin(Math.Clamp(progress, 0.0, 1.0) * Math.PI);
+
+ foreach (var item in visibleItems)
+ {
+ var visual = item.Visual;
+ visual.IsVisible = true;
+ visual.ZIndex = GetZIndex(item.ViewportCenterOffset);
+
+ if (visual.RenderTransform is not Rotate3DTransform transform)
+ visual.RenderTransform = transform = new Rotate3DTransform();
+
+ transform.Depth = depth;
+ transform.CenterZ = -center / 2;
+
+ var angle = GetAngleForOffset(item.ViewportCenterOffset) * angleStrength;
+ if (Orientation == SlideAxis.Horizontal)
+ {
+ transform.AngleY = angle;
+ transform.AngleX = 0;
+ }
+ else
+ {
+ transform.AngleX = angle;
+ transform.AngleY = 0;
+ }
+ }
+ }
+
+ private static double GetAngleForOffset(double offsetFromCenter)
+ {
+ var sign = Math.Sign(offsetFromCenter);
+ if (sign == 0)
+ return 0;
+
+ var distance = Math.Abs(offsetFromCenter);
+ if (distance <= 1.0)
+ return sign * Lerp(0.0, SidePeekAngle, distance);
+
+ if (distance <= 2.0)
+ return sign * Lerp(SidePeekAngle, FarPeekAngle, distance - 1.0);
+
+ return sign * FarPeekAngle;
+ }
+
+ private static int GetZIndex(double offsetFromCenter)
+ {
+ var distance = Math.Abs(offsetFromCenter);
+
+ if (distance < 0.5)
+ return 3;
+ if (distance < 1.5)
+ return 2;
+ return 1;
+ }
+
+ private static double Lerp(double from, double to, double t)
+ {
+ return from + ((to - from) * Math.Clamp(t, 0.0, 1.0));
+ }
+
+ ///
+ public override void Reset(Visual visual)
+ {
+ visual.RenderTransform = null;
+ visual.ZIndex = 0;
+ }
}
diff --git a/src/Avalonia.Base/Input/FindNextElementOptions.cs b/src/Avalonia.Base/Input/FindNextElementOptions.cs
index e6062daf9b..72d83ec419 100644
--- a/src/Avalonia.Base/Input/FindNextElementOptions.cs
+++ b/src/Avalonia.Base/Input/FindNextElementOptions.cs
@@ -6,12 +6,49 @@ using System.Threading.Tasks;
namespace Avalonia.Input
{
+ ///
+ /// Provides options to customize the behavior when identifying the next element to focus
+ /// during a navigation operation.
+ ///
public sealed class FindNextElementOptions
{
+ ///
+ /// Gets or sets the root within which the search for the next
+ /// focusable element will be conducted.
+ ///
+ ///
+ /// This property defines the boundary for focus navigation operations. It determines the root element
+ /// in the visual tree under which the focusable item search is performed. If not specified, the search
+ /// will default to the current scope.
+ ///
public InputElement? SearchRoot { get; init; }
+
+ ///
+ /// Gets or sets the rectangular region within the visual hierarchy that will be excluded
+ /// from consideration during focus navigation.
+ ///
public Rect ExclusionRect { get; init; }
+
+ ///
+ /// Gets or sets a rectangular region that serves as a hint for focus navigation.
+ /// This property specifies a rectangle, relative to the coordinate system of the search root,
+ /// which can be used as a preferred or prioritized target when navigating focus.
+ /// It can be null if no specific hint region is provided.
+ ///
public Rect? FocusHintRectangle { get; init; }
+
+ ///
+ /// Specifies an optional override for the navigation strategy used in XY focus navigation.
+ /// This property allows customizing the focus movement behavior when navigating between UI elements.
+ ///
public XYFocusNavigationStrategy? NavigationStrategyOverride { get; init; }
+
+ ///
+ /// Specifies whether occlusivity (overlapping of elements or obstructions)
+ /// should be ignored during focus navigation. When set to true,
+ /// the navigation logic disregards obstructions that may block a potential
+ /// focus target, allowing elements behind such obstructions to be considered.
+ ///
public bool IgnoreOcclusivity { get; init; }
}
}
diff --git a/src/Avalonia.Base/Input/FocusManager.cs b/src/Avalonia.Base/Input/FocusManager.cs
index 15b8fea77d..dc62171f48 100644
--- a/src/Avalonia.Base/Input/FocusManager.cs
+++ b/src/Avalonia.Base/Input/FocusManager.cs
@@ -4,7 +4,6 @@ using System.Linq;
using Avalonia.Input.Navigation;
using Avalonia.Interactivity;
using Avalonia.Metadata;
-using Avalonia.Reactive;
using Avalonia.VisualTree;
namespace Avalonia.Input
@@ -12,7 +11,6 @@ namespace Avalonia.Input
///
/// Manages focus for the application.
///
- [PrivateApi]
public class FocusManager : IFocusManager
{
///
@@ -42,58 +40,51 @@ namespace Avalonia.Input
RoutingStrategies.Tunnel);
}
+ [PrivateApi]
public FocusManager()
{
- _contentRoot = null;
}
- public FocusManager(IInputElement contentRoot)
- {
- _contentRoot = contentRoot;
- }
-
- internal void SetContentRoot(IInputElement? contentRoot)
+ ///
+ /// Gets or sets the content root for the focus management system.
+ ///
+ [PrivateApi]
+ public IInputElement? ContentRoot
{
- _contentRoot = contentRoot;
+ get => _contentRoot;
+ set => _contentRoot = value;
}
private IInputElement? Current => KeyboardDevice.Instance?.FocusedElement;
- private XYFocus _xyFocus = new();
- private XYFocusOptions _xYFocusOptions = new XYFocusOptions();
+ private readonly XYFocus _xyFocus = new();
private IInputElement? _contentRoot;
+ private XYFocusOptions? _reusableFocusOptions;
- ///
- /// Gets the currently focused .
- ///
+ ///
public IInputElement? GetFocusedElement() => Current;
- ///
- /// Focuses a control.
- ///
- /// The control to focus.
- /// The method by which focus was changed.
- /// Any key modifiers active at the time of focus.
+ ///
public bool Focus(
- IInputElement? control,
+ IInputElement? element,
NavigationMethod method = NavigationMethod.Unspecified,
KeyModifiers keyModifiers = KeyModifiers.None)
{
if (KeyboardDevice.Instance is not { } keyboardDevice)
return false;
- if (control is not null)
+ if (element is not null)
{
- if (!CanFocus(control))
+ if (!CanFocus(element))
return false;
- if (GetFocusScope(control) is StyledElement scope)
+ if (GetFocusScope(element) is StyledElement scope)
{
- scope.SetValue(FocusedElementProperty, control);
+ scope.SetValue(FocusedElementProperty, element);
_focusRoot = GetFocusRoot(scope);
}
- keyboardDevice.SetFocusedElement(control, method, keyModifiers);
+ keyboardDevice.SetFocusedElement(element, method, keyModifiers);
return true;
}
else if (_focusRoot?.GetValue(FocusedElementProperty) is { } restore &&
@@ -110,12 +101,7 @@ namespace Avalonia.Input
}
}
- public void ClearFocus()
- {
- Focus(null);
- }
-
- public void ClearFocusOnElementRemoved(IInputElement removedElement, Visual oldParent)
+ internal void ClearFocusOnElementRemoved(IInputElement removedElement, Visual oldParent)
{
if (oldParent is IInputElement parentElement &&
GetFocusScope(parentElement) is StyledElement scope &&
@@ -129,6 +115,7 @@ namespace Avalonia.Input
Focus(null);
}
+ [PrivateApi]
public IInputElement? GetFocusedElement(IFocusScope scope)
{
return (scope as StyledElement)?.GetValue(FocusedElementProperty);
@@ -138,6 +125,7 @@ namespace Avalonia.Input
/// Notifies the focus manager of a change in focus scope.
///
/// The new focus scope.
+ [PrivateApi]
public void SetFocusScope(IFocusScope scope)
{
if (GetFocusedElement(scope) is { } focused)
@@ -153,12 +141,14 @@ namespace Avalonia.Input
}
}
+ [PrivateApi]
public void RemoveFocusRoot(IFocusScope scope)
{
if (scope == _focusRoot)
- ClearFocus();
+ Focus(null);
}
+ [PrivateApi]
public static bool GetIsFocusScope(IInputElement e) => e is IFocusScope;
///
@@ -176,25 +166,15 @@ namespace Avalonia.Input
?? (FocusManager?)AvaloniaLocator.Current.GetService();
}
- ///
- /// Attempts to change focus from the element with focus to the next focusable element in the specified direction.
- ///
- /// The direction to traverse (in tab order).
- /// true if focus moved; otherwise, false.
- public bool TryMoveFocus(NavigationDirection direction)
+ ///
+ public bool TryMoveFocus(NavigationDirection direction, FindNextElementOptions? options = null)
{
- return FindAndSetNextFocus(direction, _xYFocusOptions);
- }
+ ValidateDirection(direction);
- ///
- /// Attempts to change focus from the element with focus to the next focusable element in the specified direction, using the specified navigation options.
- ///
- /// The direction to traverse (in tab order).
- /// The options to help identify the next element to receive focus with keyboard/controller/remote navigation.
- /// true if focus moved; otherwise, false.
- public bool TryMoveFocus(NavigationDirection direction, FindNextElementOptions options)
- {
- return FindAndSetNextFocus(direction, ValidateAndCreateFocusOptions(direction, options));
+ var focusOptions = ToFocusOptions(options, true);
+ var result = FindAndSetNextFocus(direction, focusOptions);
+ _reusableFocusOptions = focusOptions;
+ return result;
}
///
@@ -295,10 +275,7 @@ namespace Avalonia.Input
return true;
}
- ///
- /// Retrieves the first element that can receive focus.
- ///
- /// The first focusable element.
+ ///
public IInputElement? FindFirstFocusableElement()
{
var root = (_contentRoot as Visual)?.GetSelfAndVisualDescendants().FirstOrDefault(x => x is IInputElement) as IInputElement;
@@ -317,10 +294,7 @@ namespace Avalonia.Input
return GetFirstFocusableElement(searchScope);
}
- ///
- /// Retrieves the last element that can receive focus.
- ///
- /// The last focusable element.
+ ///
public IInputElement? FindLastFocusableElement()
{
var root = (_contentRoot as Visual)?.GetSelfAndVisualDescendants().FirstOrDefault(x => x is IInputElement) as IInputElement;
@@ -339,52 +313,59 @@ namespace Avalonia.Input
return GetFocusManager(searchScope)?.GetLastFocusableElement(searchScope);
}
- ///
- /// Retrieves the element that should receive focus based on the specified navigation direction.
- ///
- ///
- ///
- public IInputElement? FindNextElement(NavigationDirection direction)
+ ///
+ public IInputElement? FindNextElement(NavigationDirection direction, FindNextElementOptions? options = null)
{
- var xyOption = new XYFocusOptions()
- {
- UpdateManifold = false
- };
+ ValidateDirection(direction);
- return FindNextFocus(direction, xyOption);
+ var focusOptions = ToFocusOptions(options, false);
+ var result = FindNextFocus(direction, focusOptions);
+ _reusableFocusOptions = focusOptions;
+ return result;
}
- ///
- /// Retrieves the element that should receive focus based on the specified navigation direction (cannot be used with tab navigation).
- ///
- /// The direction that focus moves from element to element within the app UI.
- /// The options to help identify the next element to receive focus with the provided navigation.
- /// The next element to receive focus.
- public IInputElement? FindNextElement(NavigationDirection direction, FindNextElementOptions options)
+ private static void ValidateDirection(NavigationDirection direction)
{
- return FindNextFocus(direction, ValidateAndCreateFocusOptions(direction, options));
+ if (direction is not (
+ NavigationDirection.Next or
+ NavigationDirection.Previous or
+ NavigationDirection.Up or
+ NavigationDirection.Down or
+ NavigationDirection.Left or
+ NavigationDirection.Right))
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(direction),
+ direction,
+ $"Only {nameof(NavigationDirection.Next)}, {nameof(NavigationDirection.Previous)}, " +
+ $"{nameof(NavigationDirection.Up)}, {nameof(NavigationDirection.Down)}," +
+ $" {nameof(NavigationDirection.Left)} and {nameof(NavigationDirection.Right)} directions are supported");
+ }
}
- private static XYFocusOptions ValidateAndCreateFocusOptions(NavigationDirection direction, FindNextElementOptions options)
+ private XYFocusOptions ToFocusOptions(FindNextElementOptions? options, bool updateManifold)
{
- if (direction is not NavigationDirection.Up
- and not NavigationDirection.Down
- and not NavigationDirection.Left
- and not NavigationDirection.Right)
+ // XYFocus only uses the options and never modifies them; we can cache and reset them between calls.
+ var focusOptions = _reusableFocusOptions;
+ _reusableFocusOptions = null;
+
+ if (focusOptions is null)
+ focusOptions = new XYFocusOptions();
+ else
+ focusOptions.Reset();
+
+ if (options is not null)
{
- throw new ArgumentOutOfRangeException(nameof(direction),
- $"{direction} is not supported with FindNextElementOptions. Only Up, Down, Left and right are supported");
+ focusOptions.SearchRoot = options.SearchRoot;
+ focusOptions.ExclusionRect = options.ExclusionRect;
+ focusOptions.FocusHintRectangle = options.FocusHintRectangle;
+ focusOptions.NavigationStrategyOverride = options.NavigationStrategyOverride;
+ focusOptions.IgnoreOcclusivity = options.IgnoreOcclusivity;
}
- return new XYFocusOptions
- {
- UpdateManifold = false,
- SearchRoot = options.SearchRoot,
- ExclusionRect = options.ExclusionRect,
- FocusHintRectangle = options.FocusHintRectangle,
- NavigationStrategyOverride = options.NavigationStrategyOverride,
- IgnoreOcclusivity = options.IgnoreOcclusivity
- };
+ focusOptions.UpdateManifold = updateManifold;
+
+ return focusOptions;
}
internal IInputElement? FindNextFocus(NavigationDirection direction, XYFocusOptions focusOptions, bool updateManifolds = true)
diff --git a/src/Avalonia.Base/Input/GestureRecognizers/GestureRecognizerCollection.cs b/src/Avalonia.Base/Input/GestureRecognizers/GestureRecognizerCollection.cs
index 74e8061292..34e900c7d7 100644
--- a/src/Avalonia.Base/Input/GestureRecognizers/GestureRecognizerCollection.cs
+++ b/src/Avalonia.Base/Input/GestureRecognizers/GestureRecognizerCollection.cs
@@ -39,6 +39,24 @@ namespace Avalonia.Input.GestureRecognizers
}
}
+ public bool Remove(GestureRecognizer recognizer)
+ {
+ if (_recognizers == null)
+ return false;
+
+ var removed = _recognizers.Remove(recognizer);
+
+ if (removed)
+ {
+ recognizer.Target = null;
+
+ if (recognizer is ISetLogicalParent logical)
+ logical.SetParent(null);
+ }
+
+ return removed;
+ }
+
static readonly List s_Empty = new List();
public IEnumerator GetEnumerator()
diff --git a/src/Avalonia.Base/Input/GestureRecognizers/SwipeGestureRecognizer.cs b/src/Avalonia.Base/Input/GestureRecognizers/SwipeGestureRecognizer.cs
index 5d17940c8a..2328e5e874 100644
--- a/src/Avalonia.Base/Input/GestureRecognizers/SwipeGestureRecognizer.cs
+++ b/src/Avalonia.Base/Input/GestureRecognizers/SwipeGestureRecognizer.cs
@@ -1,87 +1,102 @@
using System;
-using Avalonia.Logging;
-using Avalonia.Media;
+using System.Diagnostics;
+using Avalonia.Platform;
namespace Avalonia.Input.GestureRecognizers
{
///
- /// A gesture recognizer that detects swipe gestures and raises
- /// on the target element when a swipe is confirmed.
+ /// A gesture recognizer that detects swipe gestures for paging interactions.
///
+ ///
+ /// Unlike , this recognizer is optimized for discrete
+ /// paging interactions (e.g., carousel navigation) rather than continuous scrolling.
+ /// It does not include inertia or friction physics.
+ ///
public class SwipeGestureRecognizer : GestureRecognizer
{
+ private bool _swiping;
+ private Point _trackedRootPoint;
private IPointer? _tracking;
- private IPointer? _captured;
- private Point _initialPosition;
- private int _gestureId;
+ private int _id;
+
+ private Vector _velocity;
+ private long _lastTimestamp;
///
- /// Defines the property.
+ /// Defines the property.
///
- public static readonly StyledProperty ThresholdProperty =
- AvaloniaProperty.Register(nameof(Threshold), 30d);
+ public static readonly StyledProperty CanHorizontallySwipeProperty =
+ AvaloniaProperty.Register(nameof(CanHorizontallySwipe));
///
- /// Defines the property.
+ /// Defines the property.
///
- public static readonly StyledProperty CrossAxisCancelThresholdProperty =
- AvaloniaProperty.Register(
- nameof(CrossAxisCancelThreshold), 8d);
+ public static readonly StyledProperty CanVerticallySwipeProperty =
+ AvaloniaProperty.Register(nameof(CanVerticallySwipe));
///
- /// Defines the property.
- /// Leading-edge start zone in px. 0 (default) = full area.
- /// When > 0, only starts tracking if the pointer is within this many px
- /// of the leading edge (LTR: left; RTL: right).
+ /// Defines the property.
///
- public static readonly StyledProperty EdgeSizeProperty =
- AvaloniaProperty.Register(nameof(EdgeSize), 0d);
+ ///
+ /// A value of 0 (the default) causes the distance to be read from
+ /// at the time of the first gesture.
+ ///
+ public static readonly StyledProperty ThresholdProperty =
+ AvaloniaProperty.Register(nameof(Threshold), defaultValue: 0d);
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty IsMouseEnabledProperty =
+ AvaloniaProperty.Register(nameof(IsMouseEnabled), defaultValue: false);
///
/// Defines the property.
- /// When false, the recognizer ignores all pointer events.
- /// Lets callers toggle the recognizer at runtime without needing to remove it from the
- /// collection (GestureRecognizerCollection has Add but no Remove).
- /// Default: true.
///
public static readonly StyledProperty IsEnabledProperty =
- AvaloniaProperty.Register(nameof(IsEnabled), true);
+ AvaloniaProperty.Register(nameof(IsEnabled), defaultValue: true);
///
- /// Gets or sets the minimum distance in pixels the pointer must travel before a swipe
- /// is recognized. Default is 30px.
+ /// Gets or sets a value indicating whether horizontal swipes are tracked.
///
- public double Threshold
+ public bool CanHorizontallySwipe
{
- get => GetValue(ThresholdProperty);
- set => SetValue(ThresholdProperty, value);
+ get => GetValue(CanHorizontallySwipeProperty);
+ set => SetValue(CanHorizontallySwipeProperty, value);
}
///
- /// Gets or sets the maximum cross-axis drift in pixels allowed before the gesture is
- /// cancelled. Default is 8px.
+ /// Gets or sets a value indicating whether vertical swipes are tracked.
///
- public double CrossAxisCancelThreshold
+ public bool CanVerticallySwipe
{
- get => GetValue(CrossAxisCancelThresholdProperty);
- set => SetValue(CrossAxisCancelThresholdProperty, value);
+ get => GetValue(CanVerticallySwipeProperty);
+ set => SetValue(CanVerticallySwipeProperty, value);
}
///
- /// Gets or sets the leading-edge start zone in pixels. When greater than zero, tracking
- /// only begins if the pointer is within this distance of the leading edge. Default is 0
- /// (full area).
+ /// Gets or sets the minimum pointer movement in pixels before a swipe is recognized.
+ /// A value of 0 reads the threshold from at gesture time.
///
- public double EdgeSize
+ public double Threshold
{
- get => GetValue(EdgeSizeProperty);
- set => SetValue(EdgeSizeProperty, value);
+ get => GetValue(ThresholdProperty);
+ set => SetValue(ThresholdProperty, value);
+ }
+
+ ///
+ /// Gets or sets a value indicating whether mouse pointer events trigger swipe gestures.
+ /// Defaults to ; touch and pen are always enabled.
+ ///
+ public bool IsMouseEnabled
+ {
+ get => GetValue(IsMouseEnabledProperty);
+ set => SetValue(IsMouseEnabledProperty, value);
}
///
- /// Gets or sets a value indicating whether the recognizer responds to pointer events.
- /// Setting this to false is a lightweight alternative to removing the recognizer from
- /// the collection. Default is true.
+ /// Gets or sets a value indicating whether this recognizer responds to pointer events.
+ /// Defaults to .
///
public bool IsEnabled
{
@@ -89,104 +104,122 @@ namespace Avalonia.Input.GestureRecognizers
set => SetValue(IsEnabledProperty, value);
}
+ ///
protected override void PointerPressed(PointerPressedEventArgs e)
{
- if (!IsEnabled) return;
- if (!e.GetCurrentPoint(null).Properties.IsLeftButtonPressed) return;
- if (Target is not Visual visual) return;
+ if (!IsEnabled)
+ return;
- var pos = e.GetPosition(visual);
- var edgeSize = EdgeSize;
+ var point = e.GetCurrentPoint(null);
- if (edgeSize > 0)
+ if ((e.Pointer.Type is PointerType.Touch or PointerType.Pen ||
+ (IsMouseEnabled && e.Pointer.Type == PointerType.Mouse))
+ && point.Properties.IsLeftButtonPressed)
{
- bool isRtl = visual.FlowDirection == FlowDirection.RightToLeft;
- bool inEdge = isRtl
- ? pos.X >= visual.Bounds.Width - edgeSize
- : pos.X <= edgeSize;
- if (!inEdge)
- {
- Logger.TryGet(LogEventLevel.Verbose, LogArea.Control)?.Log(
- this, "SwipeGestureRecognizer: press at {Pos} outside edge zone ({EdgeSize}px), ignoring",
- pos, edgeSize);
- return;
- }
+ EndGesture();
+ _tracking = e.Pointer;
+ _id = SwipeGestureEventArgs.GetNextFreeId();
+ _trackedRootPoint = point.Position;
+ _velocity = default;
+ _lastTimestamp = 0;
}
-
- _gestureId = SwipeGestureEventArgs.GetNextFreeId();
- _tracking = e.Pointer;
- _initialPosition = pos;
-
- Logger.TryGet(LogEventLevel.Verbose, LogArea.Control)?.Log(
- this, "SwipeGestureRecognizer: tracking started at {Pos} (pointer={PointerType})",
- pos, e.Pointer.Type);
}
+ ///
protected override void PointerMoved(PointerEventArgs e)
{
- if (_tracking != e.Pointer || Target is not Visual visual) return;
-
- var pos = e.GetPosition(visual);
- double dx = pos.X - _initialPosition.X;
- double dy = pos.Y - _initialPosition.Y;
- double absDx = Math.Abs(dx);
- double absDy = Math.Abs(dy);
- double threshold = Threshold;
-
- if (absDx < threshold && absDy < threshold)
- return;
-
- SwipeDirection dir;
- Vector delta;
- if (absDx >= absDy)
+ if (e.Pointer == _tracking)
{
- dir = dx > 0 ? SwipeDirection.Right : SwipeDirection.Left;
- delta = new Vector(dx, 0);
- }
- else
- {
- dir = dy > 0 ? SwipeDirection.Down : SwipeDirection.Up;
- delta = new Vector(0, dy);
- }
+ var rootPoint = e.GetPosition(null);
+ var threshold = GetEffectiveThreshold();
- Logger.TryGet(LogEventLevel.Verbose, LogArea.Control)?.Log(
- this, "SwipeGestureRecognizer: swipe recognized — direction={Direction}, delta={Delta}",
- dir, delta);
+ if (!_swiping)
+ {
+ var horizontalTriggered = CanHorizontallySwipe && Math.Abs(_trackedRootPoint.X - rootPoint.X) > threshold;
+ var verticalTriggered = CanVerticallySwipe && Math.Abs(_trackedRootPoint.Y - rootPoint.Y) > threshold;
+
+ if (horizontalTriggered || verticalTriggered)
+ {
+ _swiping = true;
+
+ _trackedRootPoint = new Point(
+ horizontalTriggered
+ ? _trackedRootPoint.X - (_trackedRootPoint.X >= rootPoint.X ? threshold : -threshold)
+ : rootPoint.X,
+ verticalTriggered
+ ? _trackedRootPoint.Y - (_trackedRootPoint.Y >= rootPoint.Y ? threshold : -threshold)
+ : rootPoint.Y);
+
+ Capture(e.Pointer);
+ }
+ }
- _tracking = null;
- _captured = e.Pointer;
- Capture(e.Pointer);
- e.Handled = true;
+ if (_swiping)
+ {
+ var delta = _trackedRootPoint - rootPoint;
+
+ var now = Stopwatch.GetTimestamp();
+ if (_lastTimestamp > 0)
+ {
+ var elapsedSeconds = (double)(now - _lastTimestamp) / Stopwatch.Frequency;
+ if (elapsedSeconds > 0)
+ {
+ var instantVelocity = delta / elapsedSeconds;
+ _velocity = _velocity * 0.5 + instantVelocity * 0.5;
+ }
+ }
+ _lastTimestamp = now;
+
+ Target!.RaiseEvent(new SwipeGestureEventArgs(_id, delta, _velocity));
+ _trackedRootPoint = rootPoint;
+ e.Handled = true;
+ }
+ }
+ }
- var args = new SwipeGestureEventArgs(_gestureId, dir, delta, _initialPosition);
- Target?.RaiseEvent(args);
+ ///
+ protected override void PointerCaptureLost(IPointer pointer)
+ {
+ if (pointer == _tracking)
+ EndGesture();
}
+ ///
protected override void PointerReleased(PointerReleasedEventArgs e)
{
- if (_tracking == e.Pointer)
+ if (e.Pointer == _tracking && _swiping)
{
- Logger.TryGet(LogEventLevel.Verbose, LogArea.Control)?.Log(
- this, "SwipeGestureRecognizer: pointer released without crossing threshold — gesture discarded");
- _tracking = null;
+ e.Handled = true;
+ EndGesture();
}
+ }
- if (_captured == e.Pointer)
+ private void EndGesture()
+ {
+ _tracking = null;
+ if (_swiping)
{
- (e.Pointer as Pointer)?.CaptureGestureRecognizer(null);
- _captured = null;
+ _swiping = false;
+ var endedArgs = new SwipeGestureEndedEventArgs(_id, _velocity);
+ _velocity = default;
+ _lastTimestamp = 0;
+ _id = 0;
+ Target!.RaiseEvent(endedArgs);
}
}
- protected override void PointerCaptureLost(IPointer pointer)
+ private const double DefaultTapSize = 10;
+
+ private double GetEffectiveThreshold()
{
- if (_tracking == pointer)
- {
- Logger.TryGet(LogEventLevel.Verbose, LogArea.Control)?.Log(
- this, "SwipeGestureRecognizer: capture lost — gesture cancelled");
- _tracking = null;
- }
- _captured = null;
+ var configured = Threshold;
+ if (configured > 0)
+ return configured;
+
+ var tapSize = AvaloniaLocator.Current?.GetService()
+ ?.GetTapSize(PointerType.Touch).Height ?? DefaultTapSize;
+
+ return tapSize / 2;
}
}
}
diff --git a/src/Avalonia.Base/Input/Gestures.cs b/src/Avalonia.Base/Input/Gestures.cs
index 3ae504a77f..07c9ab18be 100644
--- a/src/Avalonia.Base/Input/Gestures.cs
+++ b/src/Avalonia.Base/Input/Gestures.cs
@@ -30,14 +30,12 @@ namespace Avalonia.Input
private static readonly WeakReference
public static readonly StyledProperty MarginProperty =
- AvaloniaProperty.Register(nameof(Margin));
+ AvaloniaProperty.Register(nameof(Margin), validate: ValidateThickness);
///
/// Defines the property.
@@ -161,6 +161,8 @@ namespace Avalonia.Layout
private static bool ValidateMinimumDimension(double value) => !double.IsPositiveInfinity(value) && ValidateMaximumDimension(value);
private static bool ValidateMaximumDimension(double value) => value >= 0;
+ private static bool ValidateThickness(Thickness value) => double.IsFinite(value.Left) && double.IsFinite(value.Top) && double.IsFinite(value.Right) && double.IsFinite(value.Bottom);
+
///
/// Occurs when the element's effective viewport changes.
///
diff --git a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.ComputedProperties.cs b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.ComputedProperties.cs
index ed8860e04a..e2ce331318 100644
--- a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.ComputedProperties.cs
+++ b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.ComputedProperties.cs
@@ -50,7 +50,7 @@ partial class ServerCompositionVisual
private LtrbRect? _ownClipRect;
- private bool _hasExtraDirtyRect;
+ private bool _needsToAddExtraDirtyRectToDirtyRegion;
private LtrbRect _extraDirtyRect;
public virtual LtrbRect? ComputeOwnContentBounds() => null;
@@ -107,7 +107,7 @@ partial class ServerCompositionVisual
_isDirtyForRender |= dirtyForRender;
// If node itself is dirty for render, we don't need to keep track of extra dirty rects
- _hasExtraDirtyRect = !dirtyForRender && (_hasExtraDirtyRect || additionalDirtyRegion);
+ _needsToAddExtraDirtyRectToDirtyRegion = !dirtyForRender && (_needsToAddExtraDirtyRectToDirtyRegion || additionalDirtyRegion);
}
public void RecomputeOwnProperties()
diff --git a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.DirtyInputs.cs b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.DirtyInputs.cs
index 8352fc70e2..35debea184 100644
--- a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.DirtyInputs.cs
+++ b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.DirtyInputs.cs
@@ -166,7 +166,7 @@ partial class ServerCompositionVisual
protected void AddExtraDirtyRect(LtrbRect rect)
{
- _extraDirtyRect = _hasExtraDirtyRect ? _extraDirtyRect.Union(rect) : rect;
+ _extraDirtyRect = _delayPropagateHasExtraDirtyRects ? _extraDirtyRect.Union(rect) : rect;
_delayPropagateHasExtraDirtyRects = true;
EnqueueOwnPropertiesRecompute();
}
diff --git a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.Update.cs b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.Update.cs
index b8322225bd..f9b65e01e0 100644
--- a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.Update.cs
+++ b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.Update.cs
@@ -56,7 +56,7 @@ internal partial class ServerCompositionVisual
private bool NeedToPushBoundsAffectingProperties(ServerCompositionVisual node)
{
- return (node._isDirtyForRenderInSubgraph || node._hasExtraDirtyRect || node._contentChanged);
+ return (node._isDirtyForRenderInSubgraph || node._needsToAddExtraDirtyRectToDirtyRegion || node._contentChanged);
}
public void PreSubgraph(ServerCompositionVisual node, out bool visitChildren)
@@ -142,7 +142,7 @@ internal partial class ServerCompositionVisual
// specified before the tranform, i.e. in inner space, hence we have to pick them
// up before we pop the transform from the transform stack.
//
- if (node._hasExtraDirtyRect)
+ if (node._needsToAddExtraDirtyRectToDirtyRegion)
{
AddToDirtyRegion(node._extraDirtyRect);
}
@@ -169,7 +169,7 @@ internal partial class ServerCompositionVisual
node._isDirtyForRender = false;
node._isDirtyForRenderInSubgraph = false;
node._needsBoundingBoxUpdate = false;
- node._hasExtraDirtyRect = false;
+ node._needsToAddExtraDirtyRectToDirtyRegion = false;
node._contentChanged = false;
}
diff --git a/src/Avalonia.Controls/Border.cs b/src/Avalonia.Controls/Border.cs
index b816858632..29a31d8070 100644
--- a/src/Avalonia.Controls/Border.cs
+++ b/src/Avalonia.Controls/Border.cs
@@ -38,7 +38,7 @@ namespace Avalonia.Controls
/// Defines the property.
///
public static readonly StyledProperty BorderThicknessProperty =
- AvaloniaProperty.Register(nameof(BorderThickness));
+ AvaloniaProperty.Register(nameof(BorderThickness), validate: MarginProperty.ValidateValue);
///
/// Defines the property.
diff --git a/src/Avalonia.Controls/Carousel.cs b/src/Avalonia.Controls/Carousel.cs
index 533f7bb626..bf22671462 100644
--- a/src/Avalonia.Controls/Carousel.cs
+++ b/src/Avalonia.Controls/Carousel.cs
@@ -1,11 +1,13 @@
using Avalonia.Animation;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
+using Avalonia.Input;
namespace Avalonia.Controls
{
///
- /// An items control that displays its items as pages that fill the control.
+ /// An items control that displays its items as pages and can reveal adjacent pages
+ /// using .
///
public class Carousel : SelectingItemsControl
{
@@ -16,13 +18,36 @@ namespace Avalonia.Controls
AvaloniaProperty.Register(nameof(PageTransition));
///
- /// The default value of for
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty IsSwipeEnabledProperty =
+ AvaloniaProperty.Register(nameof(IsSwipeEnabled), defaultValue: false);
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty ViewportFractionProperty =
+ AvaloniaProperty.Register(
+ nameof(ViewportFraction),
+ defaultValue: 1d,
+ coerce: (_, value) => double.IsFinite(value) && value > 0 ? value : 1d);
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly DirectProperty IsSwipingProperty =
+ AvaloniaProperty.RegisterDirect(nameof(IsSwiping),
+ o => o.IsSwiping);
+
+ ///
+ /// The default value of for
/// .
///
private static readonly FuncTemplate DefaultPanel =
new(() => new VirtualizingCarouselPanel());
private IScrollable? _scroller;
+ private bool _isSwiping;
///
/// Initializes static members of the class.
@@ -42,15 +67,51 @@ namespace Avalonia.Controls
set => SetValue(PageTransitionProperty, value);
}
+ ///
+ /// Gets or sets whether swipe gestures are enabled for navigating between pages.
+ /// When enabled, mouse pointer events are also accepted in addition to touch and pen.
+ ///
+ public bool IsSwipeEnabled
+ {
+ get => GetValue(IsSwipeEnabledProperty);
+ set => SetValue(IsSwipeEnabledProperty, value);
+ }
+
+ ///
+ /// Gets or sets the fraction of the viewport occupied by each page.
+ /// A value of 1 shows a single full page; values below 1 reveal adjacent pages.
+ ///
+ public double ViewportFraction
+ {
+ get => GetValue(ViewportFractionProperty);
+ set => SetValue(ViewportFractionProperty, value);
+ }
+
+ ///
+ /// Gets a value indicating whether a swipe gesture is currently in progress.
+ ///
+ public bool IsSwiping
+ {
+ get => _isSwiping;
+ internal set => SetAndRaise(IsSwipingProperty, ref _isSwiping, value);
+ }
+
///
/// Moves to the next item in the carousel.
///
public void Next()
{
+ if (ItemCount == 0)
+ return;
+
if (SelectedIndex < ItemCount - 1)
{
++SelectedIndex;
}
+ else if (WrapSelection)
+ {
+ SelectedIndex = 0;
+ }
}
///
@@ -58,18 +119,78 @@ namespace Avalonia.Controls
///
public void Previous()
{
+ if (ItemCount == 0)
+ return;
+
if (SelectedIndex > 0)
{
--SelectedIndex;
}
+ else if (WrapSelection)
+ {
+ SelectedIndex = ItemCount - 1;
+ }
+ }
+
+ internal PageSlide.SlideAxis? GetTransitionAxis()
+ {
+ var transition = PageTransition;
+
+ if (transition is CompositePageTransition composite)
+ {
+ foreach (var t in composite.PageTransitions)
+ {
+ if (t is PageSlide slide)
+ return slide.Orientation;
+ }
+
+ return null;
+ }
+
+ return transition is PageSlide ps ? ps.Orientation : null;
+ }
+
+ internal PageSlide.SlideAxis GetLayoutAxis() => GetTransitionAxis() ?? PageSlide.SlideAxis.Horizontal;
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+
+ if (e.Handled || ItemCount == 0)
+ return;
+
+ var axis = ViewportFraction != 1d ? GetLayoutAxis() : GetTransitionAxis();
+ var isVertical = axis == PageSlide.SlideAxis.Vertical;
+ var isHorizontal = axis == PageSlide.SlideAxis.Horizontal;
+
+ switch (e.Key)
+ {
+ case Key.Left when !isVertical:
+ case Key.Up when !isHorizontal:
+ Previous();
+ e.Handled = true;
+ break;
+ case Key.Right when !isVertical:
+ case Key.Down when !isHorizontal:
+ Next();
+ e.Handled = true;
+ break;
+ case Key.Home:
+ SelectedIndex = 0;
+ e.Handled = true;
+ break;
+ case Key.End:
+ SelectedIndex = ItemCount - 1;
+ e.Handled = true;
+ break;
+ }
}
protected override Size ArrangeOverride(Size finalSize)
{
var result = base.ArrangeOverride(finalSize);
- if (_scroller is not null)
- _scroller.Offset = new(SelectedIndex, 0);
+ SyncScrollOffset();
return result;
}
@@ -84,11 +205,54 @@ namespace Avalonia.Controls
{
base.OnPropertyChanged(change);
- if (change.Property == SelectedIndexProperty && _scroller is not null)
+ if (change.Property == SelectedIndexProperty)
+ {
+ SyncScrollOffset();
+ }
+
+ if (change.Property == IsSwipeEnabledProperty ||
+ change.Property == PageTransitionProperty ||
+ change.Property == ViewportFractionProperty ||
+ change.Property == WrapSelectionProperty)
+ {
+ if (ItemsPanelRoot is VirtualizingCarouselPanel panel)
+ {
+ if (change.Property == ViewportFractionProperty && !panel.IsManagingInteractionOffset)
+ panel.SyncSelectionOffset(SelectedIndex);
+
+ panel.RefreshGestureRecognizer();
+ panel.InvalidateMeasure();
+ }
+
+ SyncScrollOffset();
+ }
+ }
+
+ private void SyncScrollOffset()
+ {
+ if (ItemsPanelRoot is VirtualizingCarouselPanel panel)
{
- var value = change.GetNewValue();
- _scroller.Offset = new(value, 0);
+ if (panel.IsManagingInteractionOffset)
+ return;
+
+ panel.SyncSelectionOffset(SelectedIndex);
+
+ if (ViewportFraction != 1d)
+ return;
}
+
+ if (_scroller is null)
+ return;
+
+ _scroller.Offset = CreateScrollOffset(SelectedIndex);
+ }
+
+ private Vector CreateScrollOffset(int index)
+ {
+ if (ViewportFraction != 1d && GetLayoutAxis() == PageSlide.SlideAxis.Vertical)
+ return new(0, index);
+
+ return new(index, 0);
}
}
}
diff --git a/src/Avalonia.Controls/Chrome/WindowDrawnDecorations.cs b/src/Avalonia.Controls/Chrome/WindowDrawnDecorations.cs
index ae279d6ab3..48847b5f59 100644
--- a/src/Avalonia.Controls/Chrome/WindowDrawnDecorations.cs
+++ b/src/Avalonia.Controls/Chrome/WindowDrawnDecorations.cs
@@ -58,13 +58,13 @@ public class WindowDrawnDecorations : StyledElement
/// Defines the property.
///
public static readonly StyledProperty DefaultFrameThicknessProperty =
- AvaloniaProperty.Register(nameof(DefaultFrameThickness));
+ AvaloniaProperty.Register(nameof(DefaultFrameThickness), validate: Border.BorderThicknessProperty.ValidateValue);
///
/// Defines the property.
///
public static readonly StyledProperty DefaultShadowThicknessProperty =
- AvaloniaProperty.Register(nameof(DefaultShadowThickness));
+ AvaloniaProperty.Register(nameof(DefaultShadowThickness), validate: Border.BorderThicknessProperty.ValidateValue);
///
/// Defines the property.
diff --git a/src/Avalonia.Controls/Decorator.cs b/src/Avalonia.Controls/Decorator.cs
index e62ca0000b..8cd1916718 100644
--- a/src/Avalonia.Controls/Decorator.cs
+++ b/src/Avalonia.Controls/Decorator.cs
@@ -20,7 +20,7 @@ namespace Avalonia.Controls
/// Defines the property.
///
public static readonly StyledProperty PaddingProperty =
- AvaloniaProperty.Register(nameof(Padding));
+ AvaloniaProperty.Register(nameof(Padding), validate: MarginProperty.ValidateValue);
///
/// Initializes static members of the class.
diff --git a/src/Avalonia.Controls/Embedding/EmbeddableControlRoot.cs b/src/Avalonia.Controls/Embedding/EmbeddableControlRoot.cs
index d4e5488019..59718c3e3f 100644
--- a/src/Avalonia.Controls/Embedding/EmbeddableControlRoot.cs
+++ b/src/Avalonia.Controls/Embedding/EmbeddableControlRoot.cs
@@ -4,6 +4,7 @@ using Avalonia.Automation.Peers;
using Avalonia.Controls.Automation;
using Avalonia.Controls.Automation.Peers;
using Avalonia.Controls.Platform;
+using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Platform;
@@ -54,6 +55,12 @@ namespace Avalonia.Controls.Embedding
protected override Type StyleKeyOverride => typeof(EmbeddableControlRoot);
+ protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
+ {
+ base.OnApplyTemplate(e);
+ EnableVisualLayerManagerLayers();
+ }
+
protected override AutomationPeer OnCreateAutomationPeer()
{
return new EmbeddableControlRootAutomationPeer(this);
diff --git a/src/Avalonia.Controls/Page/DrawerPage.cs b/src/Avalonia.Controls/Page/DrawerPage.cs
index 814e533939..1392e98fbb 100644
--- a/src/Avalonia.Controls/Page/DrawerPage.cs
+++ b/src/Avalonia.Controls/Page/DrawerPage.cs
@@ -211,6 +211,7 @@ namespace Avalonia.Controls
private Border? _topBar;
private ToggleButton? _paneButton;
private Border? _backdrop;
+ private Point _swipeStartPoint;
private IDisposable? _navBarVisibleSub;
private const double EdgeGestureWidth = 20;
@@ -292,6 +293,8 @@ namespace Avalonia.Controls
public DrawerPage()
{
GestureRecognizers.Add(_swipeRecognizer);
+ AddHandler(PointerPressedEvent, OnSwipePointerPressed, handledEventsToo: true);
+ UpdateSwipeRecognizerAxes();
}
///
@@ -617,6 +620,7 @@ namespace Avalonia.Controls
}
else if (change.Property == DrawerPlacementProperty)
{
+ UpdateSwipeRecognizerAxes();
UpdatePanePlacement();
UpdateContentSafeAreaPadding();
}
@@ -664,6 +668,12 @@ namespace Avalonia.Controls
nav.SetDrawerPage(null);
}
+ private void UpdateSwipeRecognizerAxes()
+ {
+ _swipeRecognizer.CanVerticallySwipe = IsVerticalPlacement;
+ _swipeRecognizer.CanHorizontallySwipe = !IsVerticalPlacement;
+ }
+
protected override void OnLoaded(RoutedEventArgs e)
{
base.OnLoaded(e);
@@ -675,6 +685,11 @@ namespace Avalonia.Controls
}
}
+ private void OnSwipePointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ _swipeStartPoint = e.GetPosition(this);
+ }
+
protected override void OnKeyDown(KeyEventArgs e)
{
base.OnKeyDown(e);
@@ -714,8 +729,8 @@ namespace Avalonia.Controls
: EdgeGestureWidth;
bool inEdge = DrawerPlacement == DrawerPlacement.Bottom
- ? e.StartPoint.Y >= Bounds.Height - openGestureEdge
- : e.StartPoint.Y <= openGestureEdge;
+ ? _swipeStartPoint.Y >= Bounds.Height - openGestureEdge
+ : _swipeStartPoint.Y <= openGestureEdge;
if (towardPane && inEdge)
{
@@ -746,8 +761,8 @@ namespace Avalonia.Controls
: EdgeGestureWidth;
bool inEdge = IsPaneOnRight
- ? e.StartPoint.X >= Bounds.Width - openGestureEdge
- : e.StartPoint.X <= openGestureEdge;
+ ? _swipeStartPoint.X >= Bounds.Width - openGestureEdge
+ : _swipeStartPoint.X <= openGestureEdge;
if (towardPane && inEdge)
{
diff --git a/src/Avalonia.Controls/Page/NavigationPage.cs b/src/Avalonia.Controls/Page/NavigationPage.cs
index dd14d71a04..7f496ab10b 100644
--- a/src/Avalonia.Controls/Page/NavigationPage.cs
+++ b/src/Avalonia.Controls/Page/NavigationPage.cs
@@ -68,6 +68,8 @@ namespace Avalonia.Controls
private bool _isBackButtonEffectivelyEnabled;
private DrawerPage? _drawerPage;
private IPageTransition? _overrideTransition;
+ private Point _swipeStartPoint;
+ private int _lastSwipeGestureId;
private bool _hasOverrideTransition;
private readonly HashSet _pageSet = new(ReferenceEqualityComparer.Instance);
@@ -257,7 +259,12 @@ namespace Avalonia.Controls
public NavigationPage()
{
SetCurrentValue(PagesProperty, new Stack());
- GestureRecognizers.Add(new SwipeGestureRecognizer { EdgeSize = EdgeGestureWidth });
+ GestureRecognizers.Add(new SwipeGestureRecognizer
+ {
+ CanHorizontallySwipe = true,
+ CanVerticallySwipe = false
+ });
+ AddHandler(PointerPressedEvent, OnSwipePointerPressed, handledEventsToo: true);
}
///
@@ -1871,18 +1878,31 @@ namespace Avalonia.Controls
private void OnSwipeGesture(object? sender, SwipeGestureEventArgs e)
{
- if (!IsGestureEnabled || StackDepth <= 1 || _isNavigating || _modalStack.Count > 0)
+ if (!IsGestureEnabled || StackDepth <= 1 || _isNavigating || _modalStack.Count > 0 || e.Id == _lastSwipeGestureId)
+ return;
+
+ bool inEdge = IsRtl
+ ? _swipeStartPoint.X >= Bounds.Width - EdgeGestureWidth
+ : _swipeStartPoint.X <= EdgeGestureWidth;
+ if (!inEdge)
return;
+
bool shouldPop = IsRtl
? e.SwipeDirection == SwipeDirection.Left
: e.SwipeDirection == SwipeDirection.Right;
if (shouldPop)
{
e.Handled = true;
+ _lastSwipeGestureId = e.Id;
_ = PopAsync();
}
}
+ private void OnSwipePointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ _swipeStartPoint = e.GetPosition(this);
+ }
+
protected override void OnKeyDown(KeyEventArgs e)
{
base.OnKeyDown(e);
diff --git a/src/Avalonia.Controls/Page/Page.cs b/src/Avalonia.Controls/Page/Page.cs
index 601af92580..48b7bd1b0c 100644
--- a/src/Avalonia.Controls/Page/Page.cs
+++ b/src/Avalonia.Controls/Page/Page.cs
@@ -17,7 +17,7 @@ namespace Avalonia.Controls
/// Defines the property.
///
public static readonly StyledProperty SafeAreaPaddingProperty =
- AvaloniaProperty.Register(nameof(SafeAreaPadding));
+ AvaloniaProperty.Register(nameof(SafeAreaPadding), validate: PaddingProperty.ValidateValue);
///
/// Defines the property.
diff --git a/src/Avalonia.Controls/Page/TabbedPage.cs b/src/Avalonia.Controls/Page/TabbedPage.cs
index 6a5422b365..8fccb45223 100644
--- a/src/Avalonia.Controls/Page/TabbedPage.cs
+++ b/src/Avalonia.Controls/Page/TabbedPage.cs
@@ -26,6 +26,7 @@ namespace Avalonia.Controls
private TabControl? _tabControl;
private readonly Dictionary _containerPageMap = new();
private readonly Dictionary _pageContainerMap = new();
+ private int _lastSwipeGestureId;
private readonly SwipeGestureRecognizer _swipeRecognizer = new SwipeGestureRecognizer
{
IsEnabled = false
@@ -92,6 +93,7 @@ namespace Avalonia.Controls
Focusable = true;
GestureRecognizers.Add(_swipeRecognizer);
AddHandler(InputElement.SwipeGestureEvent, OnSwipeGesture);
+ UpdateSwipeRecognizerAxes();
}
///
@@ -194,7 +196,10 @@ namespace Avalonia.Controls
base.OnPropertyChanged(change);
if (change.Property == TabPlacementProperty)
+ {
ApplyTabPlacement();
+ UpdateSwipeRecognizerAxes();
+ }
else if (change.Property == PageTransitionProperty && _tabControl != null)
_tabControl.PageTransition = change.GetNewValue();
else if (change.Property == IndicatorTemplateProperty)
@@ -227,6 +232,14 @@ namespace Avalonia.Controls
};
}
+ private void UpdateSwipeRecognizerAxes()
+ {
+ var placement = ResolveTabPlacement();
+ var isHorizontal = placement == TabPlacement.Top || placement == TabPlacement.Bottom;
+ _swipeRecognizer.CanHorizontallySwipe = isHorizontal;
+ _swipeRecognizer.CanVerticallySwipe = !isHorizontal;
+ }
+
private void ApplyIndicatorTemplate()
{
if (_tabControl == null)
@@ -500,7 +513,8 @@ namespace Avalonia.Controls
private void OnSwipeGesture(object? sender, SwipeGestureEventArgs e)
{
- if (!IsGestureEnabled || _tabControl == null) return;
+ if (!IsGestureEnabled || _tabControl == null || e.Id == _lastSwipeGestureId)
+ return;
var placement = ResolveTabPlacement();
bool isHorizontal = placement == TabPlacement.Top || placement == TabPlacement.Bottom;
@@ -524,6 +538,7 @@ namespace Avalonia.Controls
{
_tabControl.SelectedIndex = next;
e.Handled = true;
+ _lastSwipeGestureId = e.Id;
}
}
diff --git a/src/Avalonia.Controls/PresentationSource/PresentationSource.cs b/src/Avalonia.Controls/PresentationSource/PresentationSource.cs
index c98a380640..9917f82c93 100644
--- a/src/Avalonia.Controls/PresentationSource/PresentationSource.cs
+++ b/src/Avalonia.Controls/PresentationSource/PresentationSource.cs
@@ -61,7 +61,7 @@ internal partial class PresentationSource : IPresentationSource, IInputRoot, IDi
field?.SetPresentationSourceForRootVisual(this);
Renderer.CompositionTarget.Root = field?.CompositionVisual;
- FocusManager.SetContentRoot(value as IInputElement);
+ FocusManager.ContentRoot = value;
}
}
@@ -152,4 +152,4 @@ internal partial class PresentationSource : IPresentationSource, IInputRoot, IDi
}
return null;
}
-}
\ No newline at end of file
+}
diff --git a/src/Avalonia.Controls/Presenters/PanelContainerGenerator.cs b/src/Avalonia.Controls/Presenters/PanelContainerGenerator.cs
index b0e2af2f3a..8fbcc24346 100644
--- a/src/Avalonia.Controls/Presenters/PanelContainerGenerator.cs
+++ b/src/Avalonia.Controls/Presenters/PanelContainerGenerator.cs
@@ -19,7 +19,7 @@ namespace Avalonia.Controls.Presenters
public PanelContainerGenerator(ItemsPresenter presenter)
{
Debug.Assert(presenter.ItemsControl is not null);
- Debug.Assert(presenter.Panel is not null or VirtualizingPanel);
+ Debug.Assert(presenter.Panel is not (null or VirtualizingPanel));
_presenter = presenter;
_presenter.ItemsControl.ItemsView.PostCollectionChanged += OnItemsChanged;
diff --git a/src/Avalonia.Controls/Primitives/AdornerLayer.cs b/src/Avalonia.Controls/Primitives/AdornerLayer.cs
index 8d3d97b94f..1c8b24f627 100644
--- a/src/Avalonia.Controls/Primitives/AdornerLayer.cs
+++ b/src/Avalonia.Controls/Primitives/AdornerLayer.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Specialized;
+using System.Linq;
using Avalonia.Input.TextInput;
using Avalonia.Media;
using Avalonia.Reactive;
@@ -71,7 +72,32 @@ namespace Avalonia.Controls.Primitives
public static AdornerLayer? GetAdornerLayer(Visual visual)
{
- return visual.FindAncestorOfType()?.AdornerLayer;
+ // Check if the visual is inside an OverlayLayer with a dedicated AdornerLayer
+ foreach (var ancestor in visual.GetVisualAncestors())
+ {
+ if (GetDirectAdornerLayer(ancestor) is { } adornerLayer)
+ return adornerLayer;
+ }
+
+ if (TopLevel.GetTopLevel(visual) is { } topLevel)
+ {
+ foreach (var descendant in topLevel.GetVisualDescendants())
+ {
+ if (GetDirectAdornerLayer(descendant) is { } adornerLayer)
+ return adornerLayer;
+ }
+ }
+
+ return null;
+
+ static AdornerLayer? GetDirectAdornerLayer(Visual visual)
+ {
+ if (visual is OverlayLayer { AdornerLayer: { } adornerLayer })
+ return adornerLayer;
+ if (visual is VisualLayerManager vlm)
+ return vlm.AdornerLayer;
+ return null;
+ }
}
public static bool GetIsClipEnabled(Visual adorner)
diff --git a/src/Avalonia.Controls/Primitives/OverlayLayer.cs b/src/Avalonia.Controls/Primitives/OverlayLayer.cs
index 3337288a13..a9d9b072f2 100644
--- a/src/Avalonia.Controls/Primitives/OverlayLayer.cs
+++ b/src/Avalonia.Controls/Primitives/OverlayLayer.cs
@@ -13,6 +13,11 @@ namespace Avalonia.Controls.Primitives
public Size AvailableSize { get; private set; }
+ ///
+ /// Gets the dedicated adorner layer for this overlay layer.
+ ///
+ internal AdornerLayer? AdornerLayer { get; set; }
+
internal OverlayLayer()
{
}
diff --git a/src/Avalonia.Controls/Primitives/SelectionHandleType.cs b/src/Avalonia.Controls/Primitives/SelectionHandleType.cs
index 2e1955de26..58b2b01f97 100644
--- a/src/Avalonia.Controls/Primitives/SelectionHandleType.cs
+++ b/src/Avalonia.Controls/Primitives/SelectionHandleType.cs
@@ -3,7 +3,7 @@
///
/// Represents which part of the selection the TextSelectionHandle controls.
///
- public enum SelectionHandleType
+ internal enum SelectionHandleType
{
///
/// The Handle controls the caret position.
diff --git a/src/Avalonia.Controls/Primitives/TextSearch.cs b/src/Avalonia.Controls/Primitives/TextSearch.cs
index aa83266683..31e471845b 100644
--- a/src/Avalonia.Controls/Primitives/TextSearch.cs
+++ b/src/Avalonia.Controls/Primitives/TextSearch.cs
@@ -1,6 +1,5 @@
using Avalonia.Controls.Utils;
using Avalonia.Data;
-using Avalonia.Interactivity;
namespace Avalonia.Controls.Primitives
{
@@ -15,47 +14,47 @@ namespace Avalonia.Controls.Primitives
/// This property is usually applied to an item container directly.
///
public static readonly AttachedProperty TextProperty
- = AvaloniaProperty.RegisterAttached("Text", typeof(TextSearch));
+ = AvaloniaProperty.RegisterAttached("Text", typeof(TextSearch));
///
/// Defines the TextBinding attached property.
/// The binding will be applied to each item during text search in (such as ).
///
public static readonly AttachedProperty TextBindingProperty
- = AvaloniaProperty.RegisterAttached("TextBinding", typeof(TextSearch));
+ = AvaloniaProperty.RegisterAttached("TextBinding", typeof(TextSearch));
///
/// Sets the value of the attached property to a given .
///
- /// The control.
+ /// The control.
/// The search text to set.
- public static void SetText(Interactive control, string? text)
- => control.SetValue(TextProperty, text);
+ public static void SetText(AvaloniaObject element, string? text)
+ => element.SetValue(TextProperty, text);
///
/// Gets the value of the attached property from a given .
///
- /// The control.
+ /// The control.
/// The search text.
- public static string? GetText(Interactive control)
- => control.GetValue(TextProperty);
+ public static string? GetText(AvaloniaObject element)
+ => element.GetValue(TextProperty);
///
- /// Sets the value of the attached property to a given .
+ /// Sets the value of the attached property to a given element.
///
- /// The interactive element.
+ /// The element.
/// The search text binding to set.
- public static void SetTextBinding(Interactive interactive, BindingBase? value)
- => interactive.SetValue(TextBindingProperty, value);
+ public static void SetTextBinding(AvaloniaObject element, BindingBase? value)
+ => element.SetValue(TextBindingProperty, value);
///
- /// Gets the value of the attached property from a given .
+ /// Gets the value of the attached property from a given element.
///
- /// The interactive element.
+ /// The element.
/// The search text binding.
[AssignBinding]
- public static BindingBase? GetTextBinding(Interactive interactive)
- => interactive.GetValue(TextBindingProperty);
+ public static BindingBase? GetTextBinding(AvaloniaObject element)
+ => element.GetValue(TextBindingProperty);
///
/// Gets the effective text of a given item.
@@ -80,9 +79,9 @@ namespace Avalonia.Controls.Primitives
string? text;
- if (item is Interactive interactive)
+ if (item is AvaloniaObject obj)
{
- text = interactive.GetValue(TextProperty);
+ text = obj.GetValue(TextProperty);
if (!string.IsNullOrEmpty(text))
return text;
}
diff --git a/src/Avalonia.Controls/Primitives/VisualLayerManager.cs b/src/Avalonia.Controls/Primitives/VisualLayerManager.cs
index 6630f1e09c..eb912d2cf8 100644
--- a/src/Avalonia.Controls/Primitives/VisualLayerManager.cs
+++ b/src/Avalonia.Controls/Primitives/VisualLayerManager.cs
@@ -3,6 +3,9 @@ using Avalonia.LogicalTree;
namespace Avalonia.Controls.Primitives
{
+ ///
+ /// A control that manages multiple layers such as adorners, overlays, text selectors, and popups.
+ ///
public sealed class VisualLayerManager : Decorator
{
private const int AdornerZIndex = int.MaxValue - 100;
@@ -13,13 +16,37 @@ namespace Avalonia.Controls.Primitives
private ILogicalRoot? _logicalRoot;
private readonly List _layers = new();
-
- public bool IsPopup { get; set; }
-
- internal AdornerLayer AdornerLayer
+ private OverlayLayer? _overlayLayer;
+
+ ///
+ /// Gets or sets a value indicating whether an is
+ /// created for this . When enabled, the adorner layer is added to the
+ /// visual tree, providing a dedicated layer for rendering adorners.
+ ///
+ public bool EnableAdornerLayer { get; set; } = true;
+
+ ///
+ /// Gets or sets a value indicating whether an is
+ /// created for this . When enabled, the overlay layer is added to the
+ /// visual tree, providing a dedicated layer for rendering overlay visuals.
+ ///
+ public bool EnableOverlayLayer { get; set; }
+
+ internal bool EnablePopupOverlayLayer { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether a is
+ /// created for this . When enabled, the overlay layer is added to the
+ /// visual tree, providing a dedicated layer for rendering text selection handles.
+ ///
+ public bool EnableTextSelectorLayer { get; set; }
+
+ internal AdornerLayer? AdornerLayer
{
get
{
+ if (!EnableAdornerLayer)
+ return null;
var rv = FindLayer();
if (rv == null)
AddLayer(rv = new AdornerLayer(), AdornerZIndex);
@@ -31,7 +58,7 @@ namespace Avalonia.Controls.Primitives
{
get
{
- if (IsPopup)
+ if (!EnablePopupOverlayLayer)
return null;
var rv = FindLayer();
if (rv == null)
@@ -44,12 +71,21 @@ namespace Avalonia.Controls.Primitives
{
get
{
- if (IsPopup)
+ if (!EnableOverlayLayer)
return null;
- var rv = FindLayer();
- if (rv == null)
- AddLayer(rv = new OverlayLayer(), OverlayZIndex);
- return rv;
+ if (_overlayLayer == null)
+ {
+ _overlayLayer = new OverlayLayer();
+ var adorner = new AdornerLayer();
+ _overlayLayer.AdornerLayer = adorner;
+
+ var panel = new Panel();
+ panel.Children.Add(_overlayLayer);
+ panel.Children.Add(adorner);
+
+ AddLayer(panel, OverlayZIndex);
+ }
+ return _overlayLayer;
}
}
@@ -57,7 +93,7 @@ namespace Avalonia.Controls.Primitives
{
get
{
- if (IsPopup)
+ if (!EnableTextSelectorLayer)
return null;
var rv = FindLayer();
if (rv == null)
diff --git a/src/Avalonia.Controls/TopLevel.cs b/src/Avalonia.Controls/TopLevel.cs
index 8556d03d91..ceb9590564 100644
--- a/src/Avalonia.Controls/TopLevel.cs
+++ b/src/Avalonia.Controls/TopLevel.cs
@@ -38,6 +38,7 @@ namespace Avalonia.Controls
/// tracking the widget's .
///
[TemplatePart("PART_TransparencyFallback", typeof(Border))]
+ [TemplatePart("PART_VisualLayerManager", typeof(VisualLayerManager))]
public abstract class TopLevel : ContentControl,
ICloseable,
IStyleHost,
@@ -125,6 +126,7 @@ namespace Avalonia.Controls
private Size? _frameSize;
private WindowTransparencyLevel _actualTransparencyLevel;
private Border? _transparencyFallbackBorder;
+ private VisualLayerManager? _visualLayerManager;
private TargetWeakEventSubscriber? _resourcesChangesSubscriber;
private IStorageProvider? _storageProvider;
private Screens? _screens;
@@ -133,6 +135,18 @@ namespace Avalonia.Controls
internal TopLevelHost TopLevelHost => _topLevelHost;
internal new PresentationSource PresentationSource => _source;
internal IInputRoot InputRoot => _source;
+
+ private protected VisualLayerManager? VisualLayerManager => _visualLayerManager;
+
+ private protected void EnableVisualLayerManagerLayers()
+ {
+ if (_visualLayerManager is { } vlm)
+ {
+ vlm.EnableOverlayLayer = true;
+ vlm.EnablePopupOverlayLayer = true;
+ vlm.EnableTextSelectorLayer = true;
+ }
+ }
///
/// Initializes static members of the class.
@@ -723,6 +737,8 @@ namespace Avalonia.Controls
{
base.OnApplyTemplate(e);
+ _visualLayerManager = e.NameScope.Find("PART_VisualLayerManager");
+
if (PlatformImpl is null)
return;
diff --git a/src/Avalonia.Controls/VirtualizingCarouselPanel.cs b/src/Avalonia.Controls/VirtualizingCarouselPanel.cs
index 454069b4b2..875c2dda5a 100644
--- a/src/Avalonia.Controls/VirtualizingCarouselPanel.cs
+++ b/src/Avalonia.Controls/VirtualizingCarouselPanel.cs
@@ -2,11 +2,17 @@
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Diagnostics;
+using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Animation;
+using Avalonia.Animation.Easings;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
+using Avalonia.Input.GestureRecognizers;
+using Avalonia.Media;
+using Avalonia.Styling;
+using Avalonia.Utilities;
namespace Avalonia.Controls
{
@@ -15,23 +21,76 @@ namespace Avalonia.Controls
///
public class VirtualizingCarouselPanel : VirtualizingPanel, ILogicalScrollable
{
+ private sealed class ViewportRealizedItem
+ {
+ public ViewportRealizedItem(int itemIndex, Control control)
+ {
+ ItemIndex = itemIndex;
+ Control = control;
+ }
+
+ public int ItemIndex { get; }
+ public Control Control { get; }
+ }
+
private static readonly AttachedProperty RecycleKeyProperty =
- AvaloniaProperty.RegisterAttached("RecycleKey");
+ AvaloniaProperty.RegisterAttached("RecycleKey");
private static readonly object s_itemIsItsOwnContainer = new object();
private Size _extent;
private Vector _offset;
private Size _viewport;
private Dictionary>? _recyclePool;
+ private readonly Dictionary _viewportRealized = new();
private Control? _realized;
private int _realizedIndex = -1;
private Control? _transitionFrom;
private int _transitionFromIndex = -1;
private CancellationTokenSource? _transition;
+ private Task? _transitionTask;
private EventHandler? _scrollInvalidated;
private bool _canHorizontallyScroll;
private bool _canVerticallyScroll;
+ private SwipeGestureRecognizer? _swipeGestureRecognizer;
+ private int _swipeGestureId;
+ private bool _isDragging;
+ private double _totalDelta;
+ private bool _isForward;
+ private Control? _swipeTarget;
+ private int _swipeTargetIndex = -1;
+ private PageSlide.SlideAxis? _swipeAxis;
+ private PageSlide.SlideAxis _lockedAxis;
+
+ private const double SwipeCommitThreshold = 0.25;
+ private const double VelocityCommitThreshold = 800;
+ private const double MinSwipeDistanceForVelocityCommit = 0.05;
+ private const double RubberBandFactor = 0.3;
+ private const double RubberBandReturnDuration = 0.16;
+ private const double MaxCompletionDuration = 0.35;
+ private const double MinCompletionDuration = 0.12;
+
+ private static readonly StyledProperty CompletionProgressProperty =
+ AvaloniaProperty.Register("CompletionProgress");
+ private static readonly StyledProperty OffsetAnimationProgressProperty =
+ AvaloniaProperty.Register("OffsetAnimationProgress");
+
+ private CancellationTokenSource? _completionCts;
+ private CancellationTokenSource? _offsetAnimationCts;
+ private double _completionEndProgress;
+ private bool _isRubberBanding;
+ private double _dragStartOffset;
+ private double _progressStartOffset;
+ private double _offsetAnimationStart;
+ private double _offsetAnimationTarget;
+ private double _activeViewportTargetOffset;
+ private int _progressFromIndex = -1;
+ private int _progressToIndex = -1;
+
+ internal bool IsManagingInteractionOffset =>
+ UsesViewportFractionLayout() &&
+ (_isDragging || _offsetAnimationCts is { IsCancellationRequested: false });
+
bool ILogicalScrollable.CanHorizontallyScroll
{
get => _canHorizontallyScroll;
@@ -55,12 +114,7 @@ namespace Avalonia.Controls
Vector IScrollable.Offset
{
get => _offset;
- set
- {
- if ((int)_offset.X != value.X)
- InvalidateMeasure();
- _offset = value;
- }
+ set => SetOffset(value);
}
private Size Extent
@@ -99,37 +153,342 @@ namespace Avalonia.Controls
Control? ILogicalScrollable.GetControlInDirection(NavigationDirection direction, Control? from) => null;
void ILogicalScrollable.RaiseScrollInvalidated(EventArgs e) => _scrollInvalidated?.Invoke(this, e);
+ private bool UsesViewportFractionLayout()
+ {
+ return ItemsControl is Carousel carousel &&
+ !MathUtilities.AreClose(carousel.ViewportFraction, 1d);
+ }
+
+ private PageSlide.SlideAxis GetLayoutAxis()
+ {
+ return (ItemsControl as Carousel)?.GetLayoutAxis() ?? PageSlide.SlideAxis.Horizontal;
+ }
+
+ private double GetViewportFraction()
+ {
+ return (ItemsControl as Carousel)?.ViewportFraction ?? 1d;
+ }
+
+ private double GetViewportUnits()
+ {
+ return 1d / GetViewportFraction();
+ }
+
+ private double GetPrimaryOffset(Vector offset)
+ {
+ return GetLayoutAxis() == PageSlide.SlideAxis.Vertical ? offset.Y : offset.X;
+ }
+
+ private double GetPrimarySize(Size size)
+ {
+ return GetLayoutAxis() == PageSlide.SlideAxis.Vertical ? size.Height : size.Width;
+ }
+
+ private double GetCrossSize(Size size)
+ {
+ return GetLayoutAxis() == PageSlide.SlideAxis.Vertical ? size.Width : size.Height;
+ }
+
+ private Size CreateLogicalSize(double primary)
+ {
+ return GetLayoutAxis() == PageSlide.SlideAxis.Vertical ?
+ new Size(1, primary) :
+ new Size(primary, 1);
+ }
+
+ private Size CreateItemSize(double primary, double cross)
+ {
+ return GetLayoutAxis() == PageSlide.SlideAxis.Vertical ?
+ new Size(cross, primary) :
+ new Size(primary, cross);
+ }
+
+ private Rect CreateItemRect(double primaryOffset, double primarySize, double crossSize)
+ {
+ return GetLayoutAxis() == PageSlide.SlideAxis.Vertical ?
+ new Rect(0, primaryOffset, crossSize, primarySize) :
+ new Rect(primaryOffset, 0, primarySize, crossSize);
+ }
+
+ private Vector WithPrimaryOffset(Vector offset, double primary)
+ {
+ return GetLayoutAxis() == PageSlide.SlideAxis.Vertical ?
+ new Vector(offset.X, primary) :
+ new Vector(primary, offset.Y);
+ }
+
+ private Size ResolveLayoutSize(Size availableSize)
+ {
+ var owner = ItemsControl as Control;
+
+ double ResolveDimension(double available, double bounds, double ownerBounds, double ownerExplicit)
+ {
+ if (!double.IsInfinity(available) && available > 0)
+ return available;
+
+ if (bounds > 0)
+ return bounds;
+
+ if (ownerBounds > 0)
+ return ownerBounds;
+
+ return double.IsNaN(ownerExplicit) ? 0 : ownerExplicit;
+ }
+
+ var width = ResolveDimension(availableSize.Width, Bounds.Width, owner?.Bounds.Width ?? 0, owner?.Width ?? double.NaN);
+ var height = ResolveDimension(availableSize.Height, Bounds.Height, owner?.Bounds.Height ?? 0, owner?.Height ?? double.NaN);
+ return new Size(width, height);
+ }
+
+ private double GetViewportItemExtent(Size size)
+ {
+ var viewportUnits = GetViewportUnits();
+ return viewportUnits <= 0 ? 0 : GetPrimarySize(size) / viewportUnits;
+ }
+
+ private bool UsesViewportWrapLayout()
+ {
+ return UsesViewportFractionLayout() &&
+ ItemsControl is Carousel { WrapSelection: true } &&
+ Items.Count > 1;
+ }
+
+ private static int NormalizeIndex(int index, int count)
+ {
+ return ((index % count) + count) % count;
+ }
+
+ private double GetNearestLogicalOffset(int itemIndex, double referenceOffset)
+ {
+ if (!UsesViewportWrapLayout() || Items.Count == 0)
+ return Math.Clamp(itemIndex, 0, Math.Max(0, Items.Count - 1));
+
+ var wrapSpan = Items.Count;
+ var wrapMultiplier = Math.Round((referenceOffset - itemIndex) / wrapSpan);
+ return itemIndex + (wrapMultiplier * wrapSpan);
+ }
+
+ private bool IsPreferredViewportSlot(int candidateLogicalIndex, int existingLogicalIndex, double primaryOffset)
+ {
+ var candidateDistance = Math.Abs(candidateLogicalIndex - primaryOffset);
+ var existingDistance = Math.Abs(existingLogicalIndex - primaryOffset);
+
+ if (!MathUtilities.AreClose(candidateDistance, existingDistance))
+ return candidateDistance < existingDistance;
+
+ var candidateInRange = candidateLogicalIndex >= 0 && candidateLogicalIndex < Items.Count;
+ var existingInRange = existingLogicalIndex >= 0 && existingLogicalIndex < Items.Count;
+
+ if (candidateInRange != existingInRange)
+ return candidateInRange;
+
+ if (_isDragging)
+ return _isForward ? candidateLogicalIndex > existingLogicalIndex : candidateLogicalIndex < existingLogicalIndex;
+
+ return candidateLogicalIndex < existingLogicalIndex;
+ }
+
+ private IReadOnlyList<(int LogicalIndex, int ItemIndex)> GetRequiredViewportSlots(double primaryOffset)
+ {
+ if (Items.Count == 0)
+ return Array.Empty<(int LogicalIndex, int ItemIndex)>();
+
+ var viewportUnits = GetViewportUnits();
+ var edgeInset = (viewportUnits - 1) / 2;
+ var start = (int)Math.Floor(primaryOffset - edgeInset);
+ var end = (int)Math.Ceiling(primaryOffset + viewportUnits - edgeInset) - 1;
+
+ if (!UsesViewportWrapLayout())
+ {
+ start = Math.Max(0, start);
+ end = Math.Min(Items.Count - 1, end);
+
+ if (start > end)
+ return Array.Empty<(int LogicalIndex, int ItemIndex)>();
+
+ var result = new (int LogicalIndex, int ItemIndex)[end - start + 1];
+
+ for (var i = 0; i < result.Length; ++i)
+ {
+ var index = start + i;
+ result[i] = (index, index);
+ }
+
+ return result;
+ }
+
+ var bestSlots = new Dictionary();
+
+ for (var logicalIndex = start; logicalIndex <= end; ++logicalIndex)
+ {
+ var itemIndex = NormalizeIndex(logicalIndex, Items.Count);
+
+ if (!bestSlots.TryGetValue(itemIndex, out var existingLogicalIndex) ||
+ IsPreferredViewportSlot(logicalIndex, existingLogicalIndex, primaryOffset))
+ {
+ bestSlots[itemIndex] = logicalIndex;
+ }
+ }
+
+ return bestSlots
+ .Select(x => (LogicalIndex: x.Value, ItemIndex: x.Key))
+ .OrderBy(x => x.LogicalIndex)
+ .ToArray();
+ }
+
+ private bool ViewportSlotsChanged(double oldPrimaryOffset, double newPrimaryOffset)
+ {
+ var oldSlots = GetRequiredViewportSlots(oldPrimaryOffset);
+ var newSlots = GetRequiredViewportSlots(newPrimaryOffset);
+
+ if (oldSlots.Count != newSlots.Count)
+ return true;
+
+ for (var i = 0; i < oldSlots.Count; ++i)
+ {
+ if (oldSlots[i].LogicalIndex != newSlots[i].LogicalIndex ||
+ oldSlots[i].ItemIndex != newSlots[i].ItemIndex)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private void SetOffset(Vector value)
+ {
+ if (UsesViewportFractionLayout())
+ {
+ var oldPrimaryOffset = GetPrimaryOffset(_offset);
+ var newPrimaryOffset = GetPrimaryOffset(value);
+
+ if (MathUtilities.AreClose(oldPrimaryOffset, newPrimaryOffset))
+ {
+ _offset = value;
+ return;
+ }
+
+ _offset = value;
+
+ var rangeChanged = ViewportSlotsChanged(oldPrimaryOffset, newPrimaryOffset);
+
+ if (rangeChanged)
+ InvalidateMeasure();
+ else
+ InvalidateArrange();
+
+ _scrollInvalidated?.Invoke(this, EventArgs.Empty);
+ return;
+ }
+
+ if ((int)_offset.X != value.X)
+ InvalidateMeasure();
+
+ _offset = value;
+ }
+
+ private void ClearViewportRealized()
+ {
+ if (_viewportRealized.Count == 0)
+ return;
+
+ foreach (var element in _viewportRealized.Values.Select(x => x.Control).ToArray())
+ RecycleElement(element);
+
+ _viewportRealized.Clear();
+ }
+
+ private void ResetSinglePageState()
+ {
+ _transition?.Cancel();
+ _transition = null;
+ _transitionTask = null;
+
+ if (_transitionFrom is not null)
+ RecycleElement(_transitionFrom);
+
+ if (_swipeTarget is not null)
+ RecycleElement(_swipeTarget);
+
+ if (_realized is not null)
+ RecycleElement(_realized);
+
+ _transitionFrom = null;
+ _transitionFromIndex = -1;
+ _swipeTarget = null;
+ _swipeTargetIndex = -1;
+ _realized = null;
+ _realizedIndex = -1;
+ }
+
+ private void CancelOffsetAnimation()
+ {
+ _offsetAnimationCts?.Cancel();
+ _offsetAnimationCts = null;
+ }
+
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ RefreshGestureRecognizer();
+ }
+
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnDetachedFromVisualTree(e);
+ TeardownGestureRecognizer();
+ }
+
+ protected override void OnItemsControlChanged(ItemsControl? oldValue)
+ {
+ base.OnItemsControlChanged(oldValue);
+
+ RefreshGestureRecognizer();
+ }
+
protected override Size MeasureOverride(Size availableSize)
+ {
+ if (UsesViewportFractionLayout())
+ return MeasureViewportFractionOverride(availableSize);
+
+ ClearViewportRealized();
+ CancelOffsetAnimation();
+
+ return MeasureSinglePageOverride(availableSize);
+ }
+
+ private Size MeasureSinglePageOverride(Size availableSize)
{
var items = Items;
var index = (int)_offset.X;
+ CompleteFinishedTransitionIfNeeded();
+
if (index != _realizedIndex)
{
if (_realized is not null)
{
- var cancelTransition = _transition is not null;
-
// Cancel any already running transition, and recycle the element we're transitioning from.
- if (cancelTransition)
+ if (_transition is not null)
{
- _transition!.Cancel();
+ _transition.Cancel();
_transition = null;
+ _transitionTask = null;
if (_transitionFrom is not null)
RecycleElement(_transitionFrom);
_transitionFrom = null;
_transitionFromIndex = -1;
+ ResetTransitionState(_realized);
}
- if (cancelTransition || GetTransition() is null)
+ if (GetTransition() is null)
{
- // If don't have a transition or we've just canceled a transition then recycle the element
- // we're moving from.
RecycleElement(_realized);
}
else
{
- // We have a transition to do: record the current element as the element we're transitioning
+ // Record the current element as the element we're transitioning
// from and we'll start the transition in the arrange pass.
_transitionFrom = _realized;
_transitionFromIndex = _realizedIndex;
@@ -163,6 +522,14 @@ namespace Avalonia.Controls
}
protected override Size ArrangeOverride(Size finalSize)
+ {
+ if (UsesViewportFractionLayout())
+ return ArrangeViewportFractionOverride(finalSize);
+
+ return ArrangeSinglePageOverride(finalSize);
+ }
+
+ private Size ArrangeSinglePageOverride(Size finalSize)
{
var result = base.ArrangeOverride(finalSize);
@@ -180,19 +547,115 @@ namespace Avalonia.Controls
forward = forward && !(_transitionFromIndex == 0 && _realizedIndex == Items.Count - 1);
}
- transition.Start(_transitionFrom, to, forward, _transition.Token)
- .ContinueWith(TransitionFinished, TaskScheduler.FromCurrentSynchronizationContext());
+ _transitionTask = RunTransitionAsync(_transition, _transitionFrom, to, forward, transition);
}
return result;
}
+ private Size MeasureViewportFractionOverride(Size availableSize)
+ {
+ ResetSinglePageState();
+
+ if (Items.Count == 0)
+ {
+ ClearViewportRealized();
+ Extent = Viewport = new(0, 0);
+ return default;
+ }
+
+ var layoutSize = ResolveLayoutSize(availableSize);
+ var primarySize = GetPrimarySize(layoutSize);
+ var crossSize = GetCrossSize(layoutSize);
+ var viewportUnits = GetViewportUnits();
+
+ if (primarySize <= 0 || viewportUnits <= 0)
+ {
+ ClearViewportRealized();
+ Extent = Viewport = new(0, 0);
+ return default;
+ }
+
+ var itemPrimarySize = primarySize / viewportUnits;
+ var itemSize = CreateItemSize(itemPrimarySize, crossSize);
+ var requiredSlots = GetRequiredViewportSlots(GetPrimaryOffset(_offset));
+ var requiredMap = requiredSlots.ToDictionary(x => x.LogicalIndex, x => x.ItemIndex);
+
+ foreach (var entry in _viewportRealized.ToArray())
+ {
+ if (!requiredMap.TryGetValue(entry.Key, out var itemIndex) ||
+ entry.Value.ItemIndex != itemIndex)
+ {
+ RecycleElement(entry.Value.Control);
+ _viewportRealized.Remove(entry.Key);
+ }
+ }
+
+ foreach (var slot in requiredSlots)
+ {
+ if (!_viewportRealized.ContainsKey(slot.LogicalIndex))
+ {
+ _viewportRealized[slot.LogicalIndex] = new ViewportRealizedItem(
+ slot.ItemIndex,
+ GetOrCreateElement(Items, slot.ItemIndex));
+ }
+ }
+
+ var maxCrossDesiredSize = 0d;
+
+ foreach (var element in _viewportRealized.Values.Select(x => x.Control))
+ {
+ element.Measure(itemSize);
+ maxCrossDesiredSize = Math.Max(maxCrossDesiredSize, GetCrossSize(element.DesiredSize));
+ }
+
+ Viewport = CreateLogicalSize(viewportUnits);
+ Extent = CreateLogicalSize(Math.Max(0, Items.Count + viewportUnits - 1));
+
+ var desiredPrimary = double.IsInfinity(primarySize) ? itemPrimarySize * viewportUnits : primarySize;
+ var desiredCross = double.IsInfinity(crossSize) ? maxCrossDesiredSize : crossSize;
+ return CreateItemSize(desiredPrimary, desiredCross);
+ }
+
+ private Size ArrangeViewportFractionOverride(Size finalSize)
+ {
+ var primarySize = GetPrimarySize(finalSize);
+ var crossSize = GetCrossSize(finalSize);
+ var viewportUnits = GetViewportUnits();
+
+ if (primarySize <= 0 || viewportUnits <= 0)
+ return finalSize;
+
+ if (_viewportRealized.Count == 0 && Items.Count > 0)
+ {
+ InvalidateMeasure();
+ return finalSize;
+ }
+
+ var itemPrimarySize = primarySize / viewportUnits;
+ var edgeInset = (viewportUnits - 1) / 2;
+ var primaryOffset = GetPrimaryOffset(_offset);
+
+ foreach (var entry in _viewportRealized.OrderBy(x => x.Key))
+ {
+ var itemOffset = (edgeInset + entry.Key - primaryOffset) * itemPrimarySize;
+ var rect = CreateItemRect(itemOffset, itemPrimarySize, crossSize);
+ entry.Value.Control.IsVisible = true;
+ entry.Value.Control.Arrange(rect);
+ }
+
+ return finalSize;
+ }
+
protected override IInputElement? GetControl(NavigationDirection direction, IInputElement? from, bool wrap) => null;
protected internal override Control? ContainerFromIndex(int index)
{
if (index < 0 || index >= Items.Count)
return null;
+ var viewportRealized = _viewportRealized.Values.FirstOrDefault(x => x.ItemIndex == index);
+ if (viewportRealized is not null)
+ return viewportRealized.Control;
if (index == _realizedIndex)
return _realized;
if (Items[index] is Control c && c.GetValue(RecycleKeyProperty) == s_itemIsItsOwnContainer)
@@ -202,11 +665,20 @@ namespace Avalonia.Controls
protected internal override IEnumerable? GetRealizedContainers()
{
+ if (_viewportRealized.Count > 0)
+ return _viewportRealized.OrderBy(x => x.Key).Select(x => x.Value.Control);
+
return _realized is not null ? new[] { _realized } : null;
}
protected internal override int IndexFromContainer(Control container)
{
+ foreach (var entry in _viewportRealized)
+ {
+ if (ReferenceEquals(entry.Value.Control, container))
+ return entry.Value.ItemIndex;
+ }
+
return container == _realized ? _realizedIndex : -1;
}
@@ -219,8 +691,21 @@ namespace Avalonia.Controls
{
base.OnItemsChanged(items, e);
+ if (UsesViewportFractionLayout() || _viewportRealized.Count > 0)
+ {
+ ClearViewportRealized();
+ InvalidateMeasure();
+ return;
+ }
+
void Add(int index, int count)
{
+ if (_realized is null)
+ {
+ InvalidateMeasure();
+ return;
+ }
+
if (index <= _realizedIndex)
_realizedIndex += count;
}
@@ -314,6 +799,10 @@ namespace Avalonia.Controls
private Control? GetRealizedElement(int index)
{
+ var viewportRealized = _viewportRealized.Values.FirstOrDefault(x => x.ItemIndex == index);
+ if (viewportRealized is not null)
+ return viewportRealized.Control;
+
return _realizedIndex == index ? _realized : null;
}
@@ -379,9 +868,13 @@ namespace Avalonia.Controls
var recycleKey = element.GetValue(RecycleKeyProperty);
Debug.Assert(recycleKey is not null);
+ // Hide first so cleanup doesn't visibly snap transforms/opacity for a frame.
+ element.IsVisible = false;
+ ResetTransitionState(element);
+
if (recycleKey == s_itemIsItsOwnContainer)
{
- element.IsVisible = false;
+ return;
}
else
{
@@ -395,22 +888,769 @@ namespace Avalonia.Controls
}
pool.Push(element);
- element.IsVisible = false;
}
}
private IPageTransition? GetTransition() => (ItemsControl as Carousel)?.PageTransition;
- private void TransitionFinished(Task task)
+ private void CompleteFinishedTransitionIfNeeded()
+ {
+ if (_transition is not null && _transitionTask?.IsCompleted == true)
+ {
+ if (_transitionFrom is not null)
+ RecycleElement(_transitionFrom);
+
+ _transition = null;
+ _transitionTask = null;
+ _transitionFrom = null;
+ _transitionFromIndex = -1;
+ }
+ }
+
+ private async Task RunTransitionAsync(
+ CancellationTokenSource transitionCts,
+ Control transitionFrom,
+ Control transitionTo,
+ bool forward,
+ IPageTransition transition)
{
- if (task.IsCanceled)
+ try
+ {
+ await transition.Start(transitionFrom, transitionTo, forward, transitionCts.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected when a transition is interrupted by a newer navigation action.
+ }
+ catch (Exception e)
+ {
+ _ = e;
+ }
+
+ if (transitionCts.IsCancellationRequested || !ReferenceEquals(_transition, transitionCts))
return;
if (_transitionFrom is not null)
RecycleElement(_transitionFrom);
_transition = null;
+ _transitionTask = null;
_transitionFrom = null;
_transitionFromIndex = -1;
}
+
+ internal void SyncSelectionOffset(int selectedIndex)
+ {
+ if (!UsesViewportFractionLayout())
+ {
+ SetOffset(WithPrimaryOffset(_offset, selectedIndex));
+ return;
+ }
+
+ var currentOffset = GetPrimaryOffset(_offset);
+ var targetOffset = GetNearestLogicalOffset(selectedIndex, currentOffset);
+
+ if (MathUtilities.AreClose(currentOffset, targetOffset))
+ {
+ SetOffset(WithPrimaryOffset(_offset, targetOffset));
+ return;
+ }
+
+ if (_isDragging)
+ return;
+
+ var transition = GetTransition();
+ var canAnimate = transition is not null && Math.Abs(targetOffset - currentOffset) <= 1.001;
+
+ if (!canAnimate)
+ {
+ ResetViewportTransitionState();
+ ClearFractionalProgressContext();
+ SetOffset(WithPrimaryOffset(_offset, targetOffset));
+ return;
+ }
+
+ var fromIndex = Items.Count > 0 ? NormalizeIndex((int)Math.Round(currentOffset), Items.Count) : -1;
+ var forward = targetOffset > currentOffset;
+
+ ResetViewportTransitionState();
+ SetFractionalProgressContext(fromIndex, selectedIndex, forward, currentOffset, targetOffset);
+ _ = AnimateViewportOffsetAsync(
+ currentOffset,
+ targetOffset,
+ TimeSpan.FromSeconds(MaxCompletionDuration),
+ new QuadraticEaseOut(),
+ () =>
+ {
+ ResetViewportTransitionState();
+ ClearFractionalProgressContext();
+
+ // SyncScrollOffset is blocked during animation and the post-animation layout
+ // still sees a live CTS, so re-sync explicitly in case SelectedIndex changed.
+ if (ItemsControl is Carousel carousel)
+ SyncSelectionOffset(carousel.SelectedIndex);
+ });
+ }
+
+ ///
+ /// Refreshes the gesture recognizer based on the carousel's IsSwipeEnabled and PageTransition settings.
+ ///
+ internal void RefreshGestureRecognizer()
+ {
+ TeardownGestureRecognizer();
+
+ if (ItemsControl is not Carousel carousel || !carousel.IsSwipeEnabled)
+ return;
+
+ _swipeAxis = UsesViewportFractionLayout() ? carousel.GetLayoutAxis() : carousel.GetTransitionAxis();
+
+ _swipeGestureRecognizer = new SwipeGestureRecognizer
+ {
+ CanHorizontallySwipe = _swipeAxis != PageSlide.SlideAxis.Vertical,
+ CanVerticallySwipe = _swipeAxis != PageSlide.SlideAxis.Horizontal,
+ IsMouseEnabled = true,
+ };
+
+ GestureRecognizers.Add(_swipeGestureRecognizer);
+ AddHandler(InputElement.SwipeGestureEvent, OnSwipeGesture);
+ AddHandler(InputElement.SwipeGestureEndedEvent, OnSwipeGestureEnded);
+ }
+
+ private void TeardownGestureRecognizer()
+ {
+ _completionCts?.Cancel();
+ _completionCts = null;
+ CancelOffsetAnimation();
+
+ if (_swipeGestureRecognizer is not null)
+ {
+ GestureRecognizers.Remove(_swipeGestureRecognizer);
+ _swipeGestureRecognizer = null;
+ }
+
+ RemoveHandler(InputElement.SwipeGestureEvent, OnSwipeGesture);
+ RemoveHandler(InputElement.SwipeGestureEndedEvent, OnSwipeGestureEnded);
+ ResetSwipeState();
+ }
+
+ private Control? FindViewportControl(int itemIndex)
+ {
+ return _viewportRealized.Values.FirstOrDefault(x => x.ItemIndex == itemIndex)?.Control;
+ }
+
+ private void SetFractionalProgressContext(int fromIndex, int toIndex, bool forward, double startOffset, double targetOffset)
+ {
+ _progressFromIndex = fromIndex;
+ _progressToIndex = toIndex;
+ _isForward = forward;
+ _progressStartOffset = startOffset;
+ _activeViewportTargetOffset = targetOffset;
+ }
+
+ private void ClearFractionalProgressContext()
+ {
+ _progressFromIndex = -1;
+ _progressToIndex = -1;
+ _progressStartOffset = 0;
+ _activeViewportTargetOffset = 0;
+ }
+
+ private double GetFractionalTransitionProgress(double currentOffset)
+ {
+ var totalDistance = Math.Abs(_activeViewportTargetOffset - _progressStartOffset);
+ if (totalDistance <= 0)
+ return 0;
+
+ return Math.Clamp(Math.Abs(currentOffset - _progressStartOffset) / totalDistance, 0, 1);
+ }
+
+ private void ResetViewportTransitionState()
+ {
+ foreach (var element in _viewportRealized.Values.Select(x => x.Control))
+ ResetTransitionState(element);
+ }
+
+ private void OnSwipeGesture(object? sender, SwipeGestureEventArgs e)
+ {
+ if (ItemsControl is not Carousel carousel || !carousel.IsSwipeEnabled)
+ return;
+
+ if (UsesViewportFractionLayout())
+ {
+ OnViewportFractionSwipeGesture(carousel, e);
+ return;
+ }
+
+ if (_realizedIndex < 0 || Items.Count == 0)
+ return;
+
+ if (_completionCts is { IsCancellationRequested: false })
+ {
+ _completionCts.Cancel();
+ _completionCts = null;
+
+ var wasCommit = _completionEndProgress > 0.5;
+ if (wasCommit && _swipeTarget is not null)
+ {
+ if (_realized != null)
+ RecycleElement(_realized);
+
+ _realized = _swipeTarget;
+ _realizedIndex = _swipeTargetIndex;
+ carousel.SelectedIndex = _swipeTargetIndex;
+ }
+ else
+ {
+ ResetSwipeState();
+ }
+
+ _swipeTarget = null;
+ _swipeTargetIndex = -1;
+ _totalDelta = 0;
+ }
+
+ if (_isDragging && e.Id != _swipeGestureId)
+ return;
+
+ if (!_isDragging)
+ {
+ // Lock the axis on gesture start to keep diagonal drags stable.
+ _lockedAxis = _swipeAxis ?? (Math.Abs(e.Delta.X) >= Math.Abs(e.Delta.Y) ?
+ PageSlide.SlideAxis.Horizontal :
+ PageSlide.SlideAxis.Vertical);
+ }
+
+ var delta = _lockedAxis == PageSlide.SlideAxis.Horizontal ? e.Delta.X : e.Delta.Y;
+
+ if (!_isDragging)
+ {
+ _isForward = delta > 0;
+ _isRubberBanding = false;
+ var currentIndex = _realizedIndex;
+ var targetIndex = _isForward ? currentIndex + 1 : currentIndex - 1;
+
+ if (targetIndex >= Items.Count)
+ {
+ if (carousel.WrapSelection)
+ targetIndex = 0;
+ else
+ _isRubberBanding = true;
+ }
+ else if (targetIndex < 0)
+ {
+ if (carousel.WrapSelection)
+ targetIndex = Items.Count - 1;
+ else
+ _isRubberBanding = true;
+ }
+
+ if (!_isRubberBanding && (targetIndex == currentIndex || targetIndex < 0 || targetIndex >= Items.Count))
+ return;
+
+ _isDragging = true;
+ _swipeGestureId = e.Id;
+ _totalDelta = 0;
+ _swipeTargetIndex = _isRubberBanding ? -1 : targetIndex;
+ carousel.IsSwiping = true;
+
+ if (_transition is not null)
+ {
+ _transition.Cancel();
+ _transition = null;
+ if (_transitionFrom is not null)
+ RecycleElement(_transitionFrom);
+ _transitionFrom = null;
+ _transitionFromIndex = -1;
+ }
+
+ if (!_isRubberBanding)
+ {
+ _swipeTarget = GetOrCreateElement(Items, _swipeTargetIndex);
+ _swipeTarget.Measure(Bounds.Size);
+ _swipeTarget.Arrange(new Rect(Bounds.Size));
+ _swipeTarget.IsVisible = true;
+ }
+ }
+
+ _totalDelta += delta;
+
+ // Clamp so totalDelta cannot cross zero (absorbs touch jitter).
+ if (_isForward)
+ _totalDelta = Math.Max(0, _totalDelta);
+ else
+ _totalDelta = Math.Min(0, _totalDelta);
+
+ var size = _lockedAxis == PageSlide.SlideAxis.Horizontal ? Bounds.Width : Bounds.Height;
+ if (size <= 0)
+ return;
+
+ var rawProgress = Math.Clamp(Math.Abs(_totalDelta) / size, 0, 1);
+ var progress = _isRubberBanding
+ ? RubberBandFactor * Math.Sqrt(rawProgress)
+ : rawProgress;
+
+ if (GetTransition() is IProgressPageTransition progressive)
+ {
+ progressive.Update(
+ progress,
+ _realized,
+ _isRubberBanding ? null : _swipeTarget,
+ _isForward,
+ size,
+ Array.Empty());
+ }
+
+ e.Handled = true;
+ }
+
+ private void OnViewportFractionSwipeGesture(Carousel carousel, SwipeGestureEventArgs e)
+ {
+ if (_offsetAnimationCts is { IsCancellationRequested: false })
+ {
+ CancelOffsetAnimation();
+ SetOffset(WithPrimaryOffset(_offset, GetNearestLogicalOffset(carousel.SelectedIndex, GetPrimaryOffset(_offset))));
+ }
+
+ if (_isDragging && e.Id != _swipeGestureId)
+ return;
+
+ var delta = _lockedAxis == PageSlide.SlideAxis.Horizontal ? e.Delta.X : e.Delta.Y;
+
+ if (!_isDragging)
+ {
+ _lockedAxis = carousel.GetLayoutAxis();
+ _swipeGestureId = e.Id;
+ _dragStartOffset = GetNearestLogicalOffset(carousel.SelectedIndex, GetPrimaryOffset(_offset));
+ _totalDelta = 0;
+ _isDragging = true;
+ _isRubberBanding = false;
+ carousel.IsSwiping = true;
+ _isForward = delta > 0;
+ var targetIndex = _isForward ? carousel.SelectedIndex + 1 : carousel.SelectedIndex - 1;
+
+ if (targetIndex >= Items.Count || targetIndex < 0)
+ {
+ if (carousel.WrapSelection && Items.Count > 1)
+ targetIndex = NormalizeIndex(targetIndex, Items.Count);
+ else
+ _isRubberBanding = true;
+ }
+
+ var targetOffset = _isForward ? _dragStartOffset + 1 : _dragStartOffset - 1;
+ SetFractionalProgressContext(
+ carousel.SelectedIndex,
+ _isRubberBanding ? -1 : targetIndex,
+ _isForward,
+ _dragStartOffset,
+ targetOffset);
+ ResetViewportTransitionState();
+ }
+
+ _totalDelta += delta;
+
+ if (_isForward)
+ _totalDelta = Math.Max(0, _totalDelta);
+ else
+ _totalDelta = Math.Min(0, _totalDelta);
+
+ var itemExtent = GetViewportItemExtent(Bounds.Size);
+ if (itemExtent <= 0)
+ return;
+
+ var logicalDelta = Math.Clamp(Math.Abs(_totalDelta) / itemExtent, 0, 1);
+ var proposedOffset = _dragStartOffset + (_isForward ? logicalDelta : -logicalDelta);
+
+ if (!_isRubberBanding)
+ {
+ proposedOffset = Math.Clamp(
+ proposedOffset,
+ Math.Min(_dragStartOffset, _activeViewportTargetOffset),
+ Math.Max(_dragStartOffset, _activeViewportTargetOffset));
+ }
+ else if (proposedOffset < 0)
+ {
+ proposedOffset = -(RubberBandFactor * Math.Sqrt(-proposedOffset));
+ }
+ else
+ {
+ var maxOffset = Math.Max(0, Items.Count - 1);
+ proposedOffset = maxOffset + (RubberBandFactor * Math.Sqrt(proposedOffset - maxOffset));
+ }
+
+ SetOffset(WithPrimaryOffset(_offset, proposedOffset));
+
+ if (GetTransition() is IProgressPageTransition progressive)
+ {
+ var currentOffset = GetPrimaryOffset(_offset);
+ var progress = Math.Clamp(Math.Abs(currentOffset - _dragStartOffset), 0, 1);
+ progressive.Update(
+ progress,
+ FindViewportControl(_progressFromIndex),
+ FindViewportControl(_progressToIndex),
+ _isForward,
+ GetViewportItemExtent(Bounds.Size),
+ BuildFractionalVisibleItems(currentOffset));
+ }
+
+ e.Handled = true;
+ }
+
+ private void OnViewportFractionSwipeGestureEnded(Carousel carousel, SwipeGestureEndedEventArgs e)
+ {
+ var itemExtent = GetViewportItemExtent(Bounds.Size);
+ var currentOffset = GetPrimaryOffset(_offset);
+ var currentProgress = Math.Abs(currentOffset - _dragStartOffset);
+ var velocity = _lockedAxis == PageSlide.SlideAxis.Horizontal ? Math.Abs(e.Velocity.X) : Math.Abs(e.Velocity.Y);
+ var targetIndex = _progressToIndex;
+ var canCommit = !_isRubberBanding && targetIndex >= 0;
+ var commit = canCommit &&
+ (currentProgress >= SwipeCommitThreshold ||
+ (velocity > VelocityCommitThreshold && currentProgress >= MinSwipeDistanceForVelocityCommit));
+ var endOffset = commit
+ ? _activeViewportTargetOffset
+ : GetNearestLogicalOffset(carousel.SelectedIndex, currentOffset);
+ var remainingDistance = Math.Abs(endOffset - currentOffset);
+ var durationSeconds = _isRubberBanding
+ ? RubberBandReturnDuration
+ : velocity > 0 && itemExtent > 0
+ ? Math.Clamp(remainingDistance * itemExtent / velocity, MinCompletionDuration, MaxCompletionDuration)
+ : MaxCompletionDuration;
+ var easing = _isRubberBanding ? (Easing)new SineEaseOut() : new QuadraticEaseOut();
+
+ _isDragging = false;
+ _ = AnimateViewportOffsetAsync(
+ currentOffset,
+ endOffset,
+ TimeSpan.FromSeconds(durationSeconds),
+ easing,
+ () =>
+ {
+ _totalDelta = 0;
+ _isRubberBanding = false;
+ carousel.IsSwiping = false;
+
+ if (commit)
+ {
+ SetOffset(WithPrimaryOffset(_offset, GetNearestLogicalOffset(targetIndex, endOffset)));
+ carousel.SelectedIndex = targetIndex;
+ }
+ else
+ {
+ SetOffset(WithPrimaryOffset(_offset, GetNearestLogicalOffset(carousel.SelectedIndex, endOffset)));
+ }
+
+ ResetViewportTransitionState();
+ ClearFractionalProgressContext();
+ });
+ }
+
+ private async Task AnimateViewportOffsetAsync(
+ double fromOffset,
+ double toOffset,
+ TimeSpan duration,
+ Easing easing,
+ Action onCompleted)
+ {
+ CancelOffsetAnimation();
+ var offsetAnimationCts = new CancellationTokenSource();
+ _offsetAnimationCts = offsetAnimationCts;
+ var cancellationToken = offsetAnimationCts.Token;
+
+ var animation = new Animation.Animation
+ {
+ FillMode = FillMode.Forward,
+ Duration = duration,
+ Easing = easing,
+ Children =
+ {
+ new KeyFrame
+ {
+ Setters = { new Setter(OffsetAnimationProgressProperty, 0d) },
+ Cue = new Cue(0d)
+ },
+ new KeyFrame
+ {
+ Setters = { new Setter(OffsetAnimationProgressProperty, 1d) },
+ Cue = new Cue(1d)
+ }
+ }
+ };
+
+ _offsetAnimationStart = fromOffset;
+ _offsetAnimationTarget = toOffset;
+ SetValue(OffsetAnimationProgressProperty, 0d);
+
+ try
+ {
+ await animation.RunAsync(this, null, cancellationToken);
+
+ if (cancellationToken.IsCancellationRequested)
+ return;
+
+ SetOffset(WithPrimaryOffset(_offset, toOffset));
+
+ if (UsesViewportFractionLayout() &&
+ GetTransition() is IProgressPageTransition progressive)
+ {
+ var transitionProgress = GetFractionalTransitionProgress(toOffset);
+ progressive.Update(
+ transitionProgress,
+ FindViewportControl(_progressFromIndex),
+ FindViewportControl(_progressToIndex),
+ _isForward,
+ GetViewportItemExtent(Bounds.Size),
+ BuildFractionalVisibleItems(toOffset));
+ }
+
+ onCompleted();
+ }
+ finally
+ {
+ if (ReferenceEquals(_offsetAnimationCts, offsetAnimationCts))
+ _offsetAnimationCts = null;
+ }
+ }
+
+ private void OnSwipeGestureEnded(object? sender, SwipeGestureEndedEventArgs e)
+ {
+ if (!_isDragging || e.Id != _swipeGestureId || ItemsControl is not Carousel carousel)
+ return;
+
+ if (UsesViewportFractionLayout())
+ {
+ OnViewportFractionSwipeGestureEnded(carousel, e);
+ return;
+ }
+
+ var size = _lockedAxis == PageSlide.SlideAxis.Horizontal ? Bounds.Width : Bounds.Height;
+ var rawProgress = size > 0 ? Math.Abs(_totalDelta) / size : 0;
+ var currentProgress = _isRubberBanding
+ ? RubberBandFactor * Math.Sqrt(rawProgress)
+ : rawProgress;
+ var velocity = _lockedAxis == PageSlide.SlideAxis.Horizontal
+ ? Math.Abs(e.Velocity.X)
+ : Math.Abs(e.Velocity.Y);
+ var commit = !_isRubberBanding
+ && (currentProgress >= SwipeCommitThreshold ||
+ (velocity > VelocityCommitThreshold && currentProgress >= MinSwipeDistanceForVelocityCommit))
+ && _swipeTarget is not null;
+
+ _completionEndProgress = commit ? 1.0 : 0.0;
+ var remainingDistance = Math.Abs(_completionEndProgress - currentProgress);
+ var durationSeconds = _isRubberBanding
+ ? RubberBandReturnDuration
+ : velocity > 0
+ ? Math.Clamp(remainingDistance * size / velocity, MinCompletionDuration, MaxCompletionDuration)
+ : MaxCompletionDuration;
+ Easing easing = _isRubberBanding ? new SineEaseOut() : new QuadraticEaseOut();
+
+ _completionCts?.Cancel();
+ var completionCts = new CancellationTokenSource();
+ _completionCts = completionCts;
+
+ SetValue(CompletionProgressProperty, currentProgress);
+
+ var animation = new Animation.Animation
+ {
+ FillMode = FillMode.Forward,
+ Easing = easing,
+ Duration = TimeSpan.FromSeconds(durationSeconds),
+ Children =
+ {
+ new KeyFrame
+ {
+ Setters = { new Setter { Property = CompletionProgressProperty, Value = currentProgress } },
+ Cue = new Cue(0d)
+ },
+ new KeyFrame
+ {
+ Setters = { new Setter { Property = CompletionProgressProperty, Value = _completionEndProgress } },
+ Cue = new Cue(1d)
+ }
+ }
+ };
+
+ _isDragging = false;
+ _ = RunCompletionAnimation(animation, carousel, completionCts);
+ }
+
+ private async Task RunCompletionAnimation(
+ Animation.Animation animation,
+ Carousel carousel,
+ CancellationTokenSource completionCts)
+ {
+ var cancellationToken = completionCts.Token;
+
+ try
+ {
+ await animation.RunAsync(this, null, cancellationToken);
+
+ if (cancellationToken.IsCancellationRequested)
+ return;
+
+ if (GetTransition() is IProgressPageTransition progressive)
+ {
+ var swipeTarget = ReferenceEquals(_realized, _swipeTarget) ? null : _swipeTarget;
+ var size = _lockedAxis == PageSlide.SlideAxis.Horizontal ? Bounds.Width : Bounds.Height;
+ progressive.Update(
+ _completionEndProgress,
+ _realized,
+ swipeTarget,
+ _isForward,
+ size,
+ Array.Empty());
+ }
+
+ var commit = _completionEndProgress > 0.5;
+
+ if (commit && _swipeTarget is not null)
+ {
+ var targetIndex = _swipeTargetIndex;
+ var targetElement = _swipeTarget;
+
+ // Clear swipe target state before promoting it to the realized element so
+ // interactive transitions never receive the same control as both from/to.
+ _swipeTarget = null;
+ _swipeTargetIndex = -1;
+
+ if (_realized != null)
+ RecycleElement(_realized);
+
+ _realized = targetElement;
+ _realizedIndex = targetIndex;
+
+ carousel.SelectedIndex = targetIndex;
+ }
+ else
+ {
+ ResetSwipeState();
+ }
+
+ _totalDelta = 0;
+ _swipeTarget = null;
+ _swipeTargetIndex = -1;
+ _isRubberBanding = false;
+ carousel.IsSwiping = false;
+ }
+ finally
+ {
+ if (ReferenceEquals(_completionCts, completionCts))
+ _completionCts = null;
+ }
+ }
+
+ ///
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+
+ if (change.Property == OffsetAnimationProgressProperty)
+ {
+ if (_offsetAnimationCts is { IsCancellationRequested: false })
+ {
+ var animProgress = change.GetNewValue();
+ var primaryOffset = _offsetAnimationStart +
+ ((_offsetAnimationTarget - _offsetAnimationStart) * animProgress);
+ SetOffset(WithPrimaryOffset(_offset, primaryOffset));
+
+ if (UsesViewportFractionLayout() &&
+ GetTransition() is IProgressPageTransition progressive)
+ {
+ var transitionProgress = GetFractionalTransitionProgress(primaryOffset);
+ progressive.Update(
+ transitionProgress,
+ FindViewportControl(_progressFromIndex),
+ FindViewportControl(_progressToIndex),
+ _isForward,
+ GetViewportItemExtent(Bounds.Size),
+ BuildFractionalVisibleItems(primaryOffset));
+ }
+ }
+ }
+ else if (change.Property == CompletionProgressProperty)
+ {
+ var isCompletionAnimating = _completionCts is { IsCancellationRequested: false };
+
+ if (!_isDragging && _swipeTarget is null && !isCompletionAnimating)
+ return;
+
+ var progress = change.GetNewValue();
+ if (GetTransition() is IProgressPageTransition progressive)
+ {
+ var swipeTarget = ReferenceEquals(_realized, _swipeTarget) ? null : _swipeTarget;
+ var size = _lockedAxis == PageSlide.SlideAxis.Horizontal ? Bounds.Width : Bounds.Height;
+ progressive.Update(
+ progress,
+ _realized,
+ swipeTarget,
+ _isForward,
+ size,
+ Array.Empty());
+ }
+ }
+ }
+
+ private IReadOnlyList BuildFractionalVisibleItems(double currentOffset)
+ {
+ var items = new PageTransitionItem[_viewportRealized.Count];
+ var i = 0;
+ foreach (var entry in _viewportRealized.OrderBy(x => x.Key))
+ {
+ items[i++] = new PageTransitionItem(
+ entry.Value.ItemIndex,
+ entry.Value.Control,
+ entry.Key - currentOffset);
+ }
+
+ return items;
+ }
+
+ private void ResetSwipeState()
+ {
+ if (ItemsControl is Carousel carousel)
+ carousel.IsSwiping = false;
+
+ CancelOffsetAnimation();
+
+ ResetViewportTransitionState();
+ ResetTransitionState(_realized);
+
+ if (_swipeTarget is not null)
+ RecycleElement(_swipeTarget);
+
+ _isDragging = false;
+ _totalDelta = 0;
+ _swipeTarget = null;
+ _swipeTargetIndex = -1;
+ _isRubberBanding = false;
+ ClearFractionalProgressContext();
+
+ if (UsesViewportFractionLayout() && ItemsControl is Carousel viewportCarousel)
+ SetOffset(WithPrimaryOffset(_offset, GetNearestLogicalOffset(viewportCarousel.SelectedIndex, GetPrimaryOffset(_offset))));
+ }
+
+ private void ResetTransitionState(Control? control)
+ {
+ if (control is null)
+ return;
+
+ if (GetTransition() is IProgressPageTransition progressive)
+ {
+ progressive.Reset(control);
+ }
+ else
+ {
+ ResetVisualState(control);
+ }
+ }
+
+ private static void ResetVisualState(Control? control)
+ {
+ if (control is null)
+ return;
+ control.RenderTransform = null;
+ control.Opacity = 1;
+ control.ZIndex = 0;
+ control.Clip = null;
+ }
}
}
diff --git a/src/Avalonia.Controls/Window.cs b/src/Avalonia.Controls/Window.cs
index d92a46a70a..e7a4ce953e 100644
--- a/src/Avalonia.Controls/Window.cs
+++ b/src/Avalonia.Controls/Window.cs
@@ -6,6 +6,7 @@ using System.Threading.Tasks;
using Avalonia.Automation.Peers;
using Avalonia.Controls.Chrome;
using Avalonia.Controls.Platform;
+using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
@@ -248,7 +249,7 @@ namespace Avalonia.Controls
this.GetObservable(ClientSizeProperty).Skip(1).Subscribe(x => PlatformImpl?.Resize(x, WindowResizeReason.Application));
CreatePlatformImplBinding(TitleProperty, title => PlatformImpl!.SetTitle(title));
- CreatePlatformImplBinding(IconProperty, icon => PlatformImpl!.SetIcon((icon ?? s_defaultIcon.Value)?.PlatformImpl));
+ CreatePlatformImplBinding(IconProperty, SetEffectiveIcon);
CreatePlatformImplBinding(CanResizeProperty, canResize => PlatformImpl!.CanResize(canResize));
CreatePlatformImplBinding(CanMinimizeProperty, canMinimize => PlatformImpl!.SetCanMinimize(canMinimize));
CreatePlatformImplBinding(CanMaximizeProperty, canMaximize => PlatformImpl!.SetCanMaximize(canMaximize));
@@ -795,6 +796,12 @@ namespace Avalonia.Controls
ShowCore(null, false);
}
+ protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
+ {
+ base.OnApplyTemplate(e);
+ EnableVisualLayerManagerLayers();
+ }
+
protected override void IsVisibleChanged(AvaloniaPropertyChangedEventArgs e)
{
if (!IgnoreVisibilityChanges)
@@ -892,6 +899,8 @@ namespace Avalonia.Controls
_shown = true;
IsVisible = true;
+ SetEffectiveIcon(Icon);
+
// If window position was not set before then platform may provide incorrect scaling at this time,
// but we need it for proper calculation of position and in some cases size (size to content)
SetExpectedScaling(owner);
@@ -1378,7 +1387,7 @@ namespace Avalonia.Controls
private static WindowIcon? LoadDefaultIcon()
{
- // Use AvaloniaLocator instead of static AssetLoader, so it won't fail on Unit Tests without any asset loader.
+ // Use AvaloniaLocator instead of static AssetLoader, so it won't fail on Unit Tests without any asset loader.
if (AvaloniaLocator.Current.GetService() is { } assetLoader
&& Assembly.GetEntryAssembly()?.GetName()?.Name is { } assemblyName
&& Uri.TryCreate($"avares://{assemblyName}/!__AvaloniaDefaultWindowIcon", UriKind.Absolute, out var path)
@@ -1390,6 +1399,12 @@ namespace Avalonia.Controls
return null;
}
+ private void SetEffectiveIcon(WindowIcon? icon)
+ {
+ icon ??= _shown ? s_defaultIcon.Value : null;
+ PlatformImpl?.SetIcon(icon?.PlatformImpl);
+ }
+
private static bool CoerceCanMaximize(AvaloniaObject target, bool value)
=> value && target is not Window { CanResize: false };
}
diff --git a/src/Avalonia.Themes.Fluent/Controls/EmbeddableControlRoot.xaml b/src/Avalonia.Themes.Fluent/Controls/EmbeddableControlRoot.xaml
index 1fc931db36..2ea83ec6a9 100644
--- a/src/Avalonia.Themes.Fluent/Controls/EmbeddableControlRoot.xaml
+++ b/src/Avalonia.Themes.Fluent/Controls/EmbeddableControlRoot.xaml
@@ -12,7 +12,7 @@
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
@@ -70,7 +70,7 @@ internal class WindowActivationTrackingHelper : IDisposable
{
var value = XLib.XGetWindowPropertyAsIntPtrArray(_platform.Display, _platform.Info.RootWindow,
_platform.Info.Atoms._NET_ACTIVE_WINDOW,
- (IntPtr)_platform.Info.Atoms.XA_WINDOW);
+ (IntPtr)_platform.Info.Atoms.WINDOW);
if (value == null || value.Length == 0)
SetActive(false);
else
diff --git a/src/Avalonia.X11/Clipboard/ClipboardDataFormatHelper.cs b/src/Avalonia.X11/Clipboard/ClipboardDataFormatHelper.cs
index aed323ddb0..5e53ff96c1 100644
--- a/src/Avalonia.X11/Clipboard/ClipboardDataFormatHelper.cs
+++ b/src/Avalonia.X11/Clipboard/ClipboardDataFormatHelper.cs
@@ -19,7 +19,7 @@ internal static class ClipboardDataFormatHelper
if (formatAtom == atoms.UTF16_STRING ||
formatAtom == atoms.UTF8_STRING ||
- formatAtom == atoms.XA_STRING ||
+ formatAtom == atoms.STRING ||
formatAtom == atoms.OEMTEXT)
{
return DataFormat.Text;
@@ -92,7 +92,7 @@ internal static class ClipboardDataFormatHelper
private static IntPtr GetPreferredStringFormatAtom(IntPtr[] textFormatAtoms, X11Atoms atoms)
{
- ReadOnlySpan preferredFormats = [atoms.UTF16_STRING, atoms.UTF8_STRING, atoms.XA_STRING];
+ ReadOnlySpan preferredFormats = [atoms.UTF16_STRING, atoms.UTF8_STRING, atoms.STRING];
foreach (var preferredFormat in preferredFormats)
{
@@ -111,7 +111,7 @@ internal static class ClipboardDataFormatHelper
if (formatAtom == atoms.UTF8_STRING)
return Encoding.UTF8;
- if (formatAtom == atoms.XA_STRING || formatAtom == atoms.OEMTEXT)
+ if (formatAtom == atoms.STRING || formatAtom == atoms.OEMTEXT)
return Encoding.ASCII;
return null;
diff --git a/src/Avalonia.X11/Clipboard/ClipboardReadSession.cs b/src/Avalonia.X11/Clipboard/ClipboardReadSession.cs
index f53d8fe3d4..7c83ecea40 100644
--- a/src/Avalonia.X11/Clipboard/ClipboardReadSession.cs
+++ b/src/Avalonia.X11/Clipboard/ClipboardReadSession.cs
@@ -127,6 +127,7 @@ class ClipboardReadSession : IDisposable
Append(part);
}
+ ms.Position = 0L;
return new(null, ms, actualTypeAtom);
}
@@ -150,4 +151,4 @@ class ClipboardReadSession : IDisposable
}
}
-}
\ No newline at end of file
+}
diff --git a/src/Avalonia.X11/Clipboard/X11Clipboard.cs b/src/Avalonia.X11/Clipboard/X11Clipboard.cs
index 6435e42e32..35abbf11c1 100644
--- a/src/Avalonia.X11/Clipboard/X11Clipboard.cs
+++ b/src/Avalonia.X11/Clipboard/X11Clipboard.cs
@@ -32,7 +32,7 @@ namespace Avalonia.X11.Clipboard
_avaloniaSaveTargetsAtom = XInternAtom(_x11.Display, "AVALONIA_SAVE_TARGETS_PROPERTY_ATOM", false);
_textAtoms = new[]
{
- _x11.Atoms.XA_STRING,
+ _x11.Atoms.STRING,
_x11.Atoms.OEMTEXT,
_x11.Atoms.UTF8_STRING,
_x11.Atoms.UTF16_STRING
@@ -99,7 +99,7 @@ namespace Avalonia.X11.Clipboard
{
var atoms = ConvertDataTransfer(_storedDataTransfer);
XChangeProperty(_x11.Display, window, property,
- _x11.Atoms.XA_ATOM, 32, PropertyMode.Replace, atoms, atoms.Length);
+ _x11.Atoms.ATOM, 32, PropertyMode.Replace, atoms, atoms.Length);
return property;
}
else if (target == _x11.Atoms.SAVE_TARGETS && _x11.Atoms.SAVE_TARGETS != IntPtr.Zero)
@@ -287,7 +287,7 @@ namespace Avalonia.X11.Clipboard
_storeAtomTcs = new TaskCompletionSource();
var atoms = ConvertDataTransfer(dataTransfer);
- XChangeProperty(_x11.Display, _handle, _avaloniaSaveTargetsAtom, _x11.Atoms.XA_ATOM, 32,
+ XChangeProperty(_x11.Display, _handle, _avaloniaSaveTargetsAtom, _x11.Atoms.ATOM, 32,
PropertyMode.Replace, atoms, atoms.Length);
XConvertSelection(_x11.Display, _x11.Atoms.CLIPBOARD_MANAGER, _x11.Atoms.SAVE_TARGETS,
_avaloniaSaveTargetsAtom, _handle, IntPtr.Zero);
diff --git a/src/Avalonia.X11/Screens/X11Screen.Providers.cs b/src/Avalonia.X11/Screens/X11Screen.Providers.cs
index f516e0f44f..1a12a279a1 100644
--- a/src/Avalonia.X11/Screens/X11Screen.Providers.cs
+++ b/src/Avalonia.X11/Screens/X11Screen.Providers.cs
@@ -56,9 +56,9 @@ internal partial class X11Screens
if (!hasEDID)
return null;
XRRGetOutputProperty(x11.Display, rrOutput, x11.Atoms.EDID, 0, EDIDStructureLength, false, false,
- x11.Atoms.AnyPropertyType, out IntPtr actualType, out int actualFormat, out int bytesAfter, out _,
+ AnyPropertyType, out IntPtr actualType, out int actualFormat, out int bytesAfter, out _,
out IntPtr prop);
- if (actualType != x11.Atoms.XA_INTEGER)
+ if (actualType != x11.Atoms.INTEGER)
return null;
if (actualFormat != 8) // Expecting an byte array
return null;
@@ -89,7 +89,7 @@ internal partial class X11Screens
IntPtr.Zero,
new IntPtr(128),
false,
- x11.Atoms.AnyPropertyType,
+ AnyPropertyType,
out var type,
out var format,
out var count,
diff --git a/src/Avalonia.X11/TransparencyHelper.cs b/src/Avalonia.X11/TransparencyHelper.cs
index 50a73a36ce..427ec647e6 100644
--- a/src/Avalonia.X11/TransparencyHelper.cs
+++ b/src/Avalonia.X11/TransparencyHelper.cs
@@ -89,7 +89,7 @@ namespace Avalonia.X11
{
IntPtr value = IntPtr.Zero;
XLib.XChangeProperty(_x11.Display, _window, _x11.Atoms._KDE_NET_WM_BLUR_BEHIND_REGION,
- _x11.Atoms.XA_CARDINAL, 32, PropertyMode.Replace, ref value, 1);
+ _x11.Atoms.CARDINAL, 32, PropertyMode.Replace, ref value, 1);
_blurAtomsAreSet = true;
}
}
diff --git a/src/Avalonia.X11/X11Atoms.cs b/src/Avalonia.X11/X11Atoms.cs
index b851974bad..64b00c411b 100644
--- a/src/Avalonia.X11/X11Atoms.cs
+++ b/src/Avalonia.X11/X11Atoms.cs
@@ -44,75 +44,74 @@ namespace Avalonia.X11
private readonly IntPtr _display;
// Our atoms
- public IntPtr AnyPropertyType = (IntPtr)0;
- public IntPtr XA_PRIMARY = (IntPtr)1;
- public IntPtr XA_SECONDARY = (IntPtr)2;
- public IntPtr XA_ARC = (IntPtr)3;
- public IntPtr XA_ATOM = (IntPtr)4;
- public IntPtr XA_BITMAP = (IntPtr)5;
- public IntPtr XA_CARDINAL = (IntPtr)6;
- public IntPtr XA_COLORMAP = (IntPtr)7;
- public IntPtr XA_CURSOR = (IntPtr)8;
- public IntPtr XA_CUT_BUFFER0 = (IntPtr)9;
- public IntPtr XA_CUT_BUFFER1 = (IntPtr)10;
- public IntPtr XA_CUT_BUFFER2 = (IntPtr)11;
- public IntPtr XA_CUT_BUFFER3 = (IntPtr)12;
- public IntPtr XA_CUT_BUFFER4 = (IntPtr)13;
- public IntPtr XA_CUT_BUFFER5 = (IntPtr)14;
- public IntPtr XA_CUT_BUFFER6 = (IntPtr)15;
- public IntPtr XA_CUT_BUFFER7 = (IntPtr)16;
- public IntPtr XA_DRAWABLE = (IntPtr)17;
- public IntPtr XA_FONT = (IntPtr)18;
- public IntPtr XA_INTEGER = (IntPtr)19;
- public IntPtr XA_PIXMAP = (IntPtr)20;
- public IntPtr XA_POINT = (IntPtr)21;
- public IntPtr XA_RECTANGLE = (IntPtr)22;
- public IntPtr XA_RESOURCE_MANAGER = (IntPtr)23;
- public IntPtr XA_RGB_COLOR_MAP = (IntPtr)24;
- public IntPtr XA_RGB_BEST_MAP = (IntPtr)25;
- public IntPtr XA_RGB_BLUE_MAP = (IntPtr)26;
- public IntPtr XA_RGB_DEFAULT_MAP = (IntPtr)27;
- public IntPtr XA_RGB_GRAY_MAP = (IntPtr)28;
- public IntPtr XA_RGB_GREEN_MAP = (IntPtr)29;
- public IntPtr XA_RGB_RED_MAP = (IntPtr)30;
- public IntPtr XA_STRING = (IntPtr)31;
- public IntPtr XA_VISUALID = (IntPtr)32;
- public IntPtr XA_WINDOW = (IntPtr)33;
- public IntPtr XA_WM_COMMAND = (IntPtr)34;
- public IntPtr XA_WM_HINTS = (IntPtr)35;
- public IntPtr XA_WM_CLIENT_MACHINE = (IntPtr)36;
- public IntPtr XA_WM_ICON_NAME = (IntPtr)37;
- public IntPtr XA_WM_ICON_SIZE = (IntPtr)38;
- public IntPtr XA_WM_NAME = (IntPtr)39;
- public IntPtr XA_WM_NORMAL_HINTS = (IntPtr)40;
- public IntPtr XA_WM_SIZE_HINTS = (IntPtr)41;
- public IntPtr XA_WM_ZOOM_HINTS = (IntPtr)42;
- public IntPtr XA_MIN_SPACE = (IntPtr)43;
- public IntPtr XA_NORM_SPACE = (IntPtr)44;
- public IntPtr XA_MAX_SPACE = (IntPtr)45;
- public IntPtr XA_END_SPACE = (IntPtr)46;
- public IntPtr XA_SUPERSCRIPT_X = (IntPtr)47;
- public IntPtr XA_SUPERSCRIPT_Y = (IntPtr)48;
- public IntPtr XA_SUBSCRIPT_X = (IntPtr)49;
- public IntPtr XA_SUBSCRIPT_Y = (IntPtr)50;
- public IntPtr XA_UNDERLINE_POSITION = (IntPtr)51;
- public IntPtr XA_UNDERLINE_THICKNESS = (IntPtr)52;
- public IntPtr XA_STRIKEOUT_ASCENT = (IntPtr)53;
- public IntPtr XA_STRIKEOUT_DESCENT = (IntPtr)54;
- public IntPtr XA_ITALIC_ANGLE = (IntPtr)55;
- public IntPtr XA_X_HEIGHT = (IntPtr)56;
- public IntPtr XA_QUAD_WIDTH = (IntPtr)57;
- public IntPtr XA_WEIGHT = (IntPtr)58;
- public IntPtr XA_POINT_SIZE = (IntPtr)59;
- public IntPtr XA_RESOLUTION = (IntPtr)60;
- public IntPtr XA_COPYRIGHT = (IntPtr)61;
- public IntPtr XA_NOTICE = (IntPtr)62;
- public IntPtr XA_FONT_NAME = (IntPtr)63;
- public IntPtr XA_FAMILY_NAME = (IntPtr)64;
- public IntPtr XA_FULL_NAME = (IntPtr)65;
- public IntPtr XA_CAP_HEIGHT = (IntPtr)66;
- public IntPtr XA_WM_CLASS = (IntPtr)67;
- public IntPtr XA_WM_TRANSIENT_FOR = (IntPtr)68;
+ public readonly IntPtr PRIMARY = 1;
+ public readonly IntPtr SECONDARY = 2;
+ public readonly IntPtr ARC = 3;
+ public readonly IntPtr ATOM = 4;
+ public readonly IntPtr BITMAP = 5;
+ public readonly IntPtr CARDINAL = 6;
+ public readonly IntPtr COLORMAP = 7;
+ public readonly IntPtr CURSOR = 8;
+ public readonly IntPtr CUT_BUFFER0 = 9;
+ public readonly IntPtr CUT_BUFFER1 = 10;
+ public readonly IntPtr CUT_BUFFER2 = 11;
+ public readonly IntPtr CUT_BUFFER3 = 12;
+ public readonly IntPtr CUT_BUFFER4 = 13;
+ public readonly IntPtr CUT_BUFFER5 = 14;
+ public readonly IntPtr CUT_BUFFER6 = 15;
+ public readonly IntPtr CUT_BUFFER7 = 16;
+ public readonly IntPtr DRAWABLE = 17;
+ public readonly IntPtr FONT = 18;
+ public readonly IntPtr INTEGER = 19;
+ public readonly IntPtr PIXMAP = 20;
+ public readonly IntPtr POINT = 21;
+ public readonly IntPtr RECTANGLE = 22;
+ public readonly IntPtr RESOURCE_MANAGER = 23;
+ public readonly IntPtr RGB_COLOR_MAP = 24;
+ public readonly IntPtr RGB_BEST_MAP = 25;
+ public readonly IntPtr RGB_BLUE_MAP = 26;
+ public readonly IntPtr RGB_DEFAULT_MAP = 27;
+ public readonly IntPtr RGB_GRAY_MAP = 28;
+ public readonly IntPtr RGB_GREEN_MAP = 29;
+ public readonly IntPtr RGB_RED_MAP = 30;
+ public readonly IntPtr STRING = 31;
+ public readonly IntPtr VISUALID = 32;
+ public readonly IntPtr WINDOW = 33;
+ public readonly IntPtr WM_COMMAND = 34;
+ public readonly IntPtr WM_HINTS = 35;
+ public readonly IntPtr WM_CLIENT_MACHINE = 36;
+ public readonly IntPtr WM_ICON_NAME = 37;
+ public readonly IntPtr WM_ICON_SIZE = 38;
+ public readonly IntPtr WM_NAME = 39;
+ public readonly IntPtr WM_NORMAL_HINTS = 40;
+ public readonly IntPtr WM_SIZE_HINTS = 41;
+ public readonly IntPtr WM_ZOOM_HINTS = 42;
+ public readonly IntPtr MIN_SPACE = 43;
+ public readonly IntPtr NORM_SPACE = 44;
+ public readonly IntPtr MAX_SPACE = 45;
+ public readonly IntPtr END_SPACE = 46;
+ public readonly IntPtr SUPERSCRIPT_X = 47;
+ public readonly IntPtr SUPERSCRIPT_Y = 48;
+ public readonly IntPtr SUBSCRIPT_X = 49;
+ public readonly IntPtr SUBSCRIPT_Y = 50;
+ public readonly IntPtr UNDERLINE_POSITION = 51;
+ public readonly IntPtr UNDERLINE_THICKNESS = 52;
+ public readonly IntPtr STRIKEOUT_ASCENT = 53;
+ public readonly IntPtr STRIKEOUT_DESCENT = 54;
+ public readonly IntPtr ITALIC_ANGLE = 55;
+ public readonly IntPtr X_HEIGHT = 56;
+ public readonly IntPtr QUAD_WIDTH = 57;
+ public readonly IntPtr WEIGHT = 58;
+ public readonly IntPtr POINT_SIZE = 59;
+ public readonly IntPtr RESOLUTION = 60;
+ public readonly IntPtr COPYRIGHT = 61;
+ public readonly IntPtr NOTICE = 62;
+ public readonly IntPtr FONT_NAME = 63;
+ public readonly IntPtr FAMILY_NAME = 64;
+ public readonly IntPtr FULL_NAME = 65;
+ public readonly IntPtr CAP_HEIGHT = 66;
+ public readonly IntPtr WM_CLASS = 67;
+ public readonly IntPtr WM_TRANSIENT_FOR = 68;
public IntPtr EDID;
@@ -183,7 +182,6 @@ namespace Avalonia.X11
public IntPtr CLIPBOARD_MANAGER;
public IntPtr SAVE_TARGETS;
public IntPtr MULTIPLE;
- public IntPtr PRIMARY;
public IntPtr OEMTEXT;
public IntPtr UNICODETEXT;
public IntPtr TARGETS;
@@ -208,11 +206,16 @@ namespace Avalonia.X11
if (value != IntPtr.Zero)
{
field = value;
- _namesToAtoms[name] = value;
- _atomsToNames[value] = name;
+ SetName(name, value);
}
}
+ private void SetName(string name, IntPtr value)
+ {
+ _namesToAtoms[name] = value;
+ _atomsToNames[value] = name;
+ }
+
public IntPtr GetAtom(string name)
{
if (_namesToAtoms.TryGetValue(name, out var rv))
diff --git a/src/Avalonia.X11/X11Globals.cs b/src/Avalonia.X11/X11Globals.cs
index 3f16f8a88f..b9e4058b2d 100644
--- a/src/Avalonia.X11/X11Globals.cs
+++ b/src/Avalonia.X11/X11Globals.cs
@@ -109,13 +109,13 @@ namespace Avalonia.X11
{
XGetWindowProperty(_x11.Display, _rootWindow, _x11.Atoms._NET_SUPPORTING_WM_CHECK,
IntPtr.Zero, new IntPtr(IntPtr.Size), false,
- _x11.Atoms.XA_WINDOW, out IntPtr actualType, out int actualFormat, out IntPtr nitems,
+ _x11.Atoms.WINDOW, out IntPtr actualType, out int actualFormat, out IntPtr nitems,
out IntPtr bytesAfter, out IntPtr prop);
if (nitems.ToInt32() != 1)
return IntPtr.Zero;
try
{
- if (actualType != _x11.Atoms.XA_WINDOW)
+ if (actualType != _x11.Atoms.WINDOW)
return IntPtr.Zero;
return *(IntPtr*)prop.ToPointer();
}
@@ -197,7 +197,7 @@ namespace Avalonia.X11
if (wm == IntPtr.Zero)
return WindowActivationTrackingMode.FocusEvents;
var supportedFeatures = XGetWindowPropertyAsIntPtrArray(_x11.Display, _x11.RootWindow,
- _x11.Atoms._NET_SUPPORTED, _x11.Atoms.XA_ATOM) ?? [];
+ _x11.Atoms._NET_SUPPORTED, _x11.Atoms.ATOM) ?? [];
if (supportedFeatures.Contains(_x11.Atoms._NET_WM_STATE_FOCUSED))
return WindowActivationTrackingMode._NET_WM_STATE_FOCUSED;
diff --git a/src/Avalonia.X11/X11IconLoader.cs b/src/Avalonia.X11/X11IconLoader.cs
index f0cd6f0192..ab0946f531 100644
--- a/src/Avalonia.X11/X11IconLoader.cs
+++ b/src/Avalonia.X11/X11IconLoader.cs
@@ -40,14 +40,15 @@ namespace Avalonia.X11
_width = Math.Min(bitmap.PixelSize.Width, 128);
_height = Math.Min(bitmap.PixelSize.Height, 128);
var pixels = new uint[_width * _height];
+ var size = new PixelSize(_width, _height);
- using (var rtb = new RenderTargetBitmap(new PixelSize(128, 128)))
+ using (var rtb = new RenderTargetBitmap(size))
{
using (var ctx = rtb.CreateDrawingContext(true))
ctx.DrawImage(bitmap, new Rect(rtb.Size));
fixed (void* pPixels = pixels)
- rtb.CopyPixels(new LockedFramebuffer((IntPtr)pPixels, new PixelSize(_width, _height), _width * 4,
+ rtb.CopyPixels(new LockedFramebuffer((IntPtr)pPixels, size, _width * 4,
new Vector(96, 96), PixelFormat.Bgra8888, AlphaFormat.Premul, null));
}
diff --git a/src/Avalonia.X11/X11Window.cs b/src/Avalonia.X11/X11Window.cs
index a57e1986ac..bf20600a18 100644
--- a/src/Avalonia.X11/X11Window.cs
+++ b/src/Avalonia.X11/X11Window.cs
@@ -71,6 +71,7 @@ namespace Avalonia.X11
private bool _useCompositorDrivenRenderWindowResize = false;
private bool _usePositioningFlags = false;
private X11WindowMode _mode;
+ private IWindowIconImpl? _iconImpl;
private enum XSyncState
{
@@ -252,14 +253,14 @@ namespace Avalonia.X11
_mode.AppendWmProtocols(data);
- XChangeProperty(_x11.Display, _handle, _x11.Atoms.WM_PROTOCOLS, _x11.Atoms.XA_ATOM, 32,
+ XChangeProperty(_x11.Display, _handle, _x11.Atoms.WM_PROTOCOLS, _x11.Atoms.ATOM, 32,
PropertyMode.Replace, data.ToArray(), data.Count);
if (_x11.HasXSync)
{
_xSyncCounter = XSyncCreateCounter(_x11.Display, _xSyncValue);
XChangeProperty(_x11.Display, _handle, _x11.Atoms._NET_WM_SYNC_REQUEST_COUNTER,
- _x11.Atoms.XA_CARDINAL, 32, PropertyMode.Replace, ref _xSyncCounter, 1);
+ _x11.Atoms.CARDINAL, 32, PropertyMode.Replace, ref _xSyncCounter, 1);
}
_storageProvider = new FallbackStorageProvider(new[]
@@ -365,7 +366,7 @@ namespace Avalonia.X11
var pid = (uint)s_pid;
// The type of `_NET_WM_PID` is `CARDINAL` which is 32-bit unsigned integer, see https://specifications.freedesktop.org/wm-spec/1.3/ar01s05.html
XChangeProperty(_x11.Display, windowXId,
- _x11.Atoms._NET_WM_PID, _x11.Atoms.XA_CARDINAL, 32,
+ _x11.Atoms._NET_WM_PID, _x11.Atoms.CARDINAL, 32,
PropertyMode.Replace, ref pid, 1);
const int maxLength = 1024;
@@ -384,7 +385,7 @@ namespace Avalonia.X11
}
XChangeProperty(_x11.Display, windowXId,
- _x11.Atoms.XA_WM_CLIENT_MACHINE, _x11.Atoms.XA_STRING, 8,
+ _x11.Atoms.WM_CLIENT_MACHINE, _x11.Atoms.STRING, 8,
PropertyMode.Replace, name, length);
}
@@ -1149,7 +1150,7 @@ namespace Avalonia.X11
public void SetParent(IWindowImpl? parent)
{
if (parent == null || parent.Handle == null || parent.Handle.Handle == IntPtr.Zero)
- XDeleteProperty(_x11.Display, _handle, _x11.Atoms.XA_WM_TRANSIENT_FOR);
+ XDeleteProperty(_x11.Display, _handle, _x11.Atoms.WM_TRANSIENT_FOR);
else
XSetTransientForHint(_x11.Display, _handle, parent.Handle.Handle);
}
@@ -1393,7 +1394,7 @@ namespace Avalonia.X11
if (string.IsNullOrEmpty(title))
{
XDeleteProperty(_x11.Display, _handle, _x11.Atoms._NET_WM_NAME);
- XDeleteProperty(_x11.Display, _handle, _x11.Atoms.XA_WM_NAME);
+ XDeleteProperty(_x11.Display, _handle, _x11.Atoms.WM_NAME);
}
else
{
@@ -1530,6 +1531,11 @@ namespace Avalonia.X11
public void SetIcon(IWindowIconImpl? icon)
{
+ if (ReferenceEquals(_iconImpl, icon))
+ return;
+
+ _iconImpl = icon;
+
if (icon != null)
{
var data = ((X11IconData)icon).Data;
@@ -1642,7 +1648,7 @@ namespace Avalonia.X11
_ => _x11.Atoms._NET_WM_WINDOW_TYPE_NORMAL
};
- XChangeProperty(_x11.Display, _handle, _x11.Atoms._NET_WM_WINDOW_TYPE, _x11.Atoms.XA_ATOM,
+ XChangeProperty(_x11.Display, _handle, _x11.Atoms._NET_WM_WINDOW_TYPE, _x11.Atoms.ATOM,
32, PropertyMode.Replace, new[] { atom }, 1);
}
diff --git a/src/Avalonia.X11/XLib.cs b/src/Avalonia.X11/XLib.cs
index 2c8ecf2c94..595e996733 100644
--- a/src/Avalonia.X11/XLib.cs
+++ b/src/Avalonia.X11/XLib.cs
@@ -22,6 +22,8 @@ namespace Avalonia.X11
private const string libXInput = "libXi.so.6";
private const string libXCursor = "libXcursor.so.1";
+ public const IntPtr AnyPropertyType = 0;
+
[DllImport(libX11)]
public static extern IntPtr XOpenDisplay(IntPtr display);
diff --git a/src/Avalonia.X11/XResources.cs b/src/Avalonia.X11/XResources.cs
index ee1a0d5d99..982954bfcb 100644
--- a/src/Avalonia.X11/XResources.cs
+++ b/src/Avalonia.X11/XResources.cs
@@ -51,9 +51,9 @@ internal class XResources
string? ReadResourcesString()
{
- XGetWindowProperty(_x11.Display, _x11.RootWindow, _x11.Atoms.XA_RESOURCE_MANAGER,
+ XGetWindowProperty(_x11.Display, _x11.RootWindow, _x11.Atoms.RESOURCE_MANAGER,
IntPtr.Zero, new IntPtr(0x7fffffff),
- false, _x11.Atoms.XA_STRING, out _, out var actualFormat,
+ false, _x11.Atoms.STRING, out _, out var actualFormat,
out var nitems, out _, out var prop);
try
{
@@ -69,7 +69,7 @@ internal class XResources
private void OnRootPropertyChanged(IntPtr atom)
{
- if (atom == _x11.Atoms.XA_RESOURCE_MANAGER)
+ if (atom == _x11.Atoms.RESOURCE_MANAGER)
UpdateResources();
}
}
diff --git a/src/Headless/Avalonia.Headless/HeadlessWindowExtensions.cs b/src/Headless/Avalonia.Headless/HeadlessWindowExtensions.cs
index 79c0d331cd..78b89d6cb1 100644
--- a/src/Headless/Avalonia.Headless/HeadlessWindowExtensions.cs
+++ b/src/Headless/Avalonia.Headless/HeadlessWindowExtensions.cs
@@ -20,9 +20,9 @@ public static class HeadlessWindowExtensions
/// Bitmap with last rendered frame. Null, if nothing was rendered.
public static WriteableBitmap? CaptureRenderedFrame(this TopLevel topLevel)
{
- Dispatcher.UIThread.RunJobs();
- AvaloniaHeadlessPlatform.ForceRenderTimerTick();
- return topLevel.GetLastRenderedFrame();
+ WriteableBitmap? bitmap = null;
+ topLevel.RunJobsOnImpl(w => bitmap = w.GetLastRenderedFrame());
+ return bitmap;
}
///
@@ -114,6 +114,15 @@ public static class HeadlessWindowExtensions
DragDropEffects effects, RawInputModifiers modifiers = RawInputModifiers.None) =>
RunJobsOnImpl(topLevel, w => w.DragDrop(point, type, data, effects, modifiers));
+ ///
+ /// Changes the render scaling (DPI) of the headless window/toplevel.
+ /// This simulates a DPI change, triggering scaling changed notifications and a layout pass.
+ ///
+ /// The target headless top level.
+ /// The new render scaling factor. Must be greater than zero.
+ public static void SetRenderScaling(this TopLevel topLevel, double scaling) =>
+ RunJobsOnImpl(topLevel, w => w.SetRenderScaling(scaling));
+
private static void RunJobsOnImpl(this TopLevel topLevel, Action action)
{
RunJobsAndRender();
diff --git a/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs b/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs
index 275dc7f48a..999a20644f 100644
--- a/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs
+++ b/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs
@@ -49,7 +49,7 @@ namespace Avalonia.Headless
public Size ClientSize { get; set; }
public Size? FrameSize => null;
- public double RenderScaling { get; } = 1;
+ public double RenderScaling { get; private set; } = 1;
public double DesktopScaling => RenderScaling;
public IPlatformRenderSurface[] Surfaces { get; }
public Action? Input { get; set; }
@@ -358,6 +358,20 @@ namespace Avalonia.Headless
Input?.Invoke(new RawDragEvent(device, type, InputRoot!, point, data, effects, modifiers));
}
+ void IHeadlessWindow.SetRenderScaling(double scaling)
+ {
+ if (scaling <= 0)
+ throw new ArgumentOutOfRangeException(nameof(scaling), "Scaling must be greater than zero.");
+
+ if (RenderScaling == scaling)
+ return;
+
+ var oldScaledSize = ClientSize;
+ RenderScaling = scaling;
+ ScalingChanged?.Invoke(scaling);
+ Resize(oldScaledSize, WindowResizeReason.DpiChange);
+ }
+
void IWindowImpl.Move(PixelPoint point)
{
Position = point;
diff --git a/src/Headless/Avalonia.Headless/IHeadlessWindow.cs b/src/Headless/Avalonia.Headless/IHeadlessWindow.cs
index 30c2390f64..44ac0a5ace 100644
--- a/src/Headless/Avalonia.Headless/IHeadlessWindow.cs
+++ b/src/Headless/Avalonia.Headless/IHeadlessWindow.cs
@@ -16,5 +16,6 @@ namespace Avalonia.Headless
void MouseUp(Point point, MouseButton button, RawInputModifiers modifiers = RawInputModifiers.None);
void MouseWheel(Point point, Vector delta, RawInputModifiers modifiers = RawInputModifiers.None);
void DragDrop(Point point, RawDragEventType type, IDataTransfer data, DragDropEffects effects, RawInputModifiers modifiers = RawInputModifiers.None);
+ void SetRenderScaling(double scaling);
}
}
diff --git a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlSelectorTransformer.cs b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlSelectorTransformer.cs
index d37cffc360..1a8a80329f 100644
--- a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlSelectorTransformer.cs
+++ b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlSelectorTransformer.cs
@@ -209,8 +209,10 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions.Transformers
throw new XamlSelectorsTransformException("Unable to parse selector: " + e.Message, node, e);
}
+ // Selectors should resolve control types only.
+ // isMarkupExtension = false to prevent resolving selector types to XExtension.
var selector = Create(parsed, (p, n)
- => TypeReferenceResolver.ResolveType(context, $"{p}:{n}", true, node, true));
+ => TypeReferenceResolver.ResolveType(context, $"{p}:{n}", false, node, true));
pn.Values[0] = selector;
var templateType = GetLastTemplateTypeFromSelector(selector);
diff --git a/src/Windows/Avalonia.Win32.Interoperability/WinForms/WinFormsAvaloniaMessageFilter.cs b/src/Windows/Avalonia.Win32.Interoperability/WinForms/WinFormsAvaloniaMessageFilter.cs
new file mode 100644
index 0000000000..3df4b89ce9
--- /dev/null
+++ b/src/Windows/Avalonia.Win32.Interoperability/WinForms/WinFormsAvaloniaMessageFilter.cs
@@ -0,0 +1,48 @@
+using System;
+using System.Windows.Forms;
+using static Avalonia.Win32.Interop.UnmanagedMethods;
+
+namespace Avalonia.Win32.Interoperability;
+
+///
+/// Provides a message filter for integrating Avalonia within a WinForms application.
+///
+///
+/// This filter ensures that key messages, which are typically handled specially by WinForms,
+/// are intercepted and routed to Avalonia's windows. This is necessary to preserve proper input handling
+/// in mixed WinForms and Avalonia application scenarios.
+///
+public class WinFormsAvaloniaMessageFilter : IMessageFilter
+{
+ ///
+ public bool PreFilterMessage(ref Message m)
+ {
+ // WinForms handles key messages specially, preventing them from reaching Avalonia's windows.
+ // Handle them first.
+ if (m.Msg >= (int)WindowsMessage.WM_KEYFIRST &&
+ m.Msg <= (int)WindowsMessage.WM_KEYLAST &&
+ WindowImpl.IsOurWindowGlobal(m.HWnd) &&
+ !IsInsideWinForms(m.HWnd))
+ {
+ var msg = new MSG
+ {
+ hwnd = m.HWnd,
+ message = (uint)m.Msg,
+ wParam = m.WParam,
+ lParam = m.LParam
+ };
+
+ TranslateMessage(ref msg);
+ DispatchMessage(ref msg);
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool IsInsideWinForms(IntPtr hwnd)
+ {
+ var parentHwnd = GetParent(hwnd);
+ return parentHwnd != IntPtr.Zero && Control.FromHandle(parentHwnd) is WinFormsAvaloniaControlHost;
+ }
+}
diff --git a/src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs b/src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs
index 5295e2c03a..82aaac226c 100644
--- a/src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs
+++ b/src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs
@@ -1004,6 +1004,14 @@ namespace Avalonia.Win32
if (hwnd == _hwnd)
return true;
+ return IsOurWindowGlobal(hwnd);
+ }
+
+ internal static bool IsOurWindowGlobal(IntPtr hwnd)
+ {
+ if (hwnd == IntPtr.Zero)
+ return false;
+
lock (s_instances)
for (int i = 0; i < s_instances.Count; i++)
if (s_instances[i]._hwnd == hwnd)
diff --git a/src/Windows/Avalonia.Win32/WindowImpl.cs b/src/Windows/Avalonia.Win32/WindowImpl.cs
index cc2e7211f1..db42fca251 100644
--- a/src/Windows/Avalonia.Win32/WindowImpl.cs
+++ b/src/Windows/Avalonia.Win32/WindowImpl.cs
@@ -809,6 +809,9 @@ namespace Avalonia.Win32
public void SetIcon(IWindowIconImpl? icon)
{
+ if (ReferenceEquals(_iconImpl, icon))
+ return;
+
_iconImpl = (IconImpl?)icon;
ClearIconCache();
RefreshIcon();
diff --git a/src/tools/DevGenerators/X11AtomsGenerator.cs b/src/tools/DevGenerators/X11AtomsGenerator.cs
index daf003c4c4..920b3477dc 100644
--- a/src/tools/DevGenerators/X11AtomsGenerator.cs
+++ b/src/tools/DevGenerators/X11AtomsGenerator.cs
@@ -1,9 +1,8 @@
-using System.IO;
+using System.Collections.Generic;
using System.Linq;
using System.Text;
using Generator;
using Microsoft.CodeAnalysis;
-using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace DevGenerators;
@@ -40,24 +39,44 @@ public class X11AtomsGenerator : IIncrementalGenerator
.AppendLine(cl.Name)
.AppendLine("{");
- var fields = cl.GetMembers().OfType()
+ var allFields = cl.GetMembers().OfType()
.Where(f => f.Type.Name == "IntPtr"
- && f.DeclaredAccessibility == Accessibility.Public).ToList();
-
+ && f.DeclaredAccessibility == Accessibility.Public);
+
+ var writeableFields = new List(128);
+ var readonlyFields = new List(128);
+
+ foreach (var field in allFields)
+ {
+ var fields = field.IsReadOnly ? readonlyFields : writeableFields;
+ fields.Add(field);
+ }
+
classBuilder.Pad(1).AppendLine("private void PopulateAtoms(IntPtr display)").Pad(1).AppendLine("{");
- classBuilder.Pad(2).Append("var atoms = new IntPtr[").Append(fields.Count).AppendLine("];");
- classBuilder.Pad(2).Append("var atomNames = new string[").Append(fields.Count).AppendLine("] {");
+ for (int c = 0; c < readonlyFields.Count; c++)
+ {
+ var field = readonlyFields[c];
+ var initializer =
+ (field.DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax(context.CancellationToken) as VariableDeclaratorSyntax)
+ ?.Initializer?.Value;
+
+ classBuilder.Pad(2).Append("SetName(").Append('\"')
+ .Append(field.Name).Append("\", ").Append(initializer).AppendLine(");");
+ }
+
+ classBuilder.Pad(2).Append("var atoms = new IntPtr[").Append(writeableFields.Count).AppendLine("];");
+ classBuilder.Pad(2).Append("var atomNames = new string[").Append(writeableFields.Count).AppendLine("] {");
- for (int c = 0; c < fields.Count; c++)
- classBuilder.Pad(3).Append("\"").Append(fields[c].Name).AppendLine("\",");
+ for (int c = 0; c < writeableFields.Count; c++)
+ classBuilder.Pad(3).Append("\"").Append(writeableFields[c].Name).AppendLine("\",");
classBuilder.Pad(2).AppendLine("};");
classBuilder.Pad(2).AppendLine("XInternAtoms(display, atomNames, atomNames.Length, true, atoms);");
- for (int c = 0; c < fields.Count; c++)
- classBuilder.Pad(2).Append("InitAtom(ref ").Append(fields[c].Name).Append(", \"")
- .Append(fields[c].Name).Append("\", atoms[").Append(c).AppendLine("]);");
+ for (int c = 0; c < writeableFields.Count; c++)
+ classBuilder.Pad(2).Append("InitAtom(ref ").Append(writeableFields[c].Name).Append(", \"")
+ .Append(writeableFields[c].Name).Append("\", atoms[").Append(c).AppendLine("]);");
classBuilder.Pad(1).AppendLine("}");
@@ -70,4 +89,4 @@ public class X11AtomsGenerator : IIncrementalGenerator
}
-}
\ No newline at end of file
+}
diff --git a/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs b/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs
index 7755eb80cf..cdb4588fff 100644
--- a/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs
+++ b/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs
@@ -577,7 +577,7 @@ namespace Avalonia.Base.UnitTests.Input
};
target.Focus();
- root.FocusManager.ClearFocus();
+ root.FocusManager.Focus(null);
Assert.Null(root.FocusManager.GetFocusedElement());
}
diff --git a/tests/Avalonia.Base.UnitTests/Input/KeyboardDeviceTests.cs b/tests/Avalonia.Base.UnitTests/Input/KeyboardDeviceTests.cs
index d11872ba6a..b1446d961f 100644
--- a/tests/Avalonia.Base.UnitTests/Input/KeyboardDeviceTests.cs
+++ b/tests/Avalonia.Base.UnitTests/Input/KeyboardDeviceTests.cs
@@ -15,7 +15,7 @@ namespace Avalonia.Base.UnitTests.Input
using (UnitTestApplication.Start(TestServices.FocusableWindow))
{
var window = new Window();
- window.FocusManager.ClearFocus();
+ window.FocusManager.Focus(null);
int raised = 0;
window.KeyDown += (sender, ev) =>
{
@@ -71,7 +71,7 @@ namespace Avalonia.Base.UnitTests.Input
using (UnitTestApplication.Start(TestServices.FocusableWindow))
{
var window = new Window();
- window.FocusManager.ClearFocus();
+ window.FocusManager.Focus(null);
int raised = 0;
window.TextInput += (sender, ev) =>
{
diff --git a/tests/Avalonia.Base.UnitTests/Input/SwipeGestureRecognizerTests.cs b/tests/Avalonia.Base.UnitTests/Input/SwipeGestureRecognizerTests.cs
new file mode 100644
index 0000000000..d0821c91b1
--- /dev/null
+++ b/tests/Avalonia.Base.UnitTests/Input/SwipeGestureRecognizerTests.cs
@@ -0,0 +1,158 @@
+using System.Collections.Generic;
+using System.Threading;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Input.GestureRecognizers;
+using Avalonia.UnitTests;
+using Xunit;
+
+namespace Avalonia.Base.UnitTests.Input;
+
+public class SwipeGestureRecognizerTests : ScopedTestBase
+{
+ [Fact]
+ public void Does_Not_Raise_Swipe_When_Both_Axes_Are_Disabled()
+ {
+ var (border, root) = CreateTarget(new SwipeGestureRecognizer { Threshold = 1 });
+ var touch = new TouchTestHelper();
+ var swipeRaised = false;
+ var endedRaised = false;
+
+ root.AddHandler(InputElement.SwipeGestureEvent, (_, _) => swipeRaised = true);
+ root.AddHandler(InputElement.SwipeGestureEndedEvent, (_, _) => endedRaised = true);
+
+ touch.Down(border, new Point(50, 50));
+ touch.Move(border, new Point(20, 20));
+ touch.Up(border, new Point(20, 20));
+
+ Assert.False(swipeRaised);
+ Assert.False(endedRaised);
+ }
+
+ [Fact]
+ public void Defaults_Disable_Both_Axes()
+ {
+ var recognizer = new SwipeGestureRecognizer();
+
+ Assert.False(recognizer.CanHorizontallySwipe);
+ Assert.False(recognizer.CanVerticallySwipe);
+ }
+
+ [Fact]
+ public void Starts_Only_After_Threshold_Is_Exceeded()
+ {
+ var (border, root) = CreateTarget(new SwipeGestureRecognizer
+ {
+ CanHorizontallySwipe = true,
+ Threshold = 50
+ });
+ var touch = new TouchTestHelper();
+ var deltas = new List();
+
+ root.AddHandler(InputElement.SwipeGestureEvent, (_, e) => deltas.Add(e.Delta));
+
+ touch.Down(border, new Point(5, 5));
+ touch.Move(border, new Point(40, 5));
+
+ Assert.Empty(deltas);
+
+ touch.Move(border, new Point(80, 5));
+
+ Assert.Single(deltas);
+ Assert.NotEqual(Vector.Zero, deltas[0]);
+ }
+
+ [Fact]
+ public void Ended_Event_Uses_Same_Id_And_Last_Velocity()
+ {
+ var (border, root) = CreateTarget(new SwipeGestureRecognizer
+ {
+ CanHorizontallySwipe = true,
+ Threshold = 1
+ });
+ var touch = new TouchTestHelper();
+ var updateIds = new List();
+ var velocities = new List();
+ var endedId = 0;
+ var endedVelocity = Vector.Zero;
+
+ root.AddHandler(InputElement.SwipeGestureEvent, (_, e) =>
+ {
+ updateIds.Add(e.Id);
+ velocities.Add(e.Velocity);
+ });
+ root.AddHandler(InputElement.SwipeGestureEndedEvent, (_, e) =>
+ {
+ endedId = e.Id;
+ endedVelocity = e.Velocity;
+ });
+
+ touch.Down(border, new Point(50, 50));
+ touch.Move(border, new Point(40, 50));
+ touch.Move(border, new Point(30, 50));
+ touch.Up(border, new Point(30, 50));
+
+ Assert.True(updateIds.Count >= 2);
+ Assert.All(updateIds, id => Assert.Equal(updateIds[0], id));
+ Assert.Equal(updateIds[0], endedId);
+ Assert.Equal(velocities[^1], endedVelocity);
+ }
+
+ [Fact]
+ public void Mouse_Swipe_Requires_IsMouseEnabled()
+ {
+ var mouse = new MouseTestHelper();
+ var (border, root) = CreateTarget(new SwipeGestureRecognizer
+ {
+ CanHorizontallySwipe = true,
+ Threshold = 1
+ });
+ var swipeRaised = false;
+
+ root.AddHandler(InputElement.SwipeGestureEvent, (_, _) => swipeRaised = true);
+
+ mouse.Down(border, position: new Point(50, 50));
+ mouse.Move(border, new Point(30, 50));
+ mouse.Up(border, position: new Point(30, 50));
+
+ Assert.False(swipeRaised);
+ }
+
+ [Fact]
+ public void Mouse_Swipe_Is_Raised_When_Enabled()
+ {
+ var mouse = new MouseTestHelper();
+ var (border, root) = CreateTarget(new SwipeGestureRecognizer
+ {
+ CanHorizontallySwipe = true,
+ Threshold = 1,
+ IsMouseEnabled = true
+ });
+ var swipeRaised = false;
+
+ root.AddHandler(InputElement.SwipeGestureEvent, (_, _) => swipeRaised = true);
+
+ mouse.Down(border, position: new Point(50, 50));
+ mouse.Move(border, new Point(30, 50));
+ mouse.Up(border, position: new Point(30, 50));
+
+ Assert.True(swipeRaised);
+ }
+
+ private static (Border Border, TestRoot Root) CreateTarget(SwipeGestureRecognizer recognizer)
+ {
+ var border = new Border
+ {
+ Width = 100,
+ Height = 100
+ };
+ border.GestureRecognizers.Add(recognizer);
+
+ var root = new TestRoot
+ {
+ Child = border
+ };
+
+ return (border, root);
+ }
+}
diff --git a/tests/Avalonia.Base.UnitTests/Rendering/CompositorInvalidationTests.cs b/tests/Avalonia.Base.UnitTests/Rendering/CompositorInvalidationTests.cs
index 699f450223..ef0e01a104 100644
--- a/tests/Avalonia.Base.UnitTests/Rendering/CompositorInvalidationTests.cs
+++ b/tests/Avalonia.Base.UnitTests/Rendering/CompositorInvalidationTests.cs
@@ -38,6 +38,31 @@ public class CompositorInvalidationTests : CompositorTestsBase
s.AssertRects(new Rect(30, 50, 20, 10));
}
}
+
+ [Fact]
+ public void Sibling_Controls_Should_Invalidate_Union_Rect_When_Removed()
+ {
+ using (var s = new CompositorCanvas())
+ {
+ var control = new Border()
+ {
+ Background = Brushes.Red, Width = 20, Height = 10,
+ [Canvas.LeftProperty] = 30, [Canvas.TopProperty] = 10
+ };
+ var control2 = new Border()
+ {
+ Background = Brushes.Blue, Width = 20, Height = 10,
+ [Canvas.LeftProperty] = 30, [Canvas.TopProperty] = 50
+ };
+ s.Canvas.Children.Add(control);
+ s.Canvas.Children.Add(control2);
+ s.RunJobs();
+ s.Events.Rects.Clear();
+ s.Canvas.Children.Remove(control);
+ s.Canvas.Children.Remove(control2);
+ s.AssertRects(new Rect(30, 10, 20, 50));
+ }
+ }
[Fact]
public void Control_Should_Invalidate_Both_Own_Rects_When_Moved()
diff --git a/tests/Avalonia.Controls.UnitTests/BorderTests.cs b/tests/Avalonia.Controls.UnitTests/BorderTests.cs
index e31eb08964..df80998b05 100644
--- a/tests/Avalonia.Controls.UnitTests/BorderTests.cs
+++ b/tests/Avalonia.Controls.UnitTests/BorderTests.cs
@@ -1,9 +1,6 @@
+using System;
using Avalonia.Layout;
-using Avalonia.Media;
-using Avalonia.Rendering;
using Avalonia.UnitTests;
-using Avalonia.VisualTree;
-using Moq;
using Xunit;
namespace Avalonia.Controls.UnitTests
@@ -45,14 +42,31 @@ namespace Avalonia.Controls.UnitTests
Assert.Equal(new Rect(6, 6, 0, 0), content.Bounds);
}
-
+
+ [Fact]
+ public void Should_Reject_NaN_Or_Infinite_Thicknesses()
+ {
+ var target = new Border();
+
+ SetValues(target, Layoutable.MarginProperty);
+ SetValues(target, Decorator.PaddingProperty);
+ SetValues(target, Border.BorderThicknessProperty);
+
+ static void SetValues(Border target, AvaloniaProperty property)
+ {
+ Assert.Throws(() => target.SetValue(property, new Thickness(0, 0, 0, double.NaN)));
+ Assert.Throws(() => target.SetValue(property, new Thickness(0, 0, 0, double.PositiveInfinity)));
+ Assert.Throws(() => target.SetValue(property, new Thickness(0, 0, 0, double.NegativeInfinity)));
+ }
+ }
+
public class UseLayoutRounding : ScopedTestBase
{
[Fact]
public void Measure_Rounds_Padding()
{
- var target = new Border
- {
+ var target = new Border
+ {
Padding = new Thickness(1),
Child = new Canvas
{
diff --git a/tests/Avalonia.Controls.UnitTests/CarouselTests.cs b/tests/Avalonia.Controls.UnitTests/CarouselTests.cs
index ab93686966..11221eb7d1 100644
--- a/tests/Avalonia.Controls.UnitTests/CarouselTests.cs
+++ b/tests/Avalonia.Controls.UnitTests/CarouselTests.cs
@@ -2,10 +2,12 @@ using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Reactive.Subjects;
+using Avalonia.Animation;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
using Avalonia.Data;
+using Avalonia.Input;
using Avalonia.Layout;
using Avalonia.LogicalTree;
using Avalonia.UnitTests;
@@ -59,6 +61,28 @@ namespace Avalonia.Controls.UnitTests
Assert.Equal("Foo", child.Text);
}
+ [Fact]
+ public void ViewportFraction_Defaults_To_One()
+ {
+ using var app = Start();
+ var target = new Carousel();
+
+ Assert.Equal(1d, target.ViewportFraction);
+ }
+
+ [Fact]
+ public void ViewportFraction_Coerces_Invalid_Values_To_One()
+ {
+ using var app = Start();
+ var target = new Carousel();
+
+ target.ViewportFraction = 0;
+ Assert.Equal(1d, target.ViewportFraction);
+
+ target.ViewportFraction = double.NaN;
+ Assert.Equal(1d, target.ViewportFraction);
+ }
+
[Fact]
public void Selected_Item_Changes_To_First_Item_When_Items_Property_Changes()
{
@@ -147,8 +171,7 @@ namespace Avalonia.Controls.UnitTests
target.ItemsSource = null;
Layout(target);
- var numChildren = target.GetRealizedContainers().Count();
-
+ Assert.Empty(target.GetRealizedContainers());
Assert.Equal(-1, target.SelectedIndex);
}
@@ -326,6 +349,204 @@ namespace Avalonia.Controls.UnitTests
Assert.Equal(1, target.SelectedIndex);
}
+ public class WrapSelectionTests : ScopedTestBase
+ {
+ [Fact]
+ public void Next_Loops_When_WrapSelection_Is_True()
+ {
+ using var app = Start();
+ var items = new[] { "foo", "bar", "baz" };
+ var target = new Carousel
+ {
+ Template = CarouselTemplate(),
+ ItemsSource = items,
+ WrapSelection = true,
+ SelectedIndex = 2
+ };
+
+ Prepare(target);
+
+ target.Next();
+ Layout(target);
+
+ Assert.Equal(0, target.SelectedIndex);
+ }
+
+ [Fact]
+ public void Previous_Loops_When_WrapSelection_Is_True()
+ {
+ using var app = Start();
+ var items = new[] { "foo", "bar", "baz" };
+ var target = new Carousel
+ {
+ Template = CarouselTemplate(),
+ ItemsSource = items,
+ WrapSelection = true,
+ SelectedIndex = 0
+ };
+
+ Prepare(target);
+
+ target.Previous();
+ Layout(target);
+
+ Assert.Equal(2, target.SelectedIndex);
+ }
+
+ [Fact]
+ public void Next_Does_Not_Loop_When_WrapSelection_Is_False()
+ {
+ using var app = Start();
+ var items = new[] { "foo", "bar", "baz" };
+ var target = new Carousel
+ {
+ Template = CarouselTemplate(),
+ ItemsSource = items,
+ WrapSelection = false,
+ SelectedIndex = 2
+ };
+
+ Prepare(target);
+
+ target.Next();
+ Layout(target);
+
+ Assert.Equal(2, target.SelectedIndex);
+ }
+
+ [Fact]
+ public void Previous_Does_Not_Loop_When_WrapSelection_Is_False()
+ {
+ using var app = Start();
+ var items = new[] { "foo", "bar", "baz" };
+ var target = new Carousel
+ {
+ Template = CarouselTemplate(),
+ ItemsSource = items,
+ WrapSelection = false,
+ SelectedIndex = 0
+ };
+
+ Prepare(target);
+
+ target.Previous();
+ Layout(target);
+
+ Assert.Equal(0, target.SelectedIndex);
+ }
+ }
+
+
+
+ [Fact]
+ public void Right_Arrow_Navigates_To_Next_With_Horizontal_PageSlide()
+ {
+ using var app = Start();
+ var target = new Carousel
+ {
+ Template = CarouselTemplate(),
+ ItemsSource = new[] { "Foo", "Bar", "Baz" },
+ PageTransition = new PageSlide(TimeSpan.FromMilliseconds(100), PageSlide.SlideAxis.Horizontal),
+ };
+
+ Prepare(target);
+ Assert.Equal(0, target.SelectedIndex);
+
+ target.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Right });
+ Assert.Equal(1, target.SelectedIndex);
+ }
+
+ [Fact]
+ public void Down_Arrow_Navigates_To_Next_With_Vertical_PageSlide()
+ {
+ using var app = Start();
+ var target = new Carousel
+ {
+ Template = CarouselTemplate(),
+ ItemsSource = new[] { "Foo", "Bar", "Baz" },
+ PageTransition = new PageSlide(TimeSpan.FromMilliseconds(100), PageSlide.SlideAxis.Vertical),
+ };
+
+ Prepare(target);
+ Assert.Equal(0, target.SelectedIndex);
+
+ target.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Down });
+ Assert.Equal(1, target.SelectedIndex);
+ }
+
+ [Fact]
+ public void Home_Navigates_To_First_Item()
+ {
+ using var app = Start();
+ var target = new Carousel
+ {
+ Template = CarouselTemplate(),
+ ItemsSource = new[] { "Foo", "Bar", "Baz" },
+ SelectedIndex = 2,
+ };
+
+ Prepare(target);
+ Layout(target);
+ Assert.Equal(2, target.SelectedIndex);
+
+ target.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Home });
+ Assert.Equal(0, target.SelectedIndex);
+ }
+
+ [Fact]
+ public void End_Navigates_To_Last_Item()
+ {
+ using var app = Start();
+ var target = new Carousel
+ {
+ Template = CarouselTemplate(),
+ ItemsSource = new[] { "Foo", "Bar", "Baz" },
+ };
+
+ Prepare(target);
+ Assert.Equal(0, target.SelectedIndex);
+
+ target.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.End });
+ Assert.Equal(2, target.SelectedIndex);
+ }
+
+ [Fact]
+ public void Wrong_Axis_Arrow_Is_Ignored()
+ {
+ using var app = Start();
+ var target = new Carousel
+ {
+ Template = CarouselTemplate(),
+ ItemsSource = new[] { "Foo", "Bar", "Baz" },
+ PageTransition = new PageSlide(TimeSpan.FromMilliseconds(100), PageSlide.SlideAxis.Horizontal),
+ };
+
+ Prepare(target);
+ Assert.Equal(0, target.SelectedIndex);
+
+ target.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Down });
+ Assert.Equal(0, target.SelectedIndex);
+ }
+
+ [Fact]
+ public void Left_Arrow_Wraps_With_WrapSelection()
+ {
+ using var app = Start();
+ var target = new Carousel
+ {
+ Template = CarouselTemplate(),
+ ItemsSource = new[] { "Foo", "Bar", "Baz" },
+ PageTransition = new PageSlide(TimeSpan.FromMilliseconds(100), PageSlide.SlideAxis.Horizontal),
+ WrapSelection = true,
+ };
+
+ Prepare(target);
+ Assert.Equal(0, target.SelectedIndex);
+
+ target.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Left });
+ Assert.Equal(2, target.SelectedIndex);
+ }
+
private static IDisposable Start() => UnitTestApplication.Start(TestServices.MockPlatformRenderInterface);
private static void Prepare(Carousel target)
diff --git a/tests/Avalonia.Controls.UnitTests/DrawerPageTests.cs b/tests/Avalonia.Controls.UnitTests/DrawerPageTests.cs
index ca3b1267bd..d8f50b81de 100644
--- a/tests/Avalonia.Controls.UnitTests/DrawerPageTests.cs
+++ b/tests/Avalonia.Controls.UnitTests/DrawerPageTests.cs
@@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using System.Threading.Tasks;
using Avalonia.Input;
+using Avalonia.Input.GestureRecognizers;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.LogicalTree;
@@ -1049,6 +1051,82 @@ public class DrawerPageTests
}
}
+ public class SwipeGestureTests : ScopedTestBase
+ {
+ [Fact]
+ public void HandledPointerPressedAtEdge_AllowsSwipeOpen()
+ {
+ var dp = new DrawerPage
+ {
+ DrawerPlacement = DrawerPlacement.Left,
+ DisplayMode = SplitViewDisplayMode.Overlay,
+ Width = 400,
+ Height = 300
+ };
+ dp.GestureRecognizers.OfType().First().IsMouseEnabled = true;
+
+ var root = new TestRoot
+ {
+ ClientSize = new Size(400, 300),
+ Child = dp
+ };
+ root.ExecuteInitialLayoutPass();
+
+ RaiseHandledPointerPressed(dp, new Point(5, 5));
+
+ var swipe = new SwipeGestureEventArgs(1, new Vector(-20, 0), default);
+ dp.RaiseEvent(swipe);
+
+ Assert.True(swipe.Handled);
+ Assert.True(dp.IsOpen);
+ }
+
+ [Fact]
+ public void MouseEdgeDrag_AllowsSwipeOpen()
+ {
+ var dp = new DrawerPage
+ {
+ DrawerPlacement = DrawerPlacement.Left,
+ DisplayMode = SplitViewDisplayMode.Overlay,
+ Width = 400,
+ Height = 300
+ };
+ dp.GestureRecognizers.OfType().First().IsMouseEnabled = true;
+
+ var root = new TestRoot
+ {
+ ClientSize = new Size(400, 300),
+ Child = dp
+ };
+ root.ExecuteInitialLayoutPass();
+
+ var mouse = new MouseTestHelper();
+ mouse.Down(dp, position: new Point(5, 5));
+ mouse.Move(dp, new Point(40, 5));
+ mouse.Up(dp, position: new Point(40, 5));
+
+ Assert.True(dp.IsOpen);
+ }
+
+ private static void RaiseHandledPointerPressed(Interactive target, Point position)
+ {
+ var pointer = new Pointer(Pointer.GetNextFreeId(), PointerType.Touch, true);
+ var args = new PointerPressedEventArgs(
+ target,
+ pointer,
+ (Visual)target,
+ position,
+ timestamp: 1,
+ new PointerPointProperties(RawInputModifiers.LeftMouseButton, PointerUpdateKind.LeftButtonPressed),
+ KeyModifiers.None)
+ {
+ Handled = true
+ };
+
+ target.RaiseEvent(args);
+ }
+ }
+
public class DetachmentTests : ScopedTestBase
{
[Fact]
diff --git a/tests/Avalonia.Controls.UnitTests/InputElementGestureTests.cs b/tests/Avalonia.Controls.UnitTests/InputElementGestureTests.cs
new file mode 100644
index 0000000000..20f5f2ec2e
--- /dev/null
+++ b/tests/Avalonia.Controls.UnitTests/InputElementGestureTests.cs
@@ -0,0 +1,23 @@
+using Avalonia.Input;
+using Avalonia.UnitTests;
+using Xunit;
+
+namespace Avalonia.Controls.UnitTests;
+
+public class InputElementGestureTests : ScopedTestBase
+{
+ [Fact]
+ public void SwipeGestureEnded_PublicEvent_CanBeObserved()
+ {
+ var target = new Border();
+ SwipeGestureEndedEventArgs? received = null;
+
+ target.SwipeGestureEnded += (_, e) => received = e;
+
+ var args = new SwipeGestureEndedEventArgs(42, new Vector(12, 34));
+ target.RaiseEvent(args);
+
+ Assert.Same(args, received);
+ Assert.Equal(InputElement.SwipeGestureEndedEvent, args.RoutedEvent);
+ }
+}
diff --git a/tests/Avalonia.Controls.UnitTests/NavigationPageTests.cs b/tests/Avalonia.Controls.UnitTests/NavigationPageTests.cs
index 9602256fe8..2d15825f72 100644
--- a/tests/Avalonia.Controls.UnitTests/NavigationPageTests.cs
+++ b/tests/Avalonia.Controls.UnitTests/NavigationPageTests.cs
@@ -1,13 +1,18 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Animation;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
+using Avalonia.Input;
+using Avalonia.Input.GestureRecognizers;
using Avalonia.Interactivity;
using Avalonia.LogicalTree;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
using Avalonia.UnitTests;
using Xunit;
@@ -1578,6 +1583,116 @@ public class NavigationPageTests
}
}
+ public class SwipeGestureTests : ScopedTestBase
+ {
+ [Fact]
+ public async Task HandledPointerPressedAtEdge_AllowsSwipePop()
+ {
+ var nav = new NavigationPage();
+ var rootPage = new ContentPage { Header = "Root" };
+ var topPage = new ContentPage { Header = "Top" };
+
+ await nav.PushAsync(rootPage);
+ await nav.PushAsync(topPage);
+
+ var root = new TestRoot { Child = nav };
+ root.ExecuteInitialLayoutPass();
+
+ RaiseHandledPointerPressed(nav, new Point(5, 5));
+
+ var swipe = new SwipeGestureEventArgs(1, new Vector(-20, 0), default);
+ nav.RaiseEvent(swipe);
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ Assert.True(swipe.Handled);
+ Assert.Equal(1, nav.StackDepth);
+ Assert.Same(rootPage, nav.CurrentPage);
+ }
+
+ [Fact]
+ public async Task MouseEdgeDrag_AllowsSwipePop()
+ {
+ var nav = new NavigationPage
+ {
+ Width = 400,
+ Height = 300
+ };
+ nav.GestureRecognizers.OfType().First().IsMouseEnabled = true;
+ var rootPage = new ContentPage { Header = "Root" };
+ var topPage = new ContentPage { Header = "Top" };
+
+ await nav.PushAsync(rootPage);
+ await nav.PushAsync(topPage);
+
+ var root = new TestRoot
+ {
+ ClientSize = new Size(400, 300),
+ Child = nav
+ };
+ root.ExecuteInitialLayoutPass();
+
+ var mouse = new MouseTestHelper();
+ mouse.Down(nav, position: new Point(5, 5));
+ mouse.Move(nav, new Point(40, 5));
+ mouse.Up(nav, position: new Point(40, 5));
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ Assert.Equal(1, nav.StackDepth);
+ Assert.Same(rootPage, nav.CurrentPage);
+ }
+
+ [Fact]
+ public async Task SameGestureId_OnlyPops_One_Page()
+ {
+ var nav = new NavigationPage
+ {
+ Width = 400,
+ Height = 300
+ };
+ var page1 = new ContentPage { Header = "1" };
+ var page2 = new ContentPage { Header = "2" };
+ var page3 = new ContentPage { Header = "3" };
+
+ await nav.PushAsync(page1);
+ await nav.PushAsync(page2);
+ await nav.PushAsync(page3);
+
+ var root = new TestRoot
+ {
+ ClientSize = new Size(400, 300),
+ Child = nav
+ };
+ root.ExecuteInitialLayoutPass();
+
+ RaiseHandledPointerPressed(nav, new Point(5, 5));
+
+ nav.RaiseEvent(new SwipeGestureEventArgs(42, new Vector(-20, 0), default));
+ nav.RaiseEvent(new SwipeGestureEventArgs(42, new Vector(-30, 0), default));
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ Assert.Equal(2, nav.StackDepth);
+ Assert.Same(page2, nav.CurrentPage);
+ }
+
+ private static void RaiseHandledPointerPressed(Interactive target, Point position)
+ {
+ var pointer = new Pointer(Pointer.GetNextFreeId(), PointerType.Touch, true);
+ var args = new PointerPressedEventArgs(
+ target,
+ pointer,
+ (Visual)target,
+ position,
+ timestamp: 1,
+ new PointerPointProperties(RawInputModifiers.LeftMouseButton, PointerUpdateKind.LeftButtonPressed),
+ KeyModifiers.None)
+ {
+ Handled = true
+ };
+
+ target.RaiseEvent(args);
+ }
+ }
+
public class LifecycleAfterTransitionTests : ScopedTestBase
{
[Fact]
diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/VisualLayerManagerTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/VisualLayerManagerTests.cs
new file mode 100644
index 0000000000..70c7d946f9
--- /dev/null
+++ b/tests/Avalonia.Controls.UnitTests/Primitives/VisualLayerManagerTests.cs
@@ -0,0 +1,37 @@
+using Avalonia.Controls.Primitives;
+using Avalonia.UnitTests;
+using Xunit;
+
+namespace Avalonia.Controls.UnitTests.Primitives
+{
+ public class VisualLayerManagerTests : ScopedTestBase
+ {
+ [Fact]
+ public void GetAdornerLayer_Returns_Dedicated_AdornerLayer_For_Controls_Inside_OverlayLayer()
+ {
+ var button = new Button();
+ var vlm = new VisualLayerManager { EnableOverlayLayer = true, Child = button };
+ var root = new TestRoot { Child = vlm };
+
+ root.Measure(new Size(100, 100));
+ root.Arrange(new Rect(0, 0, 100, 100));
+
+ var overlayLayer = vlm.OverlayLayer;
+ Assert.NotNull(overlayLayer);
+
+ var overlayChild = new Border();
+ overlayLayer.Children.Add(overlayChild);
+
+ // The adorner layer for a control inside the OverlayLayer
+ // should be the dedicated one, not the main VLM adorner layer.
+ var overlayAdornerLayer = AdornerLayer.GetAdornerLayer(overlayChild);
+ Assert.NotNull(overlayAdornerLayer);
+ Assert.Same(overlayLayer.AdornerLayer, overlayAdornerLayer);
+
+ // The main VLM adorner layer should be different.
+ var mainAdornerLayer = AdornerLayer.GetAdornerLayer(button);
+ Assert.NotNull(mainAdornerLayer);
+ Assert.NotSame(overlayAdornerLayer, mainAdornerLayer);
+ }
+ }
+}
diff --git a/tests/Avalonia.Controls.UnitTests/TabbedPageTests.cs b/tests/Avalonia.Controls.UnitTests/TabbedPageTests.cs
index c6c567e315..9034161e39 100644
--- a/tests/Avalonia.Controls.UnitTests/TabbedPageTests.cs
+++ b/tests/Avalonia.Controls.UnitTests/TabbedPageTests.cs
@@ -1,12 +1,16 @@
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
+using System.Linq;
using Avalonia.Animation;
using Avalonia.Collections;
using Avalonia.Controls.Shapes;
using Avalonia.Controls.Templates;
+using Avalonia.Input;
+using Avalonia.Input.GestureRecognizers;
using Avalonia.LogicalTree;
using Avalonia.Media;
+using Avalonia.Threading;
using Avalonia.UnitTests;
using Xunit;
@@ -809,6 +813,91 @@ public class TabbedPageTests
}
}
+ public class SwipeGestureTests : ScopedTestBase
+ {
+ [Fact]
+ public void SameGestureId_OnlyAdvancesOneTab()
+ {
+ var tp = CreateSwipeReadyTabbedPage();
+
+ var firstSwipe = new SwipeGestureEventArgs(7, new Vector(20, 0), default);
+ var repeatedSwipe = new SwipeGestureEventArgs(7, new Vector(20, 0), default);
+
+ tp.RaiseEvent(firstSwipe);
+ tp.RaiseEvent(repeatedSwipe);
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ Assert.True(firstSwipe.Handled);
+ Assert.False(repeatedSwipe.Handled);
+ Assert.Equal(1, tp.SelectedIndex);
+ }
+
+ [Fact]
+ public void NewGestureId_CanAdvanceAgain()
+ {
+ var tp = CreateSwipeReadyTabbedPage();
+
+ tp.RaiseEvent(new SwipeGestureEventArgs(7, new Vector(20, 0), default));
+ tp.RaiseEvent(new SwipeGestureEventArgs(8, new Vector(20, 0), default));
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ Assert.Equal(2, tp.SelectedIndex);
+ }
+
+ [Fact]
+ public void MouseSwipe_Advances_Tab()
+ {
+ var tp = CreateSwipeReadyTabbedPage();
+ var mouse = new MouseTestHelper();
+
+ mouse.Down(tp, position: new Point(200, 100));
+ mouse.Move(tp, new Point(160, 100));
+ mouse.Up(tp, position: new Point(160, 100));
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ Assert.Equal(1, tp.SelectedIndex);
+ }
+
+ private static TabbedPage CreateSwipeReadyTabbedPage()
+ {
+ var tp = new TabbedPage
+ {
+ IsGestureEnabled = true,
+ Width = 400,
+ Height = 300,
+ TabPlacement = TabPlacement.Top,
+ SelectedIndex = 0,
+ Pages = new AvaloniaList
+ {
+ new ContentPage { Header = "A" },
+ new ContentPage { Header = "B" },
+ new ContentPage { Header = "C" }
+ },
+ Template = new FuncControlTemplate((parent, scope) =>
+ {
+ var tabControl = new TabControl
+ {
+ Name = "PART_TabControl",
+ ItemsSource = parent.Pages
+ };
+ scope.Register("PART_TabControl", tabControl);
+ return tabControl;
+ })
+ };
+ tp.GestureRecognizers.OfType().First().IsMouseEnabled = true;
+
+ var root = new TestRoot
+ {
+ ClientSize = new Size(400, 300),
+ Child = tp
+ };
+ tp.ApplyTemplate();
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ return tp;
+ }
+ }
+
private sealed class TestableTabbedPage : TabbedPage
{
public void CallCommitSelection(int index, Page? page) => CommitSelection(index, page);
diff --git a/tests/Avalonia.Controls.UnitTests/VirtualizingCarouselPanelTests.cs b/tests/Avalonia.Controls.UnitTests/VirtualizingCarouselPanelTests.cs
index cc506dd7a9..11687fa81d 100644
--- a/tests/Avalonia.Controls.UnitTests/VirtualizingCarouselPanelTests.cs
+++ b/tests/Avalonia.Controls.UnitTests/VirtualizingCarouselPanelTests.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections;
+using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading;
@@ -9,7 +10,9 @@ using Avalonia.Collections;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
+using Avalonia.Input;
using Avalonia.Layout;
+using Avalonia.Media;
using Avalonia.Threading;
using Avalonia.UnitTests;
using Avalonia.VisualTree;
@@ -135,6 +138,86 @@ namespace Avalonia.Controls.UnitTests
});
}
+ [Fact]
+ public void ViewportFraction_Centers_Selected_Item_And_Peeks_Neighbors()
+ {
+ using var app = Start();
+ var items = new[] { "foo", "bar", "baz" };
+ var (target, _) = CreateTarget(items, viewportFraction: 0.8, clientSize: new Size(400, 300));
+
+ var realized = target.GetRealizedContainers()!
+ .OfType()
+ .ToDictionary(x => (string)x.Content!);
+
+ Assert.Equal(2, realized.Count);
+ Assert.Equal(40d, realized["foo"].Bounds.X, 6);
+ Assert.Equal(320d, realized["foo"].Bounds.Width, 6);
+ Assert.Equal(360d, realized["bar"].Bounds.X, 6);
+ }
+
+ [Fact]
+ public void ViewportFraction_OneThird_Shows_Three_Full_Items()
+ {
+ using var app = Start();
+ var items = new[] { "foo", "bar", "baz", "qux" };
+ var (target, carousel) = CreateTarget(items, viewportFraction: 1d / 3d, clientSize: new Size(300, 120));
+
+ carousel.SelectedIndex = 1;
+ Layout(target);
+
+ var realized = target.GetRealizedContainers()!
+ .OfType()
+ .ToDictionary(x => (string)x.Content!);
+
+ Assert.Equal(3, realized.Count);
+ Assert.Equal(0d, realized["foo"].Bounds.X, 6);
+ Assert.Equal(100d, realized["bar"].Bounds.X, 6);
+ Assert.Equal(200d, realized["baz"].Bounds.X, 6);
+ Assert.Equal(100d, realized["bar"].Bounds.Width, 6);
+ }
+
+ [Fact]
+ public void Changing_SelectedIndex_Repositions_Fractional_Viewport()
+ {
+ using var app = Start();
+ var items = new[] { "foo", "bar", "baz" };
+ var (target, carousel) = CreateTarget(items, viewportFraction: 0.8, clientSize: new Size(400, 300));
+
+ carousel.SelectedIndex = 1;
+ Layout(target);
+
+ var realized = target.GetRealizedContainers()!
+ .OfType()
+ .ToDictionary(x => (string)x.Content!);
+
+ Assert.Equal(40d, realized["bar"].Bounds.X, 6);
+ Assert.Equal(-280d, realized["foo"].Bounds.X, 6);
+ }
+
+ [Fact]
+ public void Changing_ViewportFraction_Does_Not_Change_Selected_Item()
+ {
+ using var app = Start();
+ var items = new[] { "foo", "bar", "baz" };
+ var (target, carousel) = CreateTarget(items, viewportFraction: 0.72, clientSize: new Size(400, 300));
+
+ carousel.WrapSelection = true;
+ carousel.SelectedIndex = 2;
+ Layout(target);
+
+ carousel.ViewportFraction = 1d;
+ Layout(target);
+
+ var visible = target.Children
+ .OfType()
+ .Where(x => x.IsVisible)
+ .ToList();
+
+ Assert.Single(visible);
+ Assert.Equal("baz", visible[0].Content);
+ Assert.Equal(2, carousel.SelectedIndex);
+ }
+
public class Transitions : ScopedTestBase
{
[Fact]
@@ -292,22 +375,89 @@ namespace Avalonia.Controls.UnitTests
Assert.True(cancelationToken!.Value.IsCancellationRequested);
}
+
+ [Fact]
+ public void Completed_Transition_Is_Flushed_Before_Starting_Next_Transition()
+ {
+ using var app = Start();
+ using var sync = UnitTestSynchronizationContext.Begin();
+ var items = new Control[] { new Button(), new Canvas(), new Label() };
+ var transition = new Mock();
+
+ transition.Setup(x => x.Start(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns(Task.CompletedTask);
+
+ var (target, carousel) = CreateTarget(items, transition.Object);
+
+ carousel.SelectedIndex = 1;
+ Layout(target);
+
+ carousel.SelectedIndex = 2;
+ Layout(target);
+
+ transition.Verify(x => x.Start(
+ items[0],
+ items[1],
+ true,
+ It.IsAny()),
+ Times.Once);
+ transition.Verify(x => x.Start(
+ items[1],
+ items[2],
+ true,
+ It.IsAny()),
+ Times.Once);
+
+ sync.ExecutePostedCallbacks();
+ }
+
+ [Fact]
+ public void Interrupted_Transition_Resets_Current_Page_Before_Starting_Next_Transition()
+ {
+ using var app = Start();
+ var items = new Control[] { new Button(), new Canvas(), new Label() };
+ var transition = new DirtyStateTransition();
+ var (target, carousel) = CreateTarget(items, transition);
+
+ carousel.SelectedIndex = 1;
+ Layout(target);
+
+ carousel.SelectedIndex = 2;
+ Layout(target);
+
+ Assert.Equal(2, transition.Starts.Count);
+ Assert.Equal(1d, transition.Starts[1].FromOpacity);
+ Assert.Null(transition.Starts[1].FromTransform);
+ }
}
private static IDisposable Start() => UnitTestApplication.Start(TestServices.MockPlatformRenderInterface);
private static (VirtualizingCarouselPanel, Carousel) CreateTarget(
IEnumerable items,
- IPageTransition? transition = null)
+ IPageTransition? transition = null,
+ double viewportFraction = 1d,
+ Size? clientSize = null)
{
+ var size = clientSize ?? new Size(400, 300);
var carousel = new Carousel
{
ItemsSource = items,
Template = CarouselTemplate(),
PageTransition = transition,
+ ViewportFraction = viewportFraction,
+ Width = size.Width,
+ Height = size.Height,
};
- var root = new TestRoot(carousel);
+ var root = new TestRoot(carousel)
+ {
+ ClientSize = size,
+ };
root.LayoutManager.ExecuteInitialLayoutPass();
return ((VirtualizingCarouselPanel)carousel.Presenter!.Panel!, carousel);
}
@@ -345,5 +495,619 @@ namespace Avalonia.Controls.UnitTests
}
private static void Layout(Control c) => c.GetLayoutManager()?.ExecuteLayoutPass();
+
+ private sealed class DirtyStateTransition : IPageTransition
+ {
+ public List<(double FromOpacity, ITransform? FromTransform)> Starts { get; } = new();
+
+ public Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken)
+ {
+ Starts.Add((from?.Opacity ?? 1d, from?.RenderTransform));
+
+ if (to is not null)
+ {
+ to.Opacity = 0.25;
+ to.RenderTransform = new TranslateTransform { X = 50 };
+ }
+
+ return Task.Delay(Timeout.Infinite, cancellationToken);
+ }
+ }
+
+ public class WrapSelectionTests : ScopedTestBase
+ {
+ [Fact]
+ public void Next_Wraps_To_First_Item_When_WrapSelection_Enabled()
+ {
+ using var app = Start();
+ var items = new[] { "foo", "bar", "baz" };
+ var (target, carousel) = CreateTarget(items);
+
+ carousel.WrapSelection = true;
+ carousel.SelectedIndex = 2; // Last item
+ Layout(target);
+
+ carousel.Next();
+ Layout(target);
+
+ Assert.Equal(0, carousel.SelectedIndex);
+ }
+
+ [Fact]
+ public void Next_Does_Not_Wrap_When_WrapSelection_Disabled()
+ {
+ using var app = Start();
+ var items = new[] { "foo", "bar", "baz" };
+ var (target, carousel) = CreateTarget(items);
+
+ carousel.WrapSelection = false;
+ carousel.SelectedIndex = 2; // Last item
+ Layout(target);
+
+ carousel.Next();
+ Layout(target);
+
+ Assert.Equal(2, carousel.SelectedIndex); // Should stay at last item
+ }
+
+ [Fact]
+ public void Previous_Wraps_To_Last_Item_When_WrapSelection_Enabled()
+ {
+ using var app = Start();
+ var items = new[] { "foo", "bar", "baz" };
+ var (target, carousel) = CreateTarget(items);
+
+ carousel.WrapSelection = true;
+ carousel.SelectedIndex = 0; // First item
+ Layout(target);
+
+ carousel.Previous();
+ Layout(target);
+
+ Assert.Equal(2, carousel.SelectedIndex); // Should wrap to last item
+ }
+
+ [Fact]
+ public void Previous_Does_Not_Wrap_When_WrapSelection_Disabled()
+ {
+ using var app = Start();
+ var items = new[] { "foo", "bar", "baz" };
+ var (target, carousel) = CreateTarget(items);
+
+ carousel.WrapSelection = false;
+ carousel.SelectedIndex = 0; // First item
+ Layout(target);
+
+ carousel.Previous();
+ Layout(target);
+
+ Assert.Equal(0, carousel.SelectedIndex); // Should stay at first item
+ }
+
+ [Fact]
+ public void WrapSelection_Works_With_Two_Items()
+ {
+ using var app = Start();
+ var items = new[] { "foo", "bar" };
+ var (target, carousel) = CreateTarget(items);
+
+ carousel.WrapSelection = true;
+ carousel.SelectedIndex = 1;
+ Layout(target);
+
+ carousel.Next();
+ Layout(target);
+
+ Assert.Equal(0, carousel.SelectedIndex);
+
+ carousel.Previous();
+ Layout(target);
+
+ Assert.Equal(1, carousel.SelectedIndex);
+ }
+
+ [Fact]
+ public void WrapSelection_Does_Not_Apply_To_Single_Item()
+ {
+ using var app = Start();
+ var items = new[] { "foo" };
+ var (target, carousel) = CreateTarget(items);
+
+ carousel.WrapSelection = true;
+ carousel.SelectedIndex = 0;
+ Layout(target);
+
+ carousel.Next();
+ Layout(target);
+
+ Assert.Equal(0, carousel.SelectedIndex);
+
+ carousel.Previous();
+ Layout(target);
+
+ Assert.Equal(0, carousel.SelectedIndex);
+ }
+ }
+
+ public class Gestures : ScopedTestBase
+ {
+ [Fact]
+ public void Swiping_Forward_Realizes_Next_Item()
+ {
+ using var app = Start();
+ var items = new[] { "foo", "bar" };
+ var (panel, carousel) = CreateTarget(items);
+ carousel.IsSwipeEnabled = true;
+
+ var e = new SwipeGestureEventArgs(1, new Vector(10, 0), default);
+ panel.RaiseEvent(e);
+
+ Assert.True(carousel.IsSwiping);
+ Assert.Equal(2, panel.Children.Count);
+ var target = panel.Children[1] as Control;
+ Assert.NotNull(target);
+ Assert.True(target.IsVisible);
+ Assert.Equal("bar", ((target as ContentPresenter)?.Content));
+ }
+
+ [Fact]
+ public void Swiping_Backward_At_Start_RubberBands_When_WrapSelection_False()
+ {
+ using var app = Start();
+ var items = new[] { "foo", "bar" };
+ var (panel, carousel) = CreateTarget(items);
+ carousel.IsSwipeEnabled = true;
+ carousel.WrapSelection = false;
+
+ var e = new SwipeGestureEventArgs(1, new Vector(-10, 0), default);
+ panel.RaiseEvent(e);
+
+ Assert.True(carousel.IsSwiping);
+ Assert.Single(panel.Children);
+ }
+
+ [Fact]
+ public void Swiping_Backward_At_Start_Wraps_When_WrapSelection_True()
+ {
+ using var app = Start();
+ var items = new[] { "foo", "bar", "baz" };
+ var (panel, carousel) = CreateTarget(items);
+ carousel.IsSwipeEnabled = true;
+ carousel.WrapSelection = true;
+
+ var e = new SwipeGestureEventArgs(1, new Vector(-10, 0), default);
+ panel.RaiseEvent(e);
+
+ Assert.True(carousel.IsSwiping);
+ Assert.Equal(2, panel.Children.Count);
+ var target = panel.Children[1] as Control;
+ Assert.Equal("baz", ((target as ContentPresenter)?.Content));
+ }
+
+ [Fact]
+ public void ViewportFraction_Swiping_Backward_At_Start_Wraps_When_WrapSelection_True()
+ {
+ var clock = new MockGlobalClock();
+
+ using var app = UnitTestApplication.Start(
+ TestServices.MockPlatformRenderInterface.With(globalClock: clock));
+ using var sync = UnitTestSynchronizationContext.Begin();
+
+ var items = new[] { "foo", "bar", "baz" };
+ var (panel, carousel) = CreateTarget(items, viewportFraction: 0.8);
+ carousel.IsSwipeEnabled = true;
+ carousel.WrapSelection = true;
+ Layout(panel);
+
+ panel.RaiseEvent(new SwipeGestureEventArgs(1, new Vector(-120, 0), default));
+
+ Assert.True(carousel.IsSwiping);
+ Assert.Contains(panel.Children.OfType(), x => Equals(x.Content, "baz"));
+
+ panel.RaiseEvent(new SwipeGestureEndedEventArgs(1, default));
+
+ clock.Pulse(TimeSpan.Zero);
+ clock.Pulse(TimeSpan.FromSeconds(1));
+ sync.ExecutePostedCallbacks();
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ Assert.Equal(2, carousel.SelectedIndex);
+ }
+
+ [Fact]
+ public void Swiping_Forward_At_End_RubberBands_When_WrapSelection_False()
+ {
+ using var app = Start();
+ var items = new[] { "foo", "bar" };
+ var (panel, carousel) = CreateTarget(items);
+ carousel.IsSwipeEnabled = true;
+ carousel.WrapSelection = false;
+ carousel.SelectedIndex = 1;
+
+ Layout(panel);
+ Layout(panel);
+
+ Assert.Equal(2, ((IReadOnlyList?)carousel.ItemsSource)?.Count);
+ Assert.Equal(1, carousel.SelectedIndex);
+ Assert.False(carousel.WrapSelection, "WrapSelection should be false");
+
+ var container = Assert.IsType(panel.Children[0]);
+ Assert.Equal("bar", container.Content);
+
+ var e = new SwipeGestureEventArgs(1, new Vector(10, 0), default);
+ panel.RaiseEvent(e);
+
+ Assert.True(carousel.IsSwiping);
+ Assert.Single(panel.Children);
+ }
+
+ [Fact]
+ public void Swiping_Locks_To_Dominant_Axis()
+ {
+ using var app = Start();
+ var items = new[] { "foo", "bar" };
+ var (panel, carousel) = CreateTarget(items, new CrossFade(TimeSpan.FromSeconds(1)));
+ carousel.IsSwipeEnabled = true;
+
+ var e = new SwipeGestureEventArgs(1, new Vector(10, 2), default);
+ panel.RaiseEvent(e);
+
+ Assert.True(carousel.IsSwiping);
+ }
+
+ [Fact]
+ public void Swipe_Completion_Does_Not_Update_With_Same_From_And_To()
+ {
+ var clock = new MockGlobalClock();
+
+ using var app = UnitTestApplication.Start(
+ TestServices.MockPlatformRenderInterface.With(globalClock: clock));
+ using var sync = UnitTestSynchronizationContext.Begin();
+
+ var items = new[] { "foo", "bar" };
+ var transition = new TrackingInteractiveTransition();
+ var (panel, carousel) = CreateTarget(items, transition);
+ carousel.IsSwipeEnabled = true;
+
+ panel.RaiseEvent(new SwipeGestureEventArgs(1, new Vector(1000, 0), default));
+ panel.RaiseEvent(new SwipeGestureEndedEventArgs(1, new Vector(1000, 0)));
+
+ clock.Pulse(TimeSpan.Zero);
+ clock.Pulse(TimeSpan.FromSeconds(1));
+ sync.ExecutePostedCallbacks();
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ Assert.True(transition.UpdateCallCount > 0);
+ Assert.False(transition.SawAliasedUpdate);
+ Assert.Equal(1d, transition.LastProgress);
+ Assert.Equal(1, carousel.SelectedIndex);
+ }
+
+ [Fact]
+ public void Swipe_Completion_Keeps_Target_Final_Interactive_Visual_State()
+ {
+ var clock = new MockGlobalClock();
+
+ using var app = UnitTestApplication.Start(
+ TestServices.MockPlatformRenderInterface.With(globalClock: clock));
+ using var sync = UnitTestSynchronizationContext.Begin();
+
+ var items = new[] { "foo", "bar" };
+ var transition = new TransformTrackingInteractiveTransition();
+ var (panel, carousel) = CreateTarget(items, transition);
+ carousel.IsSwipeEnabled = true;
+
+ panel.RaiseEvent(new SwipeGestureEventArgs(1, new Vector(1000, 0), default));
+ panel.RaiseEvent(new SwipeGestureEndedEventArgs(1, new Vector(1000, 0)));
+
+ clock.Pulse(TimeSpan.Zero);
+ clock.Pulse(TimeSpan.FromSeconds(1));
+ sync.ExecutePostedCallbacks();
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ Assert.Equal(1, carousel.SelectedIndex);
+ var realized = Assert.Single(panel.Children.OfType(), x => Equals(x.Content, "bar"));
+ Assert.NotNull(transition.LastTargetTransform);
+ Assert.Same(transition.LastTargetTransform, realized.RenderTransform);
+ }
+
+ [Fact]
+ public void Swipe_Completion_Hides_Outgoing_Page_Before_Resetting_Visual_State()
+ {
+ var clock = new MockGlobalClock();
+
+ using var app = UnitTestApplication.Start(
+ TestServices.MockPlatformRenderInterface.With(globalClock: clock));
+ using var sync = UnitTestSynchronizationContext.Begin();
+
+ var items = new[] { "foo", "bar" };
+ var transition = new OutgoingTransformTrackingInteractiveTransition();
+ var (panel, carousel) = CreateTarget(items, transition);
+ carousel.IsSwipeEnabled = true;
+
+ var outgoing = Assert.Single(panel.Children.OfType(), x => Equals(x.Content, "foo"));
+ bool? hiddenWhenReset = null;
+ outgoing.PropertyChanged += (_, args) =>
+ {
+ if (args.Property == Visual.RenderTransformProperty &&
+ args.GetNewValue() is null)
+ {
+ hiddenWhenReset = !outgoing.IsVisible;
+ }
+ };
+
+ panel.RaiseEvent(new SwipeGestureEventArgs(1, new Vector(1000, 0), default));
+ panel.RaiseEvent(new SwipeGestureEndedEventArgs(1, new Vector(1000, 0)));
+
+ clock.Pulse(TimeSpan.Zero);
+ clock.Pulse(TimeSpan.FromSeconds(1));
+ sync.ExecutePostedCallbacks();
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ Assert.True(hiddenWhenReset);
+ }
+
+ [Fact]
+ public void RubberBand_Swipe_Release_Animates_Back_Through_Intermediate_Progress()
+ {
+ var clock = new MockGlobalClock();
+
+ using var app = UnitTestApplication.Start(
+ TestServices.MockPlatformRenderInterface.With(globalClock: clock));
+ using var sync = UnitTestSynchronizationContext.Begin();
+
+ var items = new[] { "foo", "bar" };
+ var transition = new ProgressTrackingInteractiveTransition();
+ var (panel, carousel) = CreateTarget(items, transition);
+ carousel.IsSwipeEnabled = true;
+ carousel.WrapSelection = false;
+
+ panel.RaiseEvent(new SwipeGestureEventArgs(1, new Vector(-100, 0), default));
+
+ var releaseStartProgress = transition.Progresses[^1];
+ var updatesBeforeRelease = transition.Progresses.Count;
+
+ panel.RaiseEvent(new SwipeGestureEndedEventArgs(1, default));
+
+ clock.Pulse(TimeSpan.Zero);
+ clock.Pulse(TimeSpan.FromSeconds(0.1));
+ sync.ExecutePostedCallbacks();
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ var postReleaseProgresses = transition.Progresses.Skip(updatesBeforeRelease).ToArray();
+
+ Assert.Contains(postReleaseProgresses, p => p > 0 && p < releaseStartProgress);
+
+ clock.Pulse(TimeSpan.FromSeconds(1));
+ sync.ExecutePostedCallbacks();
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ Assert.Equal(0d, transition.Progresses[^1]);
+ Assert.Equal(0, carousel.SelectedIndex);
+ }
+
+ [Fact]
+ public void ViewportFraction_SelectedIndex_Change_Drives_Progress_Updates()
+ {
+ var clock = new MockGlobalClock();
+
+ using var app = UnitTestApplication.Start(
+ TestServices.MockPlatformRenderInterface.With(globalClock: clock));
+ using var sync = UnitTestSynchronizationContext.Begin();
+
+ var items = new[] { "foo", "bar", "baz" };
+ var transition = new ProgressTrackingInteractiveTransition();
+ var (panel, carousel) = CreateTarget(items, transition, viewportFraction: 0.8);
+
+ carousel.SelectedIndex = 1;
+
+ clock.Pulse(TimeSpan.Zero);
+ clock.Pulse(TimeSpan.FromSeconds(0.1));
+ clock.Pulse(TimeSpan.FromSeconds(1));
+ sync.ExecutePostedCallbacks();
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ Assert.NotEmpty(transition.Progresses);
+ Assert.Contains(transition.Progresses, p => p > 0 && p < 1);
+ Assert.Equal(1d, transition.Progresses[^1]);
+ Assert.Equal(1, carousel.SelectedIndex);
+ }
+
+ private sealed class TrackingInteractiveTransition : IProgressPageTransition
+ {
+ public int UpdateCallCount { get; private set; }
+ public bool SawAliasedUpdate { get; private set; }
+ public double LastProgress { get; private set; }
+
+ public Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken)
+ => Task.CompletedTask;
+
+ public void Update(
+ double progress,
+ Visual? from,
+ Visual? to,
+ bool forward,
+ double pageLength,
+ IReadOnlyList visibleItems)
+ {
+ UpdateCallCount++;
+ LastProgress = progress;
+
+ if (from is not null && ReferenceEquals(from, to))
+ SawAliasedUpdate = true;
+ }
+
+ public void Reset(Visual visual)
+ {
+ visual.RenderTransform = null;
+ visual.Opacity = 1;
+ visual.ZIndex = 0;
+ visual.Clip = null;
+ }
+ }
+
+ private sealed class ProgressTrackingInteractiveTransition : IProgressPageTransition
+ {
+ public List Progresses { get; } = new();
+
+ public Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken)
+ => Task.CompletedTask;
+
+ public void Update(
+ double progress,
+ Visual? from,
+ Visual? to,
+ bool forward,
+ double pageLength,
+ IReadOnlyList visibleItems)
+ {
+ Progresses.Add(progress);
+ }
+
+ public void Reset(Visual visual)
+ {
+ visual.RenderTransform = null;
+ visual.Opacity = 1;
+ visual.ZIndex = 0;
+ visual.Clip = null;
+ }
+ }
+
+ private sealed class TransformTrackingInteractiveTransition : IProgressPageTransition
+ {
+ public TransformGroup? LastTargetTransform { get; private set; }
+
+ public Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken)
+ => Task.CompletedTask;
+
+ public void Update(
+ double progress,
+ Visual? from,
+ Visual? to,
+ bool forward,
+ double pageLength,
+ IReadOnlyList visibleItems)
+ {
+ if (to is not Control target)
+ return;
+
+ if (target.RenderTransform is not TransformGroup group)
+ {
+ group = new TransformGroup
+ {
+ Children =
+ {
+ new ScaleTransform(),
+ new TranslateTransform()
+ }
+ };
+ target.RenderTransform = group;
+ }
+
+ var scale = Assert.IsType(group.Children[0]);
+ var translate = Assert.IsType(group.Children[1]);
+ scale.ScaleX = scale.ScaleY = 0.9 + (0.1 * progress);
+ translate.X = 100 * (1 - progress);
+ LastTargetTransform = group;
+ }
+
+ public void Reset(Visual visual)
+ {
+ visual.RenderTransform = null;
+ }
+ }
+
+ private sealed class OutgoingTransformTrackingInteractiveTransition : IProgressPageTransition
+ {
+ public Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken)
+ => Task.CompletedTask;
+
+ public void Update(
+ double progress,
+ Visual? from,
+ Visual? to,
+ bool forward,
+ double pageLength,
+ IReadOnlyList visibleItems)
+ {
+ if (from is Control source)
+ source.RenderTransform = new TranslateTransform(100 * progress, 0);
+
+ if (to is Control target)
+ target.RenderTransform = new TranslateTransform(100 * (1 - progress), 0);
+ }
+
+ public void Reset(Visual visual)
+ {
+ visual.RenderTransform = null;
+ }
+ }
+
+ [Fact]
+ public void Vertical_Swipe_Forward_Realizes_Next_Item()
+ {
+ using var app = Start();
+ var items = new[] { "foo", "bar" };
+ var transition = new PageSlide(TimeSpan.FromSeconds(1), PageSlide.SlideAxis.Vertical);
+ var (panel, carousel) = CreateTarget(items, transition);
+ carousel.IsSwipeEnabled = true;
+
+ var e = new SwipeGestureEventArgs(1, new Vector(0, 10), default);
+ panel.RaiseEvent(e);
+
+ Assert.True(carousel.IsSwiping);
+ Assert.Equal(2, panel.Children.Count);
+ var target = panel.Children[1] as ContentPresenter;
+ Assert.NotNull(target);
+ Assert.Equal("bar", target.Content);
+ }
+
+ [Fact]
+ public void New_Swipe_Interrupts_Active_Completion_Animation()
+ {
+ var clock = new MockGlobalClock();
+
+ using var app = UnitTestApplication.Start(
+ TestServices.MockPlatformRenderInterface.With(globalClock: clock));
+ using var sync = UnitTestSynchronizationContext.Begin();
+
+ var items = new[] { "foo", "bar", "baz" };
+ var transition = new TrackingInteractiveTransition();
+ var (panel, carousel) = CreateTarget(items, transition);
+ carousel.IsSwipeEnabled = true;
+
+ panel.RaiseEvent(new SwipeGestureEventArgs(1, new Vector(1000, 0), default));
+ panel.RaiseEvent(new SwipeGestureEndedEventArgs(1, new Vector(1000, 0)));
+
+ clock.Pulse(TimeSpan.Zero);
+ clock.Pulse(TimeSpan.FromMilliseconds(50));
+ sync.ExecutePostedCallbacks();
+
+ Assert.Equal(0, carousel.SelectedIndex);
+
+ panel.RaiseEvent(new SwipeGestureEventArgs(2, new Vector(10, 0), default));
+
+ Assert.True(carousel.IsSwiping);
+ Assert.Equal(1, carousel.SelectedIndex);
+ }
+
+ [Fact]
+ public void Swipe_With_NonInteractive_Transition_Does_Not_Crash()
+ {
+ using var app = Start();
+ var items = new[] { "foo", "bar" };
+ var transition = new Mock();
+ transition.Setup(x => x.Start(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(Task.CompletedTask);
+ var (panel, carousel) = CreateTarget(items, transition.Object);
+ carousel.IsSwipeEnabled = true;
+
+ var e = new SwipeGestureEventArgs(1, new Vector(10, 0), default);
+ panel.RaiseEvent(e);
+
+ Assert.True(carousel.IsSwiping);
+ Assert.Equal(2, panel.Children.Count);
+ }
+ }
}
}
diff --git a/tests/Avalonia.Controls.UnitTests/WindowTests.cs b/tests/Avalonia.Controls.UnitTests/WindowTests.cs
index 7ab69c8d86..59a84462ef 100644
--- a/tests/Avalonia.Controls.UnitTests/WindowTests.cs
+++ b/tests/Avalonia.Controls.UnitTests/WindowTests.cs
@@ -1188,6 +1188,26 @@ namespace Avalonia.Controls.UnitTests
}
}
+ [Fact]
+ public void Show_Should_Apply_Default_Icon_When_No_Custom_Icon_Is_Set()
+ {
+ var windowImpl = MockWindowingPlatform.CreateWindowMock();
+ var windowingPlatform = new MockWindowingPlatform(() => windowImpl.Object);
+
+ using (UnitTestApplication.Start(TestServices.StyledWindow.With(windowingPlatform: windowingPlatform)))
+ {
+ var target = new Window();
+
+ // Clear any SetIcon calls from construction.
+ windowImpl.Invocations.Clear();
+
+ target.Show();
+
+ // ShowCore should apply the default icon when no custom icon was set.
+ windowImpl.Verify(x => x.SetIcon(It.IsAny()), Times.AtLeastOnce());
+ }
+ }
+
private class TopmostWindow : Window
{
static TopmostWindow()
diff --git a/tests/Avalonia.Headless.UnitTests/RenderingTests.cs b/tests/Avalonia.Headless.UnitTests/RenderingTests.cs
index 1541b74fd9..24db2d2285 100644
--- a/tests/Avalonia.Headless.UnitTests/RenderingTests.cs
+++ b/tests/Avalonia.Headless.UnitTests/RenderingTests.cs
@@ -169,4 +169,66 @@ public class RenderingTests
AssertHelper.Equal(100, snapshot.Size.Width);
AssertHelper.Equal(100, snapshot.Size.Height);
}
+
+#if NUNIT
+ [AvaloniaTest]
+#elif XUNIT
+ [AvaloniaFact]
+#endif
+ public void Should_Change_Render_Scaling()
+ {
+ var window = new Window
+ {
+ Content = new Border
+ {
+ Background = Brushes.Red
+ },
+ Width = 100,
+ Height = 100,
+ };
+
+ window.Show();
+
+ var frameBefore = window.CaptureRenderedFrame();
+ AssertHelper.NotNull(frameBefore);
+
+ var sizeBefore = frameBefore!.PixelSize;
+
+ window.SetRenderScaling(2.0);
+
+ AssertHelper.Equal(2.0, window.RenderScaling);
+
+ var frameAfter = window.CaptureRenderedFrame();
+ AssertHelper.NotNull(frameAfter);
+
+ var sizeAfter = frameAfter!.PixelSize;
+
+ AssertHelper.Equal(sizeBefore.Width * 2, sizeAfter.Width);
+ AssertHelper.Equal(sizeBefore.Height * 2, sizeAfter.Height);
+ }
+
+#if NUNIT
+ [AvaloniaTest]
+#elif XUNIT
+ [AvaloniaFact]
+#endif
+ public void Should_Keep_Client_Size_After_Scaling_Change()
+ {
+ var window = new Window
+ {
+ Width = 200,
+ Height = 150
+ };
+
+ window.Show();
+ window.CaptureRenderedFrame();
+
+ var clientSizeBefore = window.ClientSize;
+
+ window.SetRenderScaling(2.0);
+ window.CaptureRenderedFrame();
+
+ AssertHelper.Equal(clientSizeBefore.Width, window.ClientSize.Width);
+ AssertHelper.Equal(clientSizeBefore.Height, window.ClientSize.Height);
+ }
}
diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/StyleTests.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/StyleTests.cs
index 54f80984ff..95d6cbca94 100644
--- a/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/StyleTests.cs
+++ b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/StyleTests.cs
@@ -788,5 +788,25 @@ namespace Avalonia.Markup.Xaml.UnitTests.Xaml
Assert.Equal("Cannot add a Style without selector to a ControlTheme. Line 5, position 14.", exception.Message);
}
+
+ [Fact]
+ public void Selector_Should_Not_Resolve_To_MarkupExtension_Type()
+ {
+ using var _ = UnitTestApplication.Start(TestServices.StyledWindow);
+
+ var style = (Style)AvaloniaRuntimeXamlLoader.Load(
+ $"""
+
+ """);
+
+ Assert.NotNull(style.Selector);
+
+ var targetType = style.Selector.TargetType;
+
+ Assert.NotEqual(typeof(TestSelectorControlExtension), targetType);
+ }
}
}
diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/TestSelectorControl.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/TestSelectorControl.cs
new file mode 100644
index 0000000000..a1cb89c002
--- /dev/null
+++ b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/TestSelectorControl.cs
@@ -0,0 +1,5 @@
+namespace Avalonia.Markup.Xaml.UnitTests.Xaml;
+
+public class TestSelectorControl;
+
+public class TestSelectorControlExtension;
diff --git a/tests/Avalonia.RenderTests/Controls/CarouselTests.cs b/tests/Avalonia.RenderTests/Controls/CarouselTests.cs
new file mode 100644
index 0000000000..6e5c42d093
--- /dev/null
+++ b/tests/Avalonia.RenderTests/Controls/CarouselTests.cs
@@ -0,0 +1,127 @@
+using System.Threading.Tasks;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform;
+using Avalonia.Styling;
+using Avalonia.Themes.Simple;
+using Avalonia.UnitTests;
+using Xunit;
+
+#if AVALONIA_SKIA
+namespace Avalonia.Skia.RenderTests
+#else
+namespace Avalonia.Direct2D1.RenderTests.Controls
+#endif
+{
+ public class CarouselRenderTests : TestBase
+ {
+ public CarouselRenderTests()
+ : base(@"Controls\Carousel")
+ {
+ }
+
+ private static Style FontStyle => new Style(x => x.OfType())
+ {
+ Setters = { new Setter(TextBlock.FontFamilyProperty, TestFontFamily) }
+ };
+
+ [Fact]
+ public async Task Carousel_ViewportFraction_MiddleItemSelected_ShowsSidePeeks()
+ {
+ var carousel = new Carousel
+ {
+ Background = Brushes.Transparent,
+ ViewportFraction = 0.8,
+ SelectedIndex = 1,
+ HorizontalAlignment = HorizontalAlignment.Stretch,
+ VerticalAlignment = VerticalAlignment.Stretch,
+ ItemsSource = new Control[]
+ {
+ CreateCard("One", "#D8574B", "#F7C5BE"),
+ CreateCard("Two", "#3E7AD9", "#BCD0F7"),
+ CreateCard("Three", "#3D9B67", "#BEE4CB"),
+ }
+ };
+
+ var target = new Border
+ {
+ Width = 520,
+ Height = 340,
+ Background = Brushes.White,
+ Padding = new Thickness(20),
+ Child = carousel
+ };
+
+ AvaloniaLocator.CurrentMutable.Bind().ToConstant(new CursorFactoryStub());
+ target.Styles.Add(new SimpleTheme());
+ target.Styles.Add(FontStyle);
+ await RenderToFile(target);
+ CompareImages(skipImmediate: true);
+ }
+
+ private static Control CreateCard(string label, string background, string accent)
+ {
+ return new Border
+ {
+ Margin = new Thickness(14, 12),
+ CornerRadius = new CornerRadius(18),
+ ClipToBounds = true,
+ Background = Brush.Parse(background),
+ BorderBrush = Brushes.White,
+ BorderThickness = new Thickness(2),
+ Child = new Grid
+ {
+ Children =
+ {
+ new Border
+ {
+ Height = 56,
+ Background = Brush.Parse(accent),
+ VerticalAlignment = VerticalAlignment.Top
+ },
+ new Border
+ {
+ Width = 88,
+ Height = 88,
+ CornerRadius = new CornerRadius(44),
+ Background = Brushes.White,
+ Opacity = 0.9,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center
+ },
+ new Border
+ {
+ Background = new SolidColorBrush(Color.Parse("#80000000")),
+ VerticalAlignment = VerticalAlignment.Bottom,
+ Padding = new Thickness(12),
+ Child = new TextBlock
+ {
+ Text = label,
+ Foreground = Brushes.White,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ FontWeight = FontWeight.SemiBold
+ }
+ }
+ }
+ }
+ };
+ }
+
+ private sealed class CursorFactoryStub : ICursorFactory
+ {
+ public ICursorImpl GetCursor(StandardCursorType cursorType) => new CursorStub();
+
+ public ICursorImpl CreateCursor(Bitmap cursor, PixelPoint hotSpot) => new CursorStub();
+
+ private sealed class CursorStub : ICursorImpl
+ {
+ public void Dispose()
+ {
+ }
+ }
+ }
+ }
+}
diff --git a/tests/Avalonia.UnitTests/TestRoot.cs b/tests/Avalonia.UnitTests/TestRoot.cs
index ed91463346..4400d77267 100644
--- a/tests/Avalonia.UnitTests/TestRoot.cs
+++ b/tests/Avalonia.UnitTests/TestRoot.cs
@@ -74,7 +74,7 @@ namespace Avalonia.UnitTests
IRenderer IPresentationSource.Renderer => Renderer;
IHitTester IPresentationSource.HitTester => HitTester;
- public IFocusManager FocusManager => _focusManager ??= new FocusManager(this);
+ public IFocusManager FocusManager => _focusManager ??= new FocusManager { ContentRoot = this };
public IPlatformSettings? PlatformSettings => AvaloniaLocator.Current.GetService();
public IInputElement? PointerOverElement { get; set; }
diff --git a/tests/TestFiles/Skia/Controls/Carousel/Carousel_ViewportFraction_MiddleItemSelected_ShowsSidePeeks.expected.png b/tests/TestFiles/Skia/Controls/Carousel/Carousel_ViewportFraction_MiddleItemSelected_ShowsSidePeeks.expected.png
new file mode 100644
index 0000000000..68fe675925
Binary files /dev/null and b/tests/TestFiles/Skia/Controls/Carousel/Carousel_ViewportFraction_MiddleItemSelected_ShowsSidePeeks.expected.png differ