diff --git a/api/Avalonia.nupkg.xml b/api/Avalonia.nupkg.xml
index 44617ccf64..b2a81dd55d 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
@@ -1753,6 +1849,12 @@
baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
+ CP0002
+ F:Avalonia.Controls.DrawerPage.DrawerBreakpointWidthProperty
+ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
CP0002
F:Avalonia.Controls.NativeMenuBar.EnableMenuItemClickForwardingProperty
@@ -1765,6 +1867,12 @@
baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
+ CP0002
+ F:Avalonia.Controls.Primitives.FlyoutBase.IsOpenProperty
+ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
CP0002
F:Avalonia.Controls.Primitives.Popup.PlacementModeProperty
@@ -1795,6 +1903,12 @@
baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
+ CP0002
+ F:Avalonia.Controls.TabItem.IconProperty
+ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
CP0002
F:Avalonia.Controls.TextBlock.LetterSpacingProperty
@@ -1933,6 +2047,18 @@
baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
+ CP0002
+ M:Avalonia.Controls.DrawerPage.get_DrawerBreakpointWidth
+ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
+
+ CP0002
+ M:Avalonia.Controls.DrawerPage.set_DrawerBreakpointWidth(System.Double)
+ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
CP0002
M:Avalonia.Controls.Embedding.Offscreen.OffscreenTopLevelImplBase.get_Surfaces
@@ -1969,6 +2095,12 @@
baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
+ CP0002
+ M:Avalonia.Controls.PageSelectionChangedEventArgs.#ctor(Avalonia.Controls.Page,Avalonia.Controls.Page)
+ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
CP0002
M:Avalonia.Controls.Platform.DefaultMenuInteractionHandler.GotFocus(System.Object,Avalonia.Input.GotFocusEventArgs)
@@ -2077,12 +2209,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 +2305,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,12 +2329,30 @@
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)
baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
+ CP0002
+ M:Avalonia.Controls.TabbedPage.FindNextEnabledTab(System.Int32,System.Int32)
+ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
+
+ CP0002
+ M:Avalonia.Controls.TabItem.get_Icon
+ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
CP0002
M:Avalonia.Controls.TabItem.SubscribeToOwnerProperties(Avalonia.AvaloniaObject)
@@ -2473,6 +2653,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 +2791,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 +3109,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
@@ -3247,6 +3511,12 @@
baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
+ CP0002
+ F:Avalonia.Controls.DrawerPage.DrawerBreakpointWidthProperty
+ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
CP0002
F:Avalonia.Controls.NativeMenuBar.EnableMenuItemClickForwardingProperty
@@ -3259,6 +3529,12 @@
baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
+ CP0002
+ F:Avalonia.Controls.Primitives.FlyoutBase.IsOpenProperty
+ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
CP0002
F:Avalonia.Controls.Primitives.Popup.PlacementModeProperty
@@ -3289,6 +3565,12 @@
baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
+ CP0002
+ F:Avalonia.Controls.TabItem.IconProperty
+ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
CP0002
F:Avalonia.Controls.TextBlock.LetterSpacingProperty
@@ -3427,6 +3709,18 @@
baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
+ CP0002
+ M:Avalonia.Controls.DrawerPage.get_DrawerBreakpointWidth
+ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
+
+ CP0002
+ M:Avalonia.Controls.DrawerPage.set_DrawerBreakpointWidth(System.Double)
+ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
CP0002
M:Avalonia.Controls.Embedding.Offscreen.OffscreenTopLevelImplBase.get_Surfaces
@@ -3463,6 +3757,12 @@
baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
+ CP0002
+ M:Avalonia.Controls.PageSelectionChangedEventArgs.#ctor(Avalonia.Controls.Page,Avalonia.Controls.Page)
+ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
CP0002
M:Avalonia.Controls.Platform.DefaultMenuInteractionHandler.GotFocus(System.Object,Avalonia.Input.GotFocusEventArgs)
@@ -3571,12 +3871,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 +3967,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,12 +3991,30 @@
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)
baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
+ CP0002
+ M:Avalonia.Controls.TabbedPage.FindNextEnabledTab(System.Int32,System.Int32)
+ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
+
+ CP0002
+ M:Avalonia.Controls.TabItem.get_Icon
+ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
CP0002
M:Avalonia.Controls.TabItem.SubscribeToOwnerProperties(Avalonia.AvaloniaObject)
@@ -4015,6 +4363,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 +4681,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/native/Avalonia.Native/src/OSX/WindowImpl.mm b/native/Avalonia.Native/src/OSX/WindowImpl.mm
index 1d77d3ce44..f42b7c27cb 100644
--- a/native/Avalonia.Native/src/OSX/WindowImpl.mm
+++ b/native/Avalonia.Native/src/OSX/WindowImpl.mm
@@ -563,6 +563,10 @@ NSWindowStyleMask WindowImpl::CalculateStyleMask() {
case SystemDecorationsBorderOnly:
s = s | NSWindowStyleMaskTitled | NSWindowStyleMaskFullSizeContentView;
+
+ if (_canResize && _isEnabled) {
+ s = s | NSWindowStyleMaskResizable;
+ }
break;
case SystemDecorationsFull:
diff --git a/samples/ControlCatalog/Assets/Sanctuary/city_bg.jpg b/samples/ControlCatalog/Assets/Sanctuary/city_bg.jpg
new file mode 100644
index 0000000000..9a8a4b374f
Binary files /dev/null and b/samples/ControlCatalog/Assets/Sanctuary/city_bg.jpg differ
diff --git a/samples/ControlCatalog/Assets/Sanctuary/forest_bg.jpg b/samples/ControlCatalog/Assets/Sanctuary/forest_bg.jpg
new file mode 100644
index 0000000000..32a360431d
Binary files /dev/null and b/samples/ControlCatalog/Assets/Sanctuary/forest_bg.jpg differ
diff --git a/samples/ControlCatalog/Assets/Sanctuary/main_arctic_silence.jpg b/samples/ControlCatalog/Assets/Sanctuary/main_arctic_silence.jpg
new file mode 100644
index 0000000000..61e7b06212
Binary files /dev/null and b/samples/ControlCatalog/Assets/Sanctuary/main_arctic_silence.jpg differ
diff --git a/samples/ControlCatalog/Assets/Sanctuary/main_deep_forest.jpg b/samples/ControlCatalog/Assets/Sanctuary/main_deep_forest.jpg
new file mode 100644
index 0000000000..d8576b626f
Binary files /dev/null and b/samples/ControlCatalog/Assets/Sanctuary/main_deep_forest.jpg differ
diff --git a/samples/ControlCatalog/Assets/Sanctuary/main_desert_sands.jpg b/samples/ControlCatalog/Assets/Sanctuary/main_desert_sands.jpg
new file mode 100644
index 0000000000..e4b44477e3
Binary files /dev/null and b/samples/ControlCatalog/Assets/Sanctuary/main_desert_sands.jpg differ
diff --git a/samples/ControlCatalog/Assets/Sanctuary/main_hero.jpg b/samples/ControlCatalog/Assets/Sanctuary/main_hero.jpg
new file mode 100644
index 0000000000..63dff3f948
Binary files /dev/null and b/samples/ControlCatalog/Assets/Sanctuary/main_hero.jpg differ
diff --git a/samples/ControlCatalog/Assets/Sanctuary/mountain_bg.jpg b/samples/ControlCatalog/Assets/Sanctuary/mountain_bg.jpg
new file mode 100644
index 0000000000..e9fdd5c0d9
Binary files /dev/null and b/samples/ControlCatalog/Assets/Sanctuary/mountain_bg.jpg differ
diff --git a/samples/ControlCatalog/ControlCatalog.csproj b/samples/ControlCatalog/ControlCatalog.csproj
index 8304e3e002..2ce24a4d20 100644
--- a/samples/ControlCatalog/ControlCatalog.csproj
+++ b/samples/ControlCatalog/ControlCatalog.csproj
@@ -11,14 +11,7 @@
Designer
-
-
-
-
-
-
-
-
+
diff --git a/samples/ControlCatalog/MainView.xaml b/samples/ControlCatalog/MainView.xaml
index b6249fe17f..adea1b90fc 100644
--- a/samples/ControlCatalog/MainView.xaml
+++ b/samples/ControlCatalog/MainView.xaml
@@ -54,8 +54,15 @@
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..36c9961658
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselDemoPage.xaml.cs
@@ -0,0 +1,91 @@
+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", "First Look",
+ "Basic CarouselPage with three pages and page indicator.",
+ () => new CarouselPageFirstLookPage()),
+
+ // Populate
+ ("Populate", "Data Templates",
+ "Bind CarouselPage to an ObservableCollection, add or remove pages at runtime, and switch the page template.",
+ () => new CarouselPageDataTemplatePage()),
+
+ // Appearance
+ ("Appearance", "Customization",
+ "Switch slide direction between horizontal and vertical with PageSlide. Page indicator dots update on each selection.",
+ () => new CarouselPageCustomizationPage()),
+
+ // Features
+ ("Features", "Page Transitions",
+ "Animate page switches with CrossFade or PageSlide.",
+ () => new CarouselPageTransitionsPage()),
+ ("Features", "Programmatic Selection",
+ "Jump to any page programmatically with SelectedIndex and respond to SelectionChanged events.",
+ () => new CarouselPageSelectionPage()),
+ ("Features", "Gesture & Keyboard",
+ "Swipe left/right to navigate pages. Toggle IsGestureEnabled and IsKeyboardNavigationEnabled.",
+ () => new CarouselPageGesturePage()),
+ ("Features", "Events",
+ "SelectionChanged, NavigatedTo, and NavigatedFrom events. Swipe or navigate to see the live event log.",
+ () => new CarouselPageEventsPage()),
+
+ // Performance
+ ("Performance", "Performance Monitor",
+ "Track page count, live page instances, and managed heap size. Observe how GC reclaims memory after removing pages.",
+ () => new CarouselPagePerformancePage()),
+
+ // Showcases
+ ("Showcases", "Sanctuary",
+ "Travel discovery app with 3 full-screen immersive pages. Each page has a real background photo, gradient overlay, and themed content. Built as a 1:1 replica of a Stitch design.",
+ () => new SanctuaryShowcasePage()),
+ ("Showcases", "Care Companion",
+ "Healthcare onboarding with CarouselPage (3 pages), then a TabbedPage patient dashboard. Skip or complete onboarding to navigate to the dashboard via RemovePage.",
+ () => new CareCompanionAppPage()),
+
+ // Carousel (ItemsControl) demos
+ ("Carousel", "Getting Started",
+ "Basic Carousel with image items and previous/next navigation buttons.",
+ () => new CarouselGettingStartedPage()),
+ ("Carousel", "Transitions",
+ "Configure page transitions: PageSlide, CrossFade, 3D Rotation, or None.",
+ () => new CarouselTransitionsPage()),
+ ("Carousel", "Customization",
+ "Adjust orientation and transition type to tailor the carousel layout.",
+ () => new CarouselCustomizationPage()),
+ ("Carousel", "Gestures & Keyboard",
+ "Navigate items via swipe gesture and arrow keys. Toggle each input mode on and off.",
+ () => new CarouselGesturesPage()),
+ ("Carousel", "Vertical Orientation",
+ "Carousel with Orientation set to Vertical, navigated with Up/Down keys, swipe, or buttons.",
+ () => new CarouselVerticalPage()),
+ ("Carousel", "Multi-Item Peek",
+ "Adjust ViewportFraction to show multiple items simultaneously with adjacent cards peeking.",
+ () => new CarouselMultiItemPage()),
+ ("Carousel", "Data Binding",
+ "Bind Carousel to an ObservableCollection and add, remove, or shuffle items at runtime.",
+ () => new CarouselDataBindingPage()),
+ ("Carousel", "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/CareCompanionAppPage.xaml b/samples/ControlCatalog/Pages/CarouselPage/CareCompanionAppPage.xaml
new file mode 100644
index 0000000000..a11a1ea6b8
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselPage/CareCompanionAppPage.xaml
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/CarouselPage/CareCompanionAppPage.xaml.cs b/samples/ControlCatalog/Pages/CarouselPage/CareCompanionAppPage.xaml.cs
new file mode 100644
index 0000000000..f7c87f56a3
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselPage/CareCompanionAppPage.xaml.cs
@@ -0,0 +1,1068 @@
+using System;
+using System.Collections.ObjectModel;
+using Avalonia;
+using Avalonia.Animation;
+using Avalonia.Controls;
+using Avalonia.Controls.Presenters;
+using Avalonia.Controls.Primitives;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Styling;
+using AvaCarouselPage = Avalonia.Controls.CarouselPage;
+
+namespace ControlCatalog.Pages;
+
+public partial class CareCompanionAppPage : UserControl
+{
+ static readonly Color Primary = Color.Parse("#137fec");
+ static readonly Color PrimaryDark = Color.Parse("#0a5bb5");
+ static readonly Color PrimaryLight = Color.Parse("#e0f0ff");
+ static readonly Color BgLight = Color.Parse("#f6f7f8");
+ static readonly Color TextDark = Color.Parse("#111827");
+ static readonly Color TextMuted = Color.Parse("#64748b");
+ static readonly Color CardBg = Colors.White;
+ static readonly Color SuccessGreen = Color.Parse("#10b981");
+ static readonly Color WarningAmber = Color.Parse("#f59e0b");
+
+ NavigationPage? _navPage;
+ AvaCarouselPage? _onboarding;
+ ScrollViewer? _infoPanel;
+
+ public CareCompanionAppPage()
+ {
+ InitializeComponent();
+ }
+
+ protected override void OnLoaded(RoutedEventArgs e)
+ {
+ base.OnLoaded(e);
+ _infoPanel = this.FindControl("InfoPanel");
+ UpdateInfoVisibility();
+
+ _navPage = this.FindControl("NavPage");
+ if (_navPage == null) return;
+
+ _onboarding = BuildOnboardingCarousel();
+ _ = _navPage.PushAsync(_onboarding);
+ }
+
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+ if (change.Property == BoundsProperty)
+ UpdateInfoVisibility();
+ }
+
+ void UpdateInfoVisibility()
+ {
+ if (_infoPanel != null)
+ _infoPanel.IsVisible = Bounds.Width >= 650;
+ }
+
+ static TextBlock Txt(string text, double size, FontWeight weight, Color color,
+ double opacity = 1, TextAlignment align = TextAlignment.Left,
+ TextWrapping wrap = TextWrapping.NoWrap)
+ => new TextBlock
+ {
+ Text = text,
+ FontSize = size,
+ FontWeight = weight,
+ Foreground = new SolidColorBrush(color),
+ Opacity = opacity,
+ TextAlignment = align,
+ TextWrapping = wrap,
+ };
+
+ static Button StyledButton(object content, IBrush bg, IBrush fg, double height,
+ CornerRadius radius, Thickness margin = default, double fontSize = 14,
+ FontWeight fontWeight = FontWeight.SemiBold,
+ IBrush? border = null, Thickness borderThick = default)
+ {
+ var btn = new Button
+ {
+ Content = content,
+ Background = bg,
+ Foreground = fg,
+ Height = height,
+ CornerRadius = radius,
+ Margin = margin,
+ Padding = new Thickness(16, 0),
+ HorizontalContentAlignment = HorizontalAlignment.Center,
+ VerticalContentAlignment = VerticalAlignment.Center,
+ BorderBrush = border,
+ BorderThickness = borderThick,
+ };
+
+ var over = new Style(x => x.OfType().Class(":pointerover").Descendant().OfType());
+ over.Setters.Add(new Setter(ContentPresenter.BackgroundProperty, bg));
+ over.Setters.Add(new Setter(ContentPresenter.ForegroundProperty, fg));
+ btn.Styles.Add(over);
+
+ var press = new Style(x => x.OfType().Class(":pressed").Descendant().OfType());
+ press.Setters.Add(new Setter(ContentPresenter.BackgroundProperty, bg));
+ press.Setters.Add(new Setter(ContentPresenter.ForegroundProperty, fg));
+ btn.Styles.Add(press);
+
+ return btn;
+ }
+
+ static PathIcon SvgIcon(string data, double size, Color color)
+ => new PathIcon
+ {
+ Data = Geometry.Parse(data),
+ Width = size,
+ Height = size,
+ Foreground = new SolidColorBrush(color),
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+
+ static PipsPager MakePipsPager(int count, AvaCarouselPage carousel)
+ {
+ var pager = new PipsPager
+ {
+ NumberOfPages = count,
+ IsPreviousButtonVisible = false,
+ IsNextButtonVisible = false,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ };
+
+ pager.Bind(PipsPager.SelectedPageIndexProperty,
+ new Avalonia.Data.Binding("SelectedIndex") { Source = carousel, Mode = Avalonia.Data.BindingMode.TwoWay });
+
+ return pager;
+ }
+
+ static Border ShadowWrap(Control ctrl, Color shadowColor)
+ => new Border
+ {
+ CornerRadius = new CornerRadius(999),
+ BoxShadow = new BoxShadows(new BoxShadow
+ {
+ OffsetX = 0,
+ OffsetY = 8,
+ Blur = 24,
+ Color = Color.FromArgb(55, shadowColor.R, shadowColor.G, shadowColor.B),
+ }),
+ Child = ctrl,
+ };
+
+ AvaCarouselPage BuildOnboardingCarousel()
+ {
+ var carousel = new AvaCarouselPage
+ {
+ Background = new SolidColorBrush(BgLight),
+ PageTransition = new CrossFade(TimeSpan.FromMilliseconds(300)),
+ };
+ NavigationPage.SetHasNavigationBar(carousel, false);
+
+ var pips1 = MakePipsPager(3, carousel);
+ var pips2 = MakePipsPager(3, carousel);
+ var pips3 = MakePipsPager(3, carousel);
+
+ var p1 = BuildWelcomePage(carousel, pips1);
+ var p2 = BuildTrackPage(carousel, pips2);
+ var p3 = BuildResourcesPage(carousel, pips3);
+
+ carousel.Pages = new ObservableCollection { p1, p2, p3 };
+
+ return carousel;
+ }
+
+ ContentPage BuildWelcomePage(AvaCarouselPage carousel, PipsPager dots)
+ {
+ var page = new ContentPage { Background = new SolidColorBrush(CardBg) };
+
+ var skipBtn = StyledButton("Skip", Brushes.Transparent, new SolidColorBrush(TextMuted),
+ 32, new CornerRadius(999));
+ skipBtn.HorizontalAlignment = HorizontalAlignment.Right;
+ skipBtn.Margin = new Thickness(0, 4, 8, 0);
+ skipBtn.Click += (_, _) => CompleteOnboarding();
+
+ var illGrad1 = new LinearGradientBrush
+ {
+ StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
+ EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
+ };
+ illGrad1.GradientStops.Add(new GradientStop(Color.Parse("#dbeafe"), 0));
+ illGrad1.GradientStops.Add(new GradientStop(Color.Parse("#93c5fd"), 0.5));
+ illGrad1.GradientStops.Add(new GradientStop(Color.Parse("#3b82f6"), 1));
+
+ var illPanel1 = new Panel { Background = illGrad1 };
+
+ illPanel1.Children.Add(new Border
+ {
+ Width = 160,
+ Height = 160,
+ CornerRadius = new CornerRadius(80),
+ Background = new SolidColorBrush(Color.FromArgb(30, 255, 255, 255)),
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ });
+
+ illPanel1.Children.Add(new Border
+ {
+ Width = 80,
+ Height = 80,
+ CornerRadius = new CornerRadius(20),
+ Background = Brushes.White,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ BoxShadow = BoxShadows.Parse("0 8 24 0 #0000001a"),
+ Child = SvgIcon(
+ "M12 21.593c-5.63-5.539-11-10.297-11-14.402 0-3.791 3.068-5.191 5.281-5.191 1.312 0 4.151.501 5.719 4.457 1.59-3.968 4.464-4.447 5.726-4.447 2.54 0 5.274 1.621 5.274 5.181 0 4.069-5.136 8.625-11 14.402z",
+ 38, Color.Parse("#3b82f6")),
+ });
+
+ illPanel1.Children.Add(new Border
+ {
+ Background = Brushes.White,
+ CornerRadius = new CornerRadius(999),
+ Padding = new Thickness(10, 6),
+ HorizontalAlignment = HorizontalAlignment.Left,
+ VerticalAlignment = VerticalAlignment.Bottom,
+ Margin = new Thickness(24, 0, 0, 24),
+ BoxShadow = BoxShadows.Parse("0 4 12 0 #0000001a"),
+ Child = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ Spacing = 6,
+ Children =
+ {
+ new Border
+ {
+ Width = 8,
+ Height = 8,
+ CornerRadius = new CornerRadius(4),
+ Background = new SolidColorBrush(SuccessGreen),
+ VerticalAlignment = VerticalAlignment.Center,
+ },
+ Txt("Your health, simplified", 10, FontWeight.SemiBold, TextDark),
+ },
+ },
+ });
+
+ illPanel1.Children.Add(new Border
+ {
+ Width = 44,
+ Height = 44,
+ CornerRadius = new CornerRadius(22),
+ Background = new SolidColorBrush(Color.FromArgb(50, 255, 255, 255)),
+ HorizontalAlignment = HorizontalAlignment.Right,
+ VerticalAlignment = VerticalAlignment.Top,
+ Margin = new Thickness(0, 20, 28, 0),
+ Child = SvgIcon("M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z", 20, Colors.White),
+ });
+
+ var imgCard1 = new Border
+ {
+ Height = 210,
+ CornerRadius = new CornerRadius(20),
+ ClipToBounds = true,
+ Margin = new Thickness(20, 6, 20, 0),
+ Child = illPanel1,
+ };
+
+ var textArea = new StackPanel { Margin = new Thickness(28, 20, 28, 0), Spacing = 10 };
+ var titleStack1 = new StackPanel { Spacing = 2, HorizontalAlignment = HorizontalAlignment.Center };
+ titleStack1.Children.Add(Txt("Welcome to Your", 26, FontWeight.Bold, TextDark, align: TextAlignment.Center));
+ titleStack1.Children.Add(Txt("Care Companion", 28, FontWeight.ExtraBold, Primary, align: TextAlignment.Center));
+ textArea.Children.Add(titleStack1);
+ textArea.Children.Add(Txt(
+ "We are here to support you through every step of your treatment journey. Track symptoms, manage appointments, and stay connected.",
+ 13, FontWeight.Normal, TextMuted, align: TextAlignment.Center, wrap: TextWrapping.Wrap));
+
+ var nextRow = new StackPanel
+ {
+ Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Center, Spacing = 6,
+ };
+ nextRow.Children.Add(Txt("Next", 15, FontWeight.SemiBold, Colors.White));
+ nextRow.Children.Add(SvgIcon("M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z", 12, Colors.White));
+
+ var nextBtn = StyledButton(nextRow, new SolidColorBrush(Primary), Brushes.White, 52, new CornerRadius(999));
+ nextBtn.HorizontalAlignment = HorizontalAlignment.Stretch;
+ nextBtn.Click += (_, _) => carousel.SelectedIndex = 1;
+
+ var nextBtnWrap = ShadowWrap(nextBtn, Primary);
+ nextBtnWrap.HorizontalAlignment = HorizontalAlignment.Stretch;
+
+ var bottomArea = new StackPanel { Margin = new Thickness(24, 16, 24, 36), Spacing = 20 };
+ bottomArea.Children.Add(dots);
+ bottomArea.Children.Add(nextBtnWrap);
+
+ var middleStack = new StackPanel { Spacing = 0 };
+ middleStack.Children.Add(imgCard1);
+ middleStack.Children.Add(textArea);
+
+ var middleScroll = new ScrollViewer
+ {
+ VerticalScrollBarVisibility = ScrollBarVisibility.Hidden, Content = middleStack,
+ };
+
+ var grid = new Grid();
+ grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
+ grid.RowDefinitions.Add(new RowDefinition(GridLength.Star));
+ grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
+ Grid.SetRow(skipBtn, 0);
+ Grid.SetRow(middleScroll, 1);
+ Grid.SetRow(bottomArea, 2);
+ grid.Children.Add(skipBtn);
+ grid.Children.Add(middleScroll);
+ grid.Children.Add(bottomArea);
+
+ page.Content = grid;
+ return page;
+ }
+
+ ContentPage BuildTrackPage(AvaCarouselPage carousel, PipsPager dots)
+ {
+ var page = new ContentPage { Background = new SolidColorBrush(CardBg) };
+
+ var skipBtn = StyledButton("Skip", Brushes.Transparent, new SolidColorBrush(TextMuted),
+ 32, new CornerRadius(999));
+ skipBtn.HorizontalAlignment = HorizontalAlignment.Right;
+ skipBtn.Margin = new Thickness(0, 4, 8, 0);
+ skipBtn.Click += (_, _) => CompleteOnboarding();
+
+ var illGrad2 = new LinearGradientBrush
+ {
+ StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
+ EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
+ };
+ illGrad2.GradientStops.Add(new GradientStop(Color.Parse("#0ea5e9"), 0));
+ illGrad2.GradientStops.Add(new GradientStop(Color.Parse("#6366f1"), 1));
+
+ var illPanel2 = new Panel { Background = illGrad2 };
+
+ int[] barH = { 48, 72, 40, 96, 64, 80, 56 };
+ string[] barD = { "M", "T", "W", "T", "F", "S", "S" };
+ var chartInner = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Bottom,
+ Spacing = 6,
+ Margin = new Thickness(0, 0, 0, 10),
+ };
+ for (int ci = 0; ci < barH.Length; ci++)
+ {
+ var barCol = new StackPanel { Spacing = 3, VerticalAlignment = VerticalAlignment.Bottom };
+ barCol.Children.Add(new Border
+ {
+ Width = 20,
+ Height = barH[ci],
+ CornerRadius = new CornerRadius(5, 5, 0, 0),
+ Background = new SolidColorBrush(ci == 3 ? Colors.White : Color.FromArgb(160, 255, 255, 255)),
+ VerticalAlignment = VerticalAlignment.Bottom,
+ });
+ barCol.Children.Add(Txt(barD[ci], 9, FontWeight.Medium, Colors.White, 0.7, align: TextAlignment.Center));
+ chartInner.Children.Add(barCol);
+ }
+
+ illPanel2.Children.Add(new Border
+ {
+ Background = new SolidColorBrush(Color.FromArgb(40, 255, 255, 255)),
+ CornerRadius = new CornerRadius(16),
+ Padding = new Thickness(14, 14, 14, 6),
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ Child = chartInner,
+ });
+
+ illPanel2.Children.Add(new Border
+ {
+ Background = Brushes.White,
+ CornerRadius = new CornerRadius(12),
+ Padding = new Thickness(10, 7),
+ HorizontalAlignment = HorizontalAlignment.Left,
+ VerticalAlignment = VerticalAlignment.Top,
+ Margin = new Thickness(22, 20, 0, 0),
+ BoxShadow = BoxShadows.Parse("0 4 12 0 #0000001a"),
+ Child = new StackPanel
+ {
+ Spacing = 1,
+ Children =
+ {
+ Txt("Weekly Score", 9, FontWeight.SemiBold, TextMuted),
+ Txt("\u2191 18%", 13, FontWeight.Bold, Color.Parse("#0ea5e9")),
+ },
+ },
+ });
+
+ illPanel2.Children.Add(new Border
+ {
+ Width = 36,
+ Height = 36,
+ CornerRadius = new CornerRadius(18),
+ Background = new SolidColorBrush(Color.FromArgb(50, 255, 255, 255)),
+ HorizontalAlignment = HorizontalAlignment.Right,
+ VerticalAlignment = VerticalAlignment.Top,
+ Margin = new Thickness(0, 22, 24, 0),
+ Child = SvgIcon(
+ "M16 6l2.29 2.29-4.88 4.88-4-4L2 16.59 3.41 18l6-6 4 4 6.3-6.29L22 12V6z",
+ 16, Colors.White),
+ });
+
+ var imgCard2 = new Border
+ {
+ Height = 210,
+ CornerRadius = new CornerRadius(20),
+ ClipToBounds = true,
+ Margin = new Thickness(20, 6, 20, 0),
+ Child = illPanel2,
+ };
+
+ var iconBadge = new Border
+ {
+ Width = 52,
+ Height = 52,
+ CornerRadius = new CornerRadius(14),
+ Background = new SolidColorBrush(Color.Parse("#eff6ff")),
+ Margin = new Thickness(0, 16, 0, 0),
+ HorizontalAlignment = HorizontalAlignment.Center,
+ Child = SvgIcon(
+ "M16 6l2.29 2.29-4.88 4.88-4-4L2 16.59 3.41 18l6-6 4 4 6.3-6.29L22 12V6z",
+ 24, Primary),
+ };
+
+ var textArea = new StackPanel { Margin = new Thickness(28, 10, 28, 0), Spacing = 10 };
+ textArea.Children.Add(Txt("Track and Understand", 24, FontWeight.Bold, TextDark,
+ align: TextAlignment.Center, wrap: TextWrapping.Wrap));
+ textArea.Children.Add(Txt(
+ "Easily log your symptoms and side effects to share with your medical team for better care.",
+ 13, FontWeight.Normal, TextMuted, align: TextAlignment.Center, wrap: TextWrapping.Wrap));
+
+ var backRow = new StackPanel
+ {
+ Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Center, Spacing = 6,
+ };
+ backRow.Children.Add(SvgIcon("M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z", 12, TextDark));
+ backRow.Children.Add(Txt("Back", 15, FontWeight.SemiBold, TextDark));
+
+ var nextRow = new StackPanel
+ {
+ Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Center, Spacing = 6,
+ };
+ nextRow.Children.Add(Txt("Next", 15, FontWeight.SemiBold, Colors.White));
+ nextRow.Children.Add(SvgIcon("M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z", 12, Colors.White));
+
+ var backBtn = StyledButton(backRow, new SolidColorBrush(Color.Parse("#f3f4f6")),
+ new SolidColorBrush(TextDark), 52, new CornerRadius(999));
+ backBtn.HorizontalAlignment = HorizontalAlignment.Stretch;
+ backBtn.Click += (_, _) => carousel.SelectedIndex = 0;
+
+ var nextBtn = StyledButton(nextRow, new SolidColorBrush(Primary), Brushes.White, 52, new CornerRadius(999));
+ nextBtn.HorizontalAlignment = HorizontalAlignment.Stretch;
+ nextBtn.Click += (_, _) => carousel.SelectedIndex = 2;
+
+ var nextBtnWrap2 = ShadowWrap(nextBtn, Primary);
+ nextBtnWrap2.HorizontalAlignment = HorizontalAlignment.Stretch;
+
+ var navGrid = new Grid { ColumnDefinitions = new ColumnDefinitions("*,16,*") };
+ Grid.SetColumn(backBtn, 0);
+ Grid.SetColumn(nextBtnWrap2, 2);
+ navGrid.Children.Add(backBtn);
+ navGrid.Children.Add(nextBtnWrap2);
+
+ var bottomArea = new StackPanel { Margin = new Thickness(24, 16, 24, 36), Spacing = 20 };
+ bottomArea.Children.Add(dots);
+ bottomArea.Children.Add(navGrid);
+
+ var middleStack = new StackPanel { Spacing = 0 };
+ middleStack.Children.Add(imgCard2);
+ middleStack.Children.Add(iconBadge);
+ middleStack.Children.Add(textArea);
+
+ var middleScroll = new ScrollViewer
+ {
+ VerticalScrollBarVisibility = ScrollBarVisibility.Hidden, Content = middleStack,
+ };
+
+ var grid = new Grid();
+ grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
+ grid.RowDefinitions.Add(new RowDefinition(GridLength.Star));
+ grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
+ Grid.SetRow(skipBtn, 0);
+ Grid.SetRow(middleScroll, 1);
+ Grid.SetRow(bottomArea, 2);
+ grid.Children.Add(skipBtn);
+ grid.Children.Add(middleScroll);
+ grid.Children.Add(bottomArea);
+
+ page.Content = grid;
+ return page;
+ }
+
+ ContentPage BuildResourcesPage(AvaCarouselPage carousel, PipsPager dots)
+ {
+ var page = new ContentPage { Background = new SolidColorBrush(CardBg) };
+
+ var skipBtn = StyledButton("Skip", Brushes.Transparent, new SolidColorBrush(TextMuted),
+ 32, new CornerRadius(999));
+ skipBtn.HorizontalAlignment = HorizontalAlignment.Right;
+ skipBtn.Margin = new Thickness(0, 4, 8, 0);
+ skipBtn.Click += (_, _) => CompleteOnboarding();
+
+ var illPanel = new Panel { Background = new SolidColorBrush(Color.Parse("#eef4ff")) };
+
+ illPanel.Children.Add(new Border
+ {
+ Width = 72,
+ Height = 72,
+ CornerRadius = new CornerRadius(36),
+ Background = Brushes.White,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(0, 0, 0, 32),
+ BoxShadow = BoxShadows.Parse("0 4 16 0 #0000001a"),
+ Child = SvgIcon(
+ "M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z",
+ 32, Primary),
+ });
+
+ illPanel.Children.Add(new Border
+ {
+ Width = 36,
+ Height = 36,
+ CornerRadius = new CornerRadius(18),
+ Background = new SolidColorBrush(Color.Parse("#10b981")),
+ HorizontalAlignment = HorizontalAlignment.Right,
+ VerticalAlignment = VerticalAlignment.Top,
+ Margin = new Thickness(0, 22, 44, 0),
+ Child = SvgIcon("M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z", 17, Colors.White),
+ });
+
+ illPanel.Children.Add(new Border
+ {
+ Width = 30,
+ Height = 30,
+ CornerRadius = new CornerRadius(15),
+ Background = new SolidColorBrush(Color.Parse("#8b5cf6")),
+ HorizontalAlignment = HorizontalAlignment.Right,
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(0, 0, 20, 0),
+ Child = SvgIcon(
+ "M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z",
+ 15, Colors.White),
+ });
+
+ var avatarGrad = new LinearGradientBrush
+ {
+ StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
+ EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
+ };
+ avatarGrad.GradientStops.Add(new GradientStop(Color.Parse("#93c5fd"), 0));
+ avatarGrad.GradientStops.Add(new GradientStop(Primary, 1));
+ illPanel.Children.Add(new Border
+ {
+ Width = 40,
+ Height = 40,
+ CornerRadius = new CornerRadius(20),
+ Background = avatarGrad,
+ HorizontalAlignment = HorizontalAlignment.Left,
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(32, -20, 0, 0),
+ Child = SvgIcon(
+ "M12 2C9.243 2 7 4.243 7 7s2.243 5 5 5 5-2.243 5-5-2.243-5-5-5zM12 14c-5.523 0-10 3.582-10 8a1 1 0 001 1h18a1 1 0 001-1c0-4.418-4.477-8-10-8z",
+ 22, Colors.White),
+ });
+
+ var csIconBorder = new Border
+ {
+ Width = 28,
+ Height = 28,
+ CornerRadius = new CornerRadius(6),
+ Background = new SolidColorBrush(Color.Parse("#eff6ff")),
+ VerticalAlignment = VerticalAlignment.Center,
+ Child = SvgIcon(
+ "M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z",
+ 14, Primary),
+ };
+ var csText = new StackPanel { Spacing = 1, VerticalAlignment = VerticalAlignment.Center };
+ csText.Children.Add(Txt("Community Support", 10, FontWeight.SemiBold, TextDark));
+ csText.Children.Add(Txt("Connect with others and experts.", 9, FontWeight.Normal, TextMuted,
+ wrap: TextWrapping.Wrap));
+ var csInner = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
+ csInner.Children.Add(csIconBorder);
+ csInner.Children.Add(csText);
+ illPanel.Children.Add(new Border
+ {
+ Background = Brushes.White,
+ CornerRadius = new CornerRadius(10),
+ Padding = new Thickness(8, 7),
+ HorizontalAlignment = HorizontalAlignment.Left,
+ VerticalAlignment = VerticalAlignment.Bottom,
+ Margin = new Thickness(16, 0, 64, 14),
+ BoxShadow = BoxShadows.Parse("0 2 8 0 #0000001a"),
+ Child = csInner,
+ });
+
+ var illCard = new Border
+ {
+ Height = 210,
+ CornerRadius = new CornerRadius(20),
+ ClipToBounds = true,
+ Margin = new Thickness(20, 6, 20, 0),
+ Child = illPanel,
+ };
+
+ var textArea = new StackPanel { Margin = new Thickness(28, 20, 28, 0), Spacing = 10 };
+ textArea.Children.Add(Txt("Stay Informed and Connected", 24, FontWeight.Bold, TextDark,
+ align: TextAlignment.Center, wrap: TextWrapping.Wrap));
+ textArea.Children.Add(Txt(
+ "Access expert resources and manage your appointments all in one place.",
+ 13, FontWeight.Normal, TextMuted, align: TextAlignment.Center, wrap: TextWrapping.Wrap));
+
+ var gsRow = new StackPanel
+ {
+ Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Center, Spacing = 6,
+ };
+ gsRow.Children.Add(Txt("Get Started", 15, FontWeight.SemiBold, Colors.White));
+ gsRow.Children.Add(SvgIcon("M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z", 12, Colors.White));
+
+ var getStartedBtn = StyledButton(gsRow, new SolidColorBrush(Primary), Brushes.White, 52, new CornerRadius(999));
+ getStartedBtn.HorizontalAlignment = HorizontalAlignment.Stretch;
+ getStartedBtn.Click += (_, _) => CompleteOnboarding();
+
+ var getStartedWrap = ShadowWrap(getStartedBtn, Primary);
+ getStartedWrap.HorizontalAlignment = HorizontalAlignment.Stretch;
+
+ var loginRow = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ Spacing = 0,
+ };
+ loginRow.Children.Add(new TextBlock
+ {
+ Text = "Already have an account? ",
+ FontSize = 13,
+ Foreground = new SolidColorBrush(TextMuted),
+ VerticalAlignment = VerticalAlignment.Center,
+ });
+ var loginLink = new TextBlock
+ {
+ Text = "Log In",
+ FontSize = 13,
+ FontWeight = FontWeight.SemiBold,
+ Foreground = new SolidColorBrush(Primary),
+ VerticalAlignment = VerticalAlignment.Center,
+ Cursor = new Cursor(StandardCursorType.Hand),
+ };
+ loginLink.PointerReleased += (_, _) => CompleteOnboarding();
+ loginRow.Children.Add(loginLink);
+
+ var bottomArea = new StackPanel { Margin = new Thickness(24, 16, 24, 28), Spacing = 16 };
+ bottomArea.Children.Add(dots);
+ bottomArea.Children.Add(getStartedWrap);
+ bottomArea.Children.Add(loginRow);
+
+ var middleStack = new StackPanel { Spacing = 0 };
+ middleStack.Children.Add(illCard);
+ middleStack.Children.Add(textArea);
+
+ var middleScroll = new ScrollViewer
+ {
+ VerticalScrollBarVisibility = ScrollBarVisibility.Hidden, Content = middleStack,
+ };
+
+ var grid = new Grid();
+ grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
+ grid.RowDefinitions.Add(new RowDefinition(GridLength.Star));
+ grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
+ Grid.SetRow(skipBtn, 0);
+ Grid.SetRow(middleScroll, 1);
+ Grid.SetRow(bottomArea, 2);
+ grid.Children.Add(skipBtn);
+ grid.Children.Add(middleScroll);
+ grid.Children.Add(bottomArea);
+
+ page.Content = grid;
+ return page;
+ }
+
+ async void CompleteOnboarding()
+ {
+ if (_navPage == null || _onboarding == null) return;
+ var dashboard = BuildDashboard();
+ await _navPage.PushAsync(dashboard);
+ _navPage.RemovePage(_onboarding);
+ _onboarding = null;
+ }
+
+ TabbedPage BuildDashboard()
+ {
+ var tp = new TabbedPage { Background = new SolidColorBrush(BgLight), TabPlacement = TabPlacement.Bottom, };
+ tp.Resources["TabbedPageTabStripBackground"] = Brushes.White;
+ tp.Resources["TabbedPageTabItemHeaderForegroundSelected"] = new SolidColorBrush(Primary);
+ tp.Resources["TabbedPageTabItemHeaderForegroundUnselected"] = new SolidColorBrush(TextMuted);
+ NavigationPage.SetHasNavigationBar(tp, false);
+
+ var home = BuildHomeTab();
+ home.Header = "Home";
+ home.Icon = Geometry.Parse("M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z");
+
+ tp.Pages = new ObservableCollection
+ {
+ home,
+ PlaceholderTab("Care Plan",
+ "M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z",
+ "Your personalized care plan will appear here."),
+ PlaceholderTab("Messages",
+ "M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z",
+ "Messages from your care team will appear here."),
+ PlaceholderTab("Library",
+ "M21 5c-1.11-.35-2.33-.5-3.5-.5-1.95 0-4.05.4-5.5 1.5-1.45-1.1-3.55-1.5-5.5-1.5S2.45 4.9 1 6v14.65c0 .25.25.5.5.5.1 0 .15-.05.25-.05C3.1 20.45 5.05 20 6.5 20c1.95 0 4.05.4 5.5 1.5 1.35-.85 3.8-1.5 5.5-1.5 1.65 0 3.35.3 4.75 1.05.1.05.15.05.25.05.25 0 .5-.25.5-.5V6c-.6-.45-1.25-.75-2-1z",
+ "Educational resources and guides will appear here."),
+ PlaceholderTab("Profile",
+ "M12 2C9.243 2 7 4.243 7 7s2.243 5 5 5 5-2.243 5-5-2.243-5-5-5zM12 14c-5.523 0-10 3.582-10 8a1 1 0 001 1h18a1 1 0 001-1c0-4.418-4.477-8-10-8z",
+ "Your profile and settings will appear here."),
+ };
+
+ return tp;
+ }
+
+ static ContentPage PlaceholderTab(string header, string iconData, string message)
+ => new ContentPage
+ {
+ Header = header,
+ Icon = Geometry.Parse(iconData),
+ Background = new SolidColorBrush(BgLight),
+ Content = new StackPanel
+ {
+ VerticalAlignment = VerticalAlignment.Center,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ Spacing = 12,
+ Margin = new Thickness(32),
+ Children =
+ {
+ SvgIcon(iconData, 48, Color.Parse("#d1d5db")),
+ Txt(header, 20, FontWeight.Bold, TextDark, align: TextAlignment.Center),
+ Txt(message, 13, FontWeight.Normal, TextMuted,
+ align: TextAlignment.Center, wrap: TextWrapping.Wrap),
+ },
+ },
+ };
+
+ ContentPage BuildHomeTab()
+ {
+ var page = new ContentPage { Background = new SolidColorBrush(BgLight) };
+ var scroll = new ScrollViewer { VerticalScrollBarVisibility = ScrollBarVisibility.Auto };
+ var root = new StackPanel { Spacing = 0 };
+
+ var headerBorder = new Border { Background = Brushes.White, Padding = new Thickness(16, 20, 16, 16) };
+ var hGrid = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto") };
+
+ var greetStack = new StackPanel { Spacing = 2 };
+ greetStack.Children.Add(Txt("Tuesday, Oct 24", 12, FontWeight.Normal, TextMuted));
+ greetStack.Children.Add(Txt("Good Morning, Sarah", 20, FontWeight.Bold, TextDark));
+ hGrid.Children.Add(greetStack);
+
+ var bellContainer = new Panel { Width = 40, Height = 40, VerticalAlignment = VerticalAlignment.Center };
+ bellContainer.Children.Add(new Border
+ {
+ Width = 40,
+ Height = 40,
+ CornerRadius = new CornerRadius(20),
+ Background = new SolidColorBrush(Color.Parse("#f3f4f6")),
+ Child = SvgIcon(
+ "M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z",
+ 20, TextDark),
+ });
+ bellContainer.Children.Add(new Border
+ {
+ Width = 10,
+ Height = 10,
+ CornerRadius = new CornerRadius(5),
+ Background = Brushes.Red,
+ BorderBrush = Brushes.White,
+ BorderThickness = new Thickness(1.5),
+ HorizontalAlignment = HorizontalAlignment.Right,
+ VerticalAlignment = VerticalAlignment.Top,
+ Margin = new Thickness(0, 1, 1, 0),
+ });
+ Grid.SetColumn(bellContainer, 1);
+ hGrid.Children.Add(bellContainer);
+ headerBorder.Child = hGrid;
+ root.Children.Add(headerBorder);
+ root.Children.Add(new Border { Height = 12 });
+
+ var weeklyGrad = new LinearGradientBrush
+ {
+ StartPoint = new RelativePoint(0, 0.5, RelativeUnit.Relative),
+ EndPoint = new RelativePoint(1, 0.5, RelativeUnit.Relative),
+ };
+ weeklyGrad.GradientStops.Add(new GradientStop(Primary, 0));
+ weeklyGrad.GradientStops.Add(new GradientStop(PrimaryDark, 1));
+
+ var weeklyCard = new Border
+ {
+ Background = weeklyGrad,
+ CornerRadius = new CornerRadius(16),
+ Padding = new Thickness(16),
+ Margin = new Thickness(16, 0),
+ };
+ var weeklyInner = new StackPanel { Spacing = 14 };
+
+ var weeklyTitleRow = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto") };
+ var weeklyTitleStack = new StackPanel { Spacing = 2 };
+ weeklyTitleStack.Children.Add(Txt("Weekly Progress", 16, FontWeight.Bold, Colors.White));
+ weeklyTitleStack.Children.Add(Txt("You're on a 5-day streak!", 12, FontWeight.Normal, Colors.White, 0.8));
+ weeklyTitleRow.Children.Add(weeklyTitleStack);
+
+ var trendBadge = new Border
+ {
+ Width = 40,
+ Height = 40,
+ CornerRadius = new CornerRadius(10),
+ Background = Brushes.White,
+ VerticalAlignment = VerticalAlignment.Center,
+ Child = SvgIcon(
+ "M16 6l2.29 2.29-4.88 4.88-4-4L2 16.59 3.41 18l6-6 4 4 6.3-6.29L22 12V6z",
+ 20, Primary),
+ };
+ Grid.SetColumn(trendBadge, 1);
+ weeklyTitleRow.Children.Add(trendBadge);
+ weeklyInner.Children.Add(weeklyTitleRow);
+
+ string[] dayLabels = { "M", "T", "W", "T", "F", "S", "S" };
+ var dayGrid = new UniformGrid { Rows = 1 };
+ for (int i = 0; i < 7; i++)
+ {
+ bool isCurrent = i == 4;
+ bool isPast = i < 4;
+
+ Border innerCircle;
+ if (isCurrent)
+ {
+ innerCircle = new Border
+ {
+ Width = 38,
+ Height = 38,
+ CornerRadius = new CornerRadius(19),
+ Background = Brushes.White,
+ Child = SvgIcon("M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z", 18, Primary),
+ };
+ }
+ else if (isPast)
+ {
+ innerCircle = new Border
+ {
+ Width = 30,
+ Height = 30,
+ CornerRadius = new CornerRadius(15),
+ Background = new SolidColorBrush(Color.FromArgb(55, 255, 255, 255)),
+ Child = SvgIcon("M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z", 13, Colors.White),
+ };
+ }
+ else
+ {
+ innerCircle = new Border
+ {
+ Width = 30,
+ Height = 30,
+ CornerRadius = new CornerRadius(15),
+ Background = new SolidColorBrush(Color.FromArgb(25, 255, 255, 255)),
+ };
+ }
+
+ var circleWrap = new Border
+ {
+ Width = 40, Height = 40, HorizontalAlignment = HorizontalAlignment.Center, Child = innerCircle,
+ };
+ innerCircle.HorizontalAlignment = HorizontalAlignment.Center;
+ innerCircle.VerticalAlignment = VerticalAlignment.Center;
+
+ var dayCol = new StackPanel { Spacing = 4, HorizontalAlignment = HorizontalAlignment.Center };
+ dayCol.Children.Add(circleWrap);
+ dayCol.Children.Add(Txt(dayLabels[i], 10, FontWeight.Medium, Colors.White, 0.75,
+ align: TextAlignment.Center));
+ dayGrid.Children.Add(dayCol);
+ }
+
+ weeklyInner.Children.Add(dayGrid);
+ weeklyCard.Child = weeklyInner;
+ root.Children.Add(weeklyCard);
+ root.Children.Add(new Border { Height = 12 });
+
+ var symptomCard = new Border
+ {
+ Background = Brushes.White,
+ CornerRadius = new CornerRadius(16),
+ Padding = new Thickness(16),
+ Margin = new Thickness(16, 0),
+ BoxShadow = BoxShadows.Parse("0 1 4 0 #0000000a"),
+ };
+ var sInner = new StackPanel { Spacing = 12 };
+ var sTopRow = new StackPanel
+ {
+ Orientation = Orientation.Horizontal, Spacing = 12, VerticalAlignment = VerticalAlignment.Center
+ };
+ sTopRow.Children.Add(new Border
+ {
+ Width = 48,
+ Height = 48,
+ CornerRadius = new CornerRadius(12),
+ Background = new SolidColorBrush(Color.Parse("#fff7ed")),
+ Child = SvgIcon(
+ "M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5 7.67 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z",
+ 24, Color.Parse("#f97316")),
+ });
+ var sTextStack = new StackPanel { Spacing = 2, VerticalAlignment = VerticalAlignment.Center };
+ sTextStack.Children.Add(Txt("How are you feeling?", 15, FontWeight.Bold, TextDark));
+ sTextStack.Children.Add(Txt("Track your symptoms daily.", 12, FontWeight.Normal, TextMuted));
+ sTopRow.Children.Add(sTextStack);
+ sInner.Children.Add(sTopRow);
+
+ var logBtn = StyledButton("Log Symptoms", new SolidColorBrush(Primary), Brushes.White, 44,
+ new CornerRadius(10));
+ logBtn.HorizontalAlignment = HorizontalAlignment.Stretch;
+ sInner.Children.Add(logBtn);
+ symptomCard.Child = sInner;
+ root.Children.Add(symptomCard);
+
+ root.Children.Add(new Border { Height = 16 });
+ var schedHeader = new Grid
+ {
+ ColumnDefinitions = new ColumnDefinitions("*,Auto"), Margin = new Thickness(16, 0, 16, 8),
+ };
+ schedHeader.Children.Add(Txt("Today's Schedule", 16, FontWeight.Bold, TextDark));
+ var seeAllTxt = new TextBlock
+ {
+ Text = "See All",
+ FontSize = 13,
+ FontWeight = FontWeight.SemiBold,
+ Foreground = new SolidColorBrush(Primary),
+ VerticalAlignment = VerticalAlignment.Center,
+ Cursor = new Cursor(StandardCursorType.Hand),
+ };
+ Grid.SetColumn(seeAllTxt, 1);
+ schedHeader.Children.Add(seeAllTxt);
+ root.Children.Add(schedHeader);
+
+ root.Children.Add(BuildScheduleItem(
+ bulletColor: WarningAmber,
+ iconData:
+ "M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 3c1.93 0 3.5 1.57 3.5 3.5S13.93 13 12 13s-3.5-1.57-3.5-3.5S10.07 6 12 6zm7 13H5v-.23c0-.62.28-1.2.76-1.58C7.47 15.82 9.64 15 12 15s4.53.82 6.24 2.19c.48.38.76.97.76 1.58V19z",
+ iconBg: Color.Parse("#fef3c7"),
+ iconFg: WarningAmber,
+ title: "Tamoxifen (20mg)",
+ time: "09:00 AM",
+ subtitle: "Take with food",
+ actionLabel: "Mark as Done",
+ isCheck: true));
+
+ root.Children.Add(new Border { Height = 8 });
+
+ root.Children.Add(BuildScheduleItem(
+ bulletColor: Primary,
+ iconData:
+ "M12 2C9.243 2 7 4.243 7 7s2.243 5 5 5 5-2.243 5-5-2.243-5-5-5zM12 14c-5.523 0-10 3.582-10 8a1 1 0 001 1h18a1 1 0 001-1c0-4.418-4.477-8-10-8z",
+ iconBg: PrimaryLight,
+ iconFg: Primary,
+ title: "Dr. Emily Chen",
+ time: "02:30 PM",
+ subtitle: "Oncologist \u2022 Video Consultation",
+ actionLabel: "Join Video Call",
+ isCheck: false));
+
+ root.Children.Add(new Border { Height = 12 });
+ root.Children.Add(new TextBlock
+ {
+ Text = "No more events for today. Rest well.",
+ FontSize = 12,
+ FontStyle = FontStyle.Italic,
+ Foreground = new SolidColorBrush(TextMuted),
+ TextAlignment = TextAlignment.Center,
+ Margin = new Thickness(16, 0, 16, 0),
+ });
+ root.Children.Add(new Border { Height = 24 });
+
+ scroll.Content = root;
+ page.Content = scroll;
+ return page;
+ }
+
+ Border BuildScheduleItem(Color bulletColor, string iconData, Color iconBg, Color iconFg,
+ string title, string time, string subtitle, string actionLabel, bool isCheck)
+ {
+ var card = new Border
+ {
+ Background = Brushes.White,
+ CornerRadius = new CornerRadius(16),
+ Padding = new Thickness(16),
+ Margin = new Thickness(16, 0),
+ BoxShadow = BoxShadows.Parse("0 1 4 0 #0000000a"),
+ };
+
+ var outerRow = new Grid { ColumnDefinitions = new ColumnDefinitions("8,*") };
+ outerRow.Children.Add(new Border
+ {
+ Width = 8,
+ Height = 8,
+ CornerRadius = new CornerRadius(4),
+ Background = new SolidColorBrush(bulletColor),
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Top,
+ Margin = new Thickness(0, 4, 0, 0),
+ });
+
+ var rightStack = new StackPanel { Spacing = 8, Margin = new Thickness(10, 0, 0, 0) };
+ Grid.SetColumn(rightStack, 1);
+
+ var topRow = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto") };
+ topRow.Children.Add(new Border
+ {
+ Width = 36,
+ Height = 36,
+ CornerRadius = new CornerRadius(8),
+ Background = new SolidColorBrush(iconBg),
+ VerticalAlignment = VerticalAlignment.Center,
+ Child = SvgIcon(iconData, 18, iconFg),
+ });
+
+ var titleTxt = Txt(title, 14, FontWeight.SemiBold, TextDark);
+ titleTxt.VerticalAlignment = VerticalAlignment.Center;
+ titleTxt.Margin = new Thickness(10, 0, 6, 0);
+ Grid.SetColumn(titleTxt, 1);
+ topRow.Children.Add(titleTxt);
+
+ var timeBadge = new Border
+ {
+ Background = new SolidColorBrush(Color.Parse("#f3f4f6")),
+ CornerRadius = new CornerRadius(999),
+ Padding = new Thickness(8, 3),
+ VerticalAlignment = VerticalAlignment.Center,
+ Child = Txt(time, 10, FontWeight.Medium, TextMuted),
+ };
+ Grid.SetColumn(timeBadge, 2);
+ topRow.Children.Add(timeBadge);
+ rightStack.Children.Add(topRow);
+ rightStack.Children.Add(Txt(subtitle, 12, FontWeight.Normal, TextMuted));
+
+ Button actionBtn;
+ if (isCheck)
+ {
+ actionBtn = StyledButton(actionLabel,
+ new SolidColorBrush(Color.Parse("#f0fdf4")),
+ new SolidColorBrush(SuccessGreen),
+ 36, new CornerRadius(8),
+ border: new SolidColorBrush(Color.Parse("#bbf7d0")),
+ borderThick: new Thickness(1));
+ }
+ else
+ {
+ actionBtn = StyledButton(actionLabel, new SolidColorBrush(Primary), Brushes.White, 36, new CornerRadius(8));
+ }
+
+ actionBtn.HorizontalAlignment = HorizontalAlignment.Stretch;
+ rightStack.Children.Add(actionBtn);
+
+ outerRow.Children.Add(rightStack);
+ card.Child = outerRow;
+ return card;
+ }
+}
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/CarouselPageCustomizationPage.xaml b/samples/ControlCatalog/Pages/CarouselPage/CarouselPageCustomizationPage.xaml
new file mode 100644
index 0000000000..0dc46f3432
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselPage/CarouselPageCustomizationPage.xaml
@@ -0,0 +1,115 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/CarouselPage/CarouselPageCustomizationPage.xaml.cs b/samples/ControlCatalog/Pages/CarouselPage/CarouselPageCustomizationPage.xaml.cs
new file mode 100644
index 0000000000..afcb51bd56
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselPage/CarouselPageCustomizationPage.xaml.cs
@@ -0,0 +1,79 @@
+using System;
+using System.Collections;
+using Avalonia.Animation;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+
+namespace ControlCatalog.Pages
+{
+ public partial class CarouselPageCustomizationPage : UserControl
+ {
+ public CarouselPageCustomizationPage()
+ {
+ InitializeComponent();
+ Loaded += OnLoaded;
+ Unloaded += OnUnloaded;
+ }
+
+ private void OnLoaded(object? sender, RoutedEventArgs e)
+ {
+ DemoCarousel.PageTransition = new PageSlide(TimeSpan.FromMilliseconds(300), PageSlide.SlideAxis.Horizontal);
+ DemoCarousel.SelectionChanged += OnSelectionChanged;
+ UpdateDots(DemoCarousel.SelectedIndex);
+ UpdateStatus();
+ }
+
+ private void OnUnloaded(object? sender, RoutedEventArgs e)
+ {
+ DemoCarousel.SelectionChanged -= OnSelectionChanged;
+ }
+
+ private void OnSelectionChanged(object? sender, PageSelectionChangedEventArgs e)
+ {
+ UpdateDots(DemoCarousel.SelectedIndex);
+ UpdateStatus();
+ }
+
+ private void OnOrientationChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ if (DemoCarousel == null)
+ return;
+
+ var axis = OrientationCombo.SelectedIndex == 1
+ ? PageSlide.SlideAxis.Vertical
+ : PageSlide.SlideAxis.Horizontal;
+
+ DemoCarousel.PageTransition = new PageSlide(TimeSpan.FromMilliseconds(300), axis);
+ UpdateStatus();
+ }
+
+ private void OnPrevious(object? sender, RoutedEventArgs e)
+ {
+ if (DemoCarousel.SelectedIndex > 0)
+ DemoCarousel.SelectedIndex--;
+ }
+
+ private void OnNext(object? sender, RoutedEventArgs e)
+ {
+ var pageCount = (DemoCarousel.Pages as IList)?.Count ?? 0;
+ if (DemoCarousel.SelectedIndex < pageCount - 1)
+ DemoCarousel.SelectedIndex++;
+ }
+
+ private void UpdateDots(int selectedIndex)
+ {
+ Dot0.Opacity = selectedIndex == 0 ? 1.0 : 0.4;
+ Dot1.Opacity = selectedIndex == 1 ? 1.0 : 0.4;
+ Dot2.Opacity = selectedIndex == 2 ? 1.0 : 0.4;
+ Dot3.Opacity = selectedIndex == 3 ? 1.0 : 0.4;
+ }
+
+ private void UpdateStatus()
+ {
+ if (StatusText == null) return;
+ var pageCount = (DemoCarousel.Pages as IList)?.Count ?? 0;
+ var axis = OrientationCombo?.SelectedIndex == 1 ? "Vertical" : "Horizontal";
+ StatusText.Text = $"Page {DemoCarousel.SelectedIndex + 1} of {pageCount} | {axis}";
+ }
+ }
+}
diff --git a/samples/ControlCatalog/Pages/CarouselPage/CarouselPageDataTemplatePage.xaml b/samples/ControlCatalog/Pages/CarouselPage/CarouselPageDataTemplatePage.xaml
new file mode 100644
index 0000000000..0722c9a173
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselPage/CarouselPageDataTemplatePage.xaml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/CarouselPage/CarouselPageDataTemplatePage.xaml.cs b/samples/ControlCatalog/Pages/CarouselPage/CarouselPageDataTemplatePage.xaml.cs
new file mode 100644
index 0000000000..28d3c82f31
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselPage/CarouselPageDataTemplatePage.xaml.cs
@@ -0,0 +1,209 @@
+using System.Collections.ObjectModel;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Media;
+using AvaCarouselPage = Avalonia.Controls.CarouselPage;
+
+namespace ControlCatalog.Pages
+{
+ public partial class CarouselPageDataTemplatePage : UserControl
+ {
+ private sealed class CityViewModel
+ {
+ public string Name { get; }
+ public string Color { get; }
+ public string Description { get; }
+
+ public CityViewModel(string name, string color, string description)
+ {
+ Name = name;
+ Color = color;
+ Description = description;
+ }
+ }
+
+ private static readonly CityViewModel[] InitialData =
+ {
+ new("Tokyo", "#1565C0",
+ "The neon-lit capital of Japan, where ancient temples meet futuristic skylines."),
+ new("Amsterdam", "#2E7D32", "A city of canals, bicycles, and world-class museums."),
+ new("New York", "#6A1B9A", "The city that never sleeps — a cultural and financial powerhouse."),
+ new("Sydney", "#B71C1C", "Iconic harbour, golden beaches and the world-famous Opera House."),
+ };
+
+ private static readonly CityViewModel[] AddData =
+ {
+ new("Paris", "#E65100", "The city of light, love, and the Eiffel Tower."),
+ new("Barcelona", "#00695C", "Art, architecture, and vibrant street life on the Mediterranean coast."),
+ new("Kyoto", "#880E4F", "Japan's ancient capital, a living museum of traditional culture."),
+ };
+
+ private readonly ObservableCollection _items = new();
+ private int _addCounter;
+ private bool _useCardTemplate = true;
+ private AvaCarouselPage? _carouselPage;
+
+ public CarouselPageDataTemplatePage()
+ {
+ InitializeComponent();
+ Loaded += OnLoaded;
+ }
+
+ private void OnLoaded(object? sender, RoutedEventArgs e)
+ {
+ if (_carouselPage != null)
+ return;
+
+ foreach (var vm in InitialData)
+ _items.Add(vm);
+ _addCounter = InitialData.Length;
+ _useCardTemplate = true;
+
+ _carouselPage = new AvaCarouselPage { ItemsSource = _items, PageTemplate = CreatePageTemplate() };
+
+ _carouselPage.SelectionChanged += OnSelectionChanged;
+ CarouselHost.Children.Add(_carouselPage);
+ UpdateStatus();
+ }
+
+ private void OnSelectionChanged(object? sender, PageSelectionChangedEventArgs e) => UpdateStatus();
+
+ private void OnAddPage(object? sender, RoutedEventArgs e)
+ {
+ var idx = _addCounter % AddData.Length;
+ var vm = AddData[idx];
+ var suffix = _addCounter >= AddData.Length ? $" {_addCounter / AddData.Length + 1}" : "";
+ _items.Add(new CityViewModel(vm.Name + suffix, vm.Color, vm.Description));
+ _addCounter++;
+ UpdateStatus();
+ }
+
+ private void OnRemovePage(object? sender, RoutedEventArgs e)
+ {
+ if (_items.Count > 0)
+ {
+ _items.RemoveAt(_items.Count - 1);
+ UpdateStatus();
+ }
+ }
+
+ private void OnSwitchTemplate(object? sender, RoutedEventArgs e)
+ {
+ if (_carouselPage == null)
+ return;
+
+ _useCardTemplate = !_useCardTemplate;
+ _carouselPage.PageTemplate = CreatePageTemplate();
+ }
+
+ private void OnPrevious(object? sender, RoutedEventArgs e)
+ {
+ if (_carouselPage == null)
+ return;
+ if (_carouselPage.SelectedIndex > 0)
+ _carouselPage.SelectedIndex--;
+ }
+
+ private void OnNext(object? sender, RoutedEventArgs e)
+ {
+ if (_carouselPage == null)
+ return;
+ if (_carouselPage.SelectedIndex < _items.Count - 1)
+ _carouselPage.SelectedIndex++;
+ }
+
+ private void UpdateStatus()
+ {
+ var count = _items.Count;
+ var index = _carouselPage?.SelectedIndex ?? -1;
+ StatusText.Text = count == 0 ? "No pages" : $"Page {index + 1} of {count} (index {index})";
+ }
+
+ private IDataTemplate CreatePageTemplate()
+ {
+ return new FuncDataTemplate((vm, _) => CreatePage(vm, _useCardTemplate));
+ }
+
+ private static ContentPage CreatePage(CityViewModel? vm, bool useCardTemplate)
+ {
+ if (vm is null)
+ return new ContentPage();
+
+ return new ContentPage
+ {
+ Header = vm.Name, Content = useCardTemplate ? CreateCardContent(vm) : CreateFeatureContent(vm)
+ };
+ }
+
+ private static Control CreateCardContent(CityViewModel vm)
+ {
+ return new StackPanel
+ {
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ Spacing = 8,
+ Children =
+ {
+ new TextBlock
+ {
+ Text = vm.Name,
+ FontSize = 28,
+ FontWeight = FontWeight.Bold,
+ Foreground = new SolidColorBrush(Color.Parse(vm.Color)),
+ HorizontalAlignment = HorizontalAlignment.Center
+ },
+ new TextBlock
+ {
+ Text = vm.Description,
+ FontSize = 13,
+ Opacity = 0.7,
+ TextWrapping = TextWrapping.Wrap,
+ TextAlignment = TextAlignment.Center,
+ MaxWidth = 280
+ }
+ }
+ };
+ }
+
+ private static Control CreateFeatureContent(CityViewModel vm)
+ {
+ var accent = Color.Parse(vm.Color);
+
+ return new Border
+ {
+ Background = new SolidColorBrush(accent),
+ Padding = new Thickness(32),
+ Child = new StackPanel
+ {
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ Spacing = 12,
+ Children =
+ {
+ new TextBlock
+ {
+ Text = vm.Name.ToUpperInvariant(),
+ FontSize = 34,
+ FontWeight = FontWeight.Bold,
+ Foreground = Brushes.White,
+ HorizontalAlignment = HorizontalAlignment.Center
+ },
+ new TextBlock
+ {
+ Text = vm.Description,
+ FontSize = 15,
+ Foreground = Brushes.White,
+ Opacity = 0.88,
+ TextWrapping = TextWrapping.Wrap,
+ TextAlignment = TextAlignment.Center,
+ MaxWidth = 320
+ }
+ }
+ }
+ };
+ }
+ }
+}
diff --git a/samples/ControlCatalog/Pages/CarouselPage/CarouselPageEventsPage.xaml b/samples/ControlCatalog/Pages/CarouselPage/CarouselPageEventsPage.xaml
new file mode 100644
index 0000000000..68b7c25ded
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselPage/CarouselPageEventsPage.xaml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/CarouselPage/CarouselPageEventsPage.xaml.cs b/samples/ControlCatalog/Pages/CarouselPage/CarouselPageEventsPage.xaml.cs
new file mode 100644
index 0000000000..34887d5a26
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselPage/CarouselPageEventsPage.xaml.cs
@@ -0,0 +1,92 @@
+using System;
+using System.Collections.Generic;
+using Avalonia.Collections;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+
+namespace ControlCatalog.Pages
+{
+ public partial class CarouselPageEventsPage : UserControl
+ {
+ private readonly List _log = new();
+
+ public CarouselPageEventsPage()
+ {
+ InitializeComponent();
+ Loaded += OnLoaded;
+ Unloaded += OnUnloaded;
+ }
+
+ private void OnLoaded(object? sender, RoutedEventArgs e)
+ {
+ var pageNames = new[] { "Home", "Explore", "Library", "Profile" };
+ for (int i = 0; i < pageNames.Length; i++)
+ {
+ var name = pageNames[i];
+ var page = new ContentPage
+ {
+ Header = name,
+ Background = NavigationDemoHelper.GetPageBrush(i),
+ Content = new TextBlock
+ {
+ Text = $"{name}",
+ FontSize = 28,
+ FontWeight = Avalonia.Media.FontWeight.Bold,
+ HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
+ VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
+ },
+ HorizontalContentAlignment = Avalonia.Layout.HorizontalAlignment.Stretch,
+ VerticalContentAlignment = Avalonia.Layout.VerticalAlignment.Stretch
+ };
+
+ page.NavigatedTo += (_, args) =>
+ AppendLog($"NavigatedTo: {name} (from {(args.PreviousPage as ContentPage)?.Header ?? "—"})");
+ page.NavigatedFrom += (_, args) =>
+ AppendLog($"NavigatedFrom: {name} (to {(args.DestinationPage as ContentPage)?.Header ?? "—"})");
+
+ ((Avalonia.Collections.AvaloniaList)DemoCarousel.Pages!).Add(page);
+ }
+
+ DemoCarousel.SelectionChanged += OnSelectionChanged;
+ }
+
+ private void OnSelectionChanged(object? sender, PageSelectionChangedEventArgs e)
+ {
+ AppendLog($"SelectionChanged: {(e.PreviousPage as ContentPage)?.Header ?? "—"} → {(e.CurrentPage as ContentPage)?.Header ?? "—"}");
+ }
+
+ private void OnPrevious(object? sender, RoutedEventArgs e)
+ {
+ if (DemoCarousel.SelectedIndex > 0)
+ DemoCarousel.SelectedIndex--;
+ }
+
+ private void OnNext(object? sender, RoutedEventArgs e)
+ {
+ var pageCount = ((AvaloniaList)DemoCarousel.Pages!).Count;
+ if (DemoCarousel.SelectedIndex < pageCount - 1)
+ DemoCarousel.SelectedIndex++;
+ }
+
+ private void OnUnloaded(object? sender, RoutedEventArgs e)
+ {
+ DemoCarousel.SelectionChanged -= OnSelectionChanged;
+ }
+
+ private void OnClearLog(object? sender, RoutedEventArgs e)
+ {
+ _log.Clear();
+ EventLog.Text = string.Empty;
+ }
+
+ private void AppendLog(string message)
+ {
+ var timestamp = DateTime.Now.ToString("HH:mm:ss.fff");
+ _log.Add($"[{timestamp}] {message}");
+ if (_log.Count > 50)
+ _log.RemoveAt(0);
+ EventLog.Text = string.Join(Environment.NewLine, _log);
+ LogScrollViewer.ScrollToEnd();
+ }
+ }
+}
diff --git a/samples/ControlCatalog/Pages/CarouselPage/CarouselPageFirstLookPage.xaml b/samples/ControlCatalog/Pages/CarouselPage/CarouselPageFirstLookPage.xaml
new file mode 100644
index 0000000000..564cdb78db
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselPage/CarouselPageFirstLookPage.xaml
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/CarouselPage/CarouselPageFirstLookPage.xaml.cs b/samples/ControlCatalog/Pages/CarouselPage/CarouselPageFirstLookPage.xaml.cs
new file mode 100644
index 0000000000..341d4b3dbb
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselPage/CarouselPageFirstLookPage.xaml.cs
@@ -0,0 +1,35 @@
+using System.Collections;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+
+namespace ControlCatalog.Pages
+{
+ public partial class CarouselPageFirstLookPage : UserControl
+ {
+ public CarouselPageFirstLookPage()
+ {
+ InitializeComponent();
+ }
+
+ private void OnPrevious(object? sender, RoutedEventArgs e)
+ {
+ if (DemoCarousel.SelectedIndex > 0)
+ DemoCarousel.SelectedIndex--;
+ }
+
+ private void OnNext(object? sender, RoutedEventArgs e)
+ {
+ var pageCount = (DemoCarousel.Pages as IList)?.Count ?? 0;
+ if (DemoCarousel.SelectedIndex < pageCount - 1)
+ DemoCarousel.SelectedIndex++;
+ }
+
+ private void OnSelectionChanged(object? sender, PageSelectionChangedEventArgs e)
+ {
+ if (StatusText == null)
+ return;
+ var pageCount = (DemoCarousel.Pages as IList)?.Count ?? 0;
+ StatusText.Text = $"Page {DemoCarousel.SelectedIndex + 1} of {pageCount}";
+ }
+ }
+}
diff --git a/samples/ControlCatalog/Pages/CarouselPage/CarouselPageGesturePage.xaml b/samples/ControlCatalog/Pages/CarouselPage/CarouselPageGesturePage.xaml
new file mode 100644
index 0000000000..440655b8cc
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselPage/CarouselPageGesturePage.xaml
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/CarouselPage/CarouselPageGesturePage.xaml.cs b/samples/ControlCatalog/Pages/CarouselPage/CarouselPageGesturePage.xaml.cs
new file mode 100644
index 0000000000..2184031c2f
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselPage/CarouselPageGesturePage.xaml.cs
@@ -0,0 +1,44 @@
+using System.Collections;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+
+namespace ControlCatalog.Pages
+{
+ public partial class CarouselPageGesturePage : UserControl
+ {
+ public CarouselPageGesturePage()
+ {
+ InitializeComponent();
+ Loaded += OnLoaded;
+ }
+
+ private void OnLoaded(object? sender, RoutedEventArgs e) => UpdateStatus();
+
+ private void OnGestureChanged(object? sender, RoutedEventArgs e)
+ {
+ if (DemoCarousel == null)
+ return;
+ DemoCarousel.IsGestureEnabled = GestureCheck.IsChecked == true;
+ }
+
+ private void OnKeyboardChanged(object? sender, RoutedEventArgs e)
+ {
+ if (DemoCarousel == null)
+ return;
+ DemoCarousel.IsKeyboardNavigationEnabled = KeyboardCheck.IsChecked == true;
+ }
+
+ private void OnSelectionChanged(object? sender, PageSelectionChangedEventArgs e)
+ {
+ UpdateStatus();
+ }
+
+ private void UpdateStatus()
+ {
+ if (StatusText == null)
+ return;
+ var pageCount = (DemoCarousel.Pages as IList)?.Count ?? 0;
+ StatusText.Text = $"Page {DemoCarousel.SelectedIndex + 1} of {pageCount}";
+ }
+ }
+}
diff --git a/samples/ControlCatalog/Pages/CarouselPage/CarouselPagePerformancePage.xaml b/samples/ControlCatalog/Pages/CarouselPage/CarouselPagePerformancePage.xaml
new file mode 100644
index 0000000000..eda4ee0209
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselPage/CarouselPagePerformancePage.xaml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/CarouselPage/CarouselPagePerformancePage.xaml.cs b/samples/ControlCatalog/Pages/CarouselPage/CarouselPagePerformancePage.xaml.cs
new file mode 100644
index 0000000000..720d2645dc
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselPage/CarouselPagePerformancePage.xaml.cs
@@ -0,0 +1,103 @@
+using System;
+using System.Collections;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+
+namespace ControlCatalog.Pages
+{
+ public partial class CarouselPagePerformancePage : UserControl
+ {
+ private readonly NavigationPerformanceMonitorHelper _perf = new();
+ private int _counter;
+
+ public CarouselPagePerformancePage()
+ {
+ InitializeComponent();
+ Loaded += OnLoaded;
+ Unloaded += OnUnloaded;
+ }
+
+ private void OnLoaded(object? sender, RoutedEventArgs e)
+ {
+ AddPages(5);
+ DemoCarousel.SelectionChanged += OnSelectionChanged;
+ }
+
+ private void OnUnloaded(object? sender, RoutedEventArgs e)
+ {
+ DemoCarousel.SelectionChanged -= OnSelectionChanged;
+ }
+
+ private void OnSelectionChanged(object? sender, PageSelectionChangedEventArgs e) => RefreshStats();
+
+ private void AddPages(int count)
+ {
+ var pages = (IList)DemoCarousel.Pages!;
+ _perf.OpStopwatch.Restart();
+ for (int i = 0; i < count; i++)
+ {
+ var idx = ++_counter;
+ var page = new ContentPage
+ {
+ Header = $"P{idx}",
+ Content = new TextBlock
+ {
+ Text = $"Page {idx}",
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ FontSize = 18,
+ Opacity = 0.7
+ },
+ Tag = new byte[51200],
+ };
+ _perf.TrackPage(page);
+ pages.Add(page);
+ }
+
+ _perf.StopMetrics(LastOpTimeText);
+ RefreshStats();
+ }
+
+ private void RemovePages(int count)
+ {
+ var pages = (IList)DemoCarousel.Pages!;
+ _perf.OpStopwatch.Restart();
+ for (int i = 0; i < count && pages.Count > 0; i++)
+ pages.RemoveAt(pages.Count - 1);
+
+ _perf.StopMetrics(LastOpTimeText);
+ RefreshStats();
+ }
+
+ private void OnAdd5(object? sender, RoutedEventArgs e) => AddPages(5);
+ private void OnAdd20(object? sender, RoutedEventArgs e) => AddPages(20);
+ private void OnRemove5(object? sender, RoutedEventArgs e) => RemovePages(5);
+
+ private void OnClearAll(object? sender, RoutedEventArgs e)
+ {
+ var pages = (IList)DemoCarousel.Pages!;
+ _perf.OpStopwatch.Restart();
+ while (pages.Count > 0)
+ pages.RemoveAt(pages.Count - 1);
+ _perf.StopMetrics(LastOpTimeText);
+ RefreshStats();
+ }
+
+ private void OnForceGC(object? sender, RoutedEventArgs e)
+ {
+ _perf.ForceGC(RefreshStats);
+ }
+
+ private void OnRefresh(object? sender, RoutedEventArgs e) => RefreshStats();
+
+ private void RefreshStats()
+ {
+ var pages = (IList)DemoCarousel.Pages!;
+ PageCountText.Text = $"Page count: {pages.Count}";
+ LiveCountText.Text = $"Live instances: {_perf.CountLiveInstances()} / {_perf.TotalCreated} tracked";
+ HeapText.Text = $"Heap: {GC.GetTotalMemory(false) / 1024:N0} KB";
+ AllocText.Text = $"Total allocated: {GC.GetTotalAllocatedBytes() / 1024:N0} KB";
+ }
+ }
+}
diff --git a/samples/ControlCatalog/Pages/CarouselPage/CarouselPageSelectionPage.xaml b/samples/ControlCatalog/Pages/CarouselPage/CarouselPageSelectionPage.xaml
new file mode 100644
index 0000000000..2460db5c45
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselPage/CarouselPageSelectionPage.xaml
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/CarouselPage/CarouselPageSelectionPage.xaml.cs b/samples/ControlCatalog/Pages/CarouselPage/CarouselPageSelectionPage.xaml.cs
new file mode 100644
index 0000000000..42ec706435
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselPage/CarouselPageSelectionPage.xaml.cs
@@ -0,0 +1,56 @@
+using System.Collections;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+
+namespace ControlCatalog.Pages
+{
+ public partial class CarouselPageSelectionPage : UserControl
+ {
+ public CarouselPageSelectionPage()
+ {
+ InitializeComponent();
+ Loaded += (_, _) => UpdateStatus();
+ }
+
+ private void OnGoTo0(object? sender, RoutedEventArgs e) => DemoCarousel.SelectedIndex = 0;
+ private void OnGoTo1(object? sender, RoutedEventArgs e) => DemoCarousel.SelectedIndex = 1;
+ private void OnGoTo2(object? sender, RoutedEventArgs e) => DemoCarousel.SelectedIndex = 2;
+ private void OnGoTo3(object? sender, RoutedEventArgs e) => DemoCarousel.SelectedIndex = 3;
+
+ private void OnFirst(object? sender, RoutedEventArgs e) => DemoCarousel.SelectedIndex = 0;
+
+ private void OnPrevious(object? sender, RoutedEventArgs e)
+ {
+ if (DemoCarousel.SelectedIndex > 0)
+ DemoCarousel.SelectedIndex--;
+ }
+
+ private void OnNext(object? sender, RoutedEventArgs e)
+ {
+ var pageCount = (DemoCarousel.Pages as IList)?.Count ?? 0;
+ if (DemoCarousel.SelectedIndex < pageCount - 1)
+ DemoCarousel.SelectedIndex++;
+ }
+
+ private void OnLast(object? sender, RoutedEventArgs e)
+ {
+ var pageCount = (DemoCarousel.Pages as IList)?.Count ?? 0;
+ if (pageCount > 0)
+ DemoCarousel.SelectedIndex = pageCount - 1;
+ }
+
+ private void OnSelectionChanged(object? sender, PageSelectionChangedEventArgs e)
+ {
+ UpdateStatus();
+ }
+
+ private void UpdateStatus()
+ {
+ if (StatusText == null)
+ return;
+ var pageCount = (DemoCarousel.Pages as IList)?.Count ?? 0;
+ var header = (DemoCarousel.SelectedPage as ContentPage)?.Header?.ToString() ?? "—";
+ StatusText.Text = $"Page {DemoCarousel.SelectedIndex + 1} of {pageCount}: {header}";
+ }
+ }
+}
diff --git a/samples/ControlCatalog/Pages/CarouselPage/CarouselPageTransitionsPage.xaml b/samples/ControlCatalog/Pages/CarouselPage/CarouselPageTransitionsPage.xaml
new file mode 100644
index 0000000000..564519915d
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselPage/CarouselPageTransitionsPage.xaml
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/CarouselPage/CarouselPageTransitionsPage.xaml.cs b/samples/ControlCatalog/Pages/CarouselPage/CarouselPageTransitionsPage.xaml.cs
new file mode 100644
index 0000000000..ee976cedbb
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselPage/CarouselPageTransitionsPage.xaml.cs
@@ -0,0 +1,69 @@
+using System;
+using System.Collections;
+using Avalonia.Animation;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using ControlCatalog.Pages.Transitions;
+
+namespace ControlCatalog.Pages
+{
+ public partial class CarouselPageTransitionsPage : UserControl
+ {
+ public CarouselPageTransitionsPage()
+ {
+ InitializeComponent();
+ }
+
+ private void OnTransitionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ if (DemoCarousel == null)
+ return;
+
+ DemoCarousel.PageTransition = TransitionCombo?.SelectedIndex switch
+ {
+ 0 => null,
+ 1 => new CrossFade(TimeSpan.FromMilliseconds(300)),
+ 2 => new PageSlide(TimeSpan.FromMilliseconds(300), PageSlide.SlideAxis.Horizontal),
+ 3 => new PageSlide(TimeSpan.FromMilliseconds(300), PageSlide.SlideAxis.Vertical),
+ 4 => new CardStackPageTransition(TimeSpan.FromMilliseconds(400)),
+ 5 => new WaveRevealPageTransition(TimeSpan.FromMilliseconds(600)),
+ _ => null
+ };
+
+ UpdateStatus();
+ }
+
+ private void OnPrevious(object? sender, RoutedEventArgs e)
+ {
+ if (DemoCarousel.SelectedIndex > 0)
+ DemoCarousel.SelectedIndex--;
+ }
+
+ private void OnNext(object? sender, RoutedEventArgs e)
+ {
+ var pageCount = (DemoCarousel.Pages as IList)?.Count ?? 0;
+ if (DemoCarousel.SelectedIndex < pageCount - 1)
+ DemoCarousel.SelectedIndex++;
+ }
+
+ private void OnSelectionChanged(object? sender, PageSelectionChangedEventArgs e)
+ {
+ UpdateStatus();
+ }
+
+ private void UpdateStatus()
+ {
+ if (StatusText == null)
+ return;
+ var pageCount = (DemoCarousel.Pages as IList)?.Count ?? 0;
+ var modeName = DemoCarousel.PageTransition switch
+ {
+ null => "None",
+ CardStackPageTransition => "Card Stack",
+ WaveRevealPageTransition => "Wave Reveal",
+ { } t => t.GetType().Name
+ };
+ StatusText.Text = $"Page {DemoCarousel.SelectedIndex + 1} of {pageCount} | Transition: {modeName}";
+ }
+ }
+}
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/CarouselPage/SanctuaryMainPage.xaml b/samples/ControlCatalog/Pages/CarouselPage/SanctuaryMainPage.xaml
new file mode 100644
index 0000000000..b701ab89ba
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselPage/SanctuaryMainPage.xaml
@@ -0,0 +1,298 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ #221710
+ #f47b25
+ #50FFFFFF
+ 8,10,8,4
+
+
+
+
+
+ M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ M12 10.9c-.61 0-1.1.49-1.1 1.1s.49 1.1 1.1 1.1c.61 0 1.1-.49 1.1-1.1s-.49-1.1-1.1-1.1zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm2.19 12.19L6 18l3.81-8.19L18 6l-3.81 8.19z
+
+
+
+
+
+
+
+
+
+
+
+ M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z
+
+
+
+
+
+
+
+
+
+
+
+ M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/CarouselPage/SanctuaryMainPage.xaml.cs b/samples/ControlCatalog/Pages/CarouselPage/SanctuaryMainPage.xaml.cs
new file mode 100644
index 0000000000..05a4097a46
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselPage/SanctuaryMainPage.xaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace ControlCatalog.Pages;
+
+public partial class SanctuaryMainPage : UserControl
+{
+ public SanctuaryMainPage()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/samples/ControlCatalog/Pages/CarouselPage/SanctuaryShowcasePage.xaml b/samples/ControlCatalog/Pages/CarouselPage/SanctuaryShowcasePage.xaml
new file mode 100644
index 0000000000..50864e5e57
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselPage/SanctuaryShowcasePage.xaml
@@ -0,0 +1,335 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/CarouselPage/SanctuaryShowcasePage.xaml.cs b/samples/ControlCatalog/Pages/CarouselPage/SanctuaryShowcasePage.xaml.cs
new file mode 100644
index 0000000000..be91370691
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselPage/SanctuaryShowcasePage.xaml.cs
@@ -0,0 +1,70 @@
+using System.Linq;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.VisualTree;
+
+namespace ControlCatalog.Pages;
+
+public partial class SanctuaryShowcasePage : UserControl
+{
+ public SanctuaryShowcasePage()
+ {
+ InitializeComponent();
+ }
+
+ private void OnPage1CTA(object? sender, RoutedEventArgs e)
+ {
+ DemoCarousel.SelectedIndex = 1;
+ }
+
+ private void OnPage2CTA(object? sender, RoutedEventArgs e)
+ {
+ DemoCarousel.SelectedIndex = 2;
+ }
+
+ private async void OnPage3CTA(object? sender, RoutedEventArgs e)
+ {
+ var nav = this.FindAncestorOfType();
+ if (nav == null)
+ return;
+
+ var carouselWrapper = nav.NavigationStack.LastOrDefault();
+
+ var headerGrid = new Grid { ColumnDefinitions = new ColumnDefinitions("*, Auto") };
+ headerGrid.Children.Add(new TextBlock
+ {
+ Text = "Sanctuary",
+ VerticalAlignment = VerticalAlignment.Center
+ });
+ var closeIcon = Geometry.Parse(
+ "M4.397 4.397a1 1 0 0 1 1.414 0L12 10.585l6.19-6.188a1 1 0 0 1 1.414 1.414L13.413 12l6.19 6.189a1 1 0 0 1-1.414 1.414L12 13.413l-6.189 6.19a1 1 0 0 1-1.414-1.414L10.585 12 4.397 5.811a1 1 0 0 1 0-1.414z");
+ var closeBtn = new Button
+ {
+ Content = new PathIcon { Data = closeIcon },
+ Background = Brushes.Transparent,
+ BorderThickness = new Thickness(0),
+ Padding = new Thickness(8, 4),
+ VerticalAlignment = VerticalAlignment.Center
+ };
+ Grid.SetColumn(closeBtn, 1);
+ headerGrid.Children.Add(closeBtn);
+ closeBtn.Click += async (_, _) => await nav.PopAsync(null);
+
+ var mainPage = new ContentPage
+ {
+ Header = headerGrid,
+ Content = new SanctuaryMainPage()
+ };
+ NavigationPage.SetHasBackButton(mainPage, false);
+
+ await nav.PushAsync(mainPage);
+
+ if (carouselWrapper != null)
+ {
+ nav.RemovePage(carouselWrapper);
+ }
+ }
+}
diff --git a/samples/ControlCatalog/Pages/CommandBar/CommandBarEventsPage.xaml b/samples/ControlCatalog/Pages/CommandBar/CommandBarEventsPage.xaml
new file mode 100644
index 0000000000..8dbc44e19b
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CommandBar/CommandBarEventsPage.xaml
@@ -0,0 +1,95 @@
+
+
+ M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z
+ M15,9H5V5H15M12,19A3,3 0 0,1 9,16A3,3 0 0,1 12,13A3,3 0 0,1 15,16A3,3 0 0,1 12,19M17,3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V7L17,3Z
+ M18,16.08C17.24,16.08 16.56,16.38 16.04,16.85L8.91,12.7C8.96,12.47 9,12.24 9,12C9,11.76 8.96,11.53 8.91,11.3L15.96,7.19C16.5,7.69 17.21,8 18,8A3,3 0 0,0 21,5A3,3 0 0,0 18,2A3,3 0 0,0 15,5C15,5.24 15.04,5.47 15.09,5.7L8.04,9.81C7.5,9.31 6.79,9 6,9A3,3 0 0,0 3,12A3,3 0 0,0 6,15C6.79,15 7.5,14.69 8.04,14.19L15.16,18.34C15.11,18.55 15.08,18.77 15.08,19C15.08,20.61 16.39,21.91 18,21.91C19.61,21.91 20.92,20.61 20.92,19C20.92,17.39 19.61,16.08 18,16.08Z
+ M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20M16,11V18.1L13.9,16L11.1,18.8L8.3,16L11.1,13.2L9,11.1L16,11Z
+ M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Observe CommandBar overflow events, item invocation, and state changes while opening, closing, and editing the primary and secondary command sets.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/CommandBar/CommandBarEventsPage.xaml.cs b/samples/ControlCatalog/Pages/CommandBar/CommandBarEventsPage.xaml.cs
new file mode 100644
index 0000000000..c2f0b439f0
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CommandBar/CommandBarEventsPage.xaml.cs
@@ -0,0 +1,220 @@
+using System.Collections.Generic;
+using System.Linq;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Media;
+using MiniMvvm;
+
+namespace ControlCatalog.Pages
+{
+ public partial class CommandBarEventsPage : UserControl
+ {
+ private readonly List _log = new();
+ private int _primaryCount = 3;
+ private int _secondaryCount = 2;
+
+ public CommandBarEventsPage()
+ {
+ InitializeComponent();
+ Loaded += OnLoaded;
+ Unloaded += OnUnloaded;
+ }
+
+ private void OnLoaded(object? sender, RoutedEventArgs e)
+ {
+ DemoBar.Opening += OnOpening;
+ DemoBar.Opened += OnOpened;
+ DemoBar.Closing += OnClosing;
+ DemoBar.Closed += OnClosed;
+ DemoBar.PropertyChanged += OnBarPropertyChanged;
+
+ AttachItemHandlers(DemoBar.PrimaryCommands);
+ AttachItemHandlers(DemoBar.SecondaryCommands);
+
+ AppendLog("Ready");
+ RefreshState();
+ }
+
+ private void OnUnloaded(object? sender, RoutedEventArgs e)
+ {
+ DemoBar.Opening -= OnOpening;
+ DemoBar.Opened -= OnOpened;
+ DemoBar.Closing -= OnClosing;
+ DemoBar.Closed -= OnClosed;
+ DemoBar.PropertyChanged -= OnBarPropertyChanged;
+
+ DetachItemHandlers(DemoBar.PrimaryCommands);
+ DetachItemHandlers(DemoBar.SecondaryCommands);
+ }
+
+ private void OnIsOpenChanged(object? sender, RoutedEventArgs e)
+ {
+ DemoBar.IsOpen = IsOpenCheck.IsChecked == true;
+ RefreshState();
+ }
+
+ private void OnAddPrimary(object? sender, RoutedEventArgs e)
+ {
+ _primaryCount++;
+
+ var button = CreateButton($"Primary {_primaryCount}");
+ DemoBar.PrimaryCommands.Add(button);
+
+ AppendLog($"Primary +, {DemoBar.PrimaryCommands.Count}");
+ RefreshState();
+ }
+
+ private void OnRemovePrimary(object? sender, RoutedEventArgs e)
+ {
+ RemoveLastCommand(DemoBar.PrimaryCommands, "Primary");
+ }
+
+ private void OnAddSecondary(object? sender, RoutedEventArgs e)
+ {
+ _secondaryCount++;
+
+ var button = CreateButton($"Secondary {_secondaryCount}");
+ DemoBar.SecondaryCommands.Add(button);
+
+ AppendLog($"Secondary +, {DemoBar.SecondaryCommands.Count}");
+ RefreshState();
+ }
+
+ private void OnRemoveSecondary(object? sender, RoutedEventArgs e)
+ {
+ RemoveLastCommand(DemoBar.SecondaryCommands, "Secondary");
+ }
+
+ private void OnClearLog(object? sender, RoutedEventArgs e)
+ {
+ _log.Clear();
+ EventLogText.Text = "Log cleared";
+ }
+
+ private void OnOpening(object? sender, RoutedEventArgs e)
+ {
+ AppendLog("Opening");
+ RefreshState();
+ }
+
+ private void OnOpened(object? sender, RoutedEventArgs e)
+ {
+ AppendLog("Opened");
+ RefreshState();
+ }
+
+ private void OnClosing(object? sender, RoutedEventArgs e)
+ {
+ AppendLog("Closing");
+ RefreshState();
+ }
+
+ private void OnClosed(object? sender, RoutedEventArgs e)
+ {
+ AppendLog("Closed");
+ RefreshState();
+ }
+
+ private void OnBarPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
+ {
+ if (e.Property == CommandBar.IsOpenProperty
+ || e.Property == CommandBar.HasSecondaryCommandsProperty
+ || e.Property == CommandBar.IsOverflowButtonVisibleProperty)
+ {
+ RefreshState();
+ }
+ }
+
+ private void RefreshState()
+ {
+ StateText.Text =
+ $"IsOpen: {DemoBar.IsOpen}\n" +
+ $"HasSecondaryCommands: {DemoBar.HasSecondaryCommands}\n" +
+ $"IsOverflowButtonVisible: {DemoBar.IsOverflowButtonVisible}\n" +
+ $"Primary: {DemoBar.PrimaryCommands.Count}\n" +
+ $"Secondary: {DemoBar.SecondaryCommands.Count}\n" +
+ $"OverflowItems: {DemoBar.OverflowItems.Count}";
+
+ IsOpenCheck.IsChecked = DemoBar.IsOpen;
+ }
+
+ private void OnCommandItemClick(object? sender, RoutedEventArgs e)
+ {
+ if (sender is AppBarButton button)
+ AppendLog($"Click, {button.Label}, {DescribePlacement(button)}");
+ }
+
+ private AppBarButton CreateButton(string label)
+ {
+ var button = new AppBarButton
+ {
+ Label = label,
+ Icon = new PathIcon
+ {
+ Data = StreamGeometry.Parse("M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z")
+ }
+ };
+
+ AttachItemHandler(button);
+ return button;
+ }
+
+ private void AttachItemHandlers(IEnumerable items)
+ {
+ foreach (var item in items)
+ AttachItemHandler(item);
+ }
+
+ private void DetachItemHandlers(IEnumerable items)
+ {
+ foreach (var item in items)
+ {
+ if (item is AppBarButton button)
+ button.Click -= OnCommandItemClick;
+ }
+ }
+
+ private void AttachItemHandler(ICommandBarElement item)
+ {
+ if (item is not AppBarButton button)
+ return;
+
+ button.Click -= OnCommandItemClick;
+ button.Click += OnCommandItemClick;
+ button.Command = MiniCommand.Create(() => AppendLog($"Command, {button.Label}, {DescribePlacement(button)}"));
+ }
+
+ private void RemoveLastCommand(IList items, string bucketName)
+ {
+ if (items.Count == 0)
+ return;
+
+ var item = items[^1];
+ var label = item is AppBarButton button ? button.Label ?? "(unnamed)" : item.GetType().Name;
+
+ if (item is AppBarButton appBarButton)
+ appBarButton.Click -= OnCommandItemClick;
+
+ items.RemoveAt(items.Count - 1);
+
+ AppendLog($"{bucketName} -, {label}, {items.Count}");
+ RefreshState();
+ }
+
+ private static string DescribePlacement(AppBarButton button)
+ {
+ return button.IsInOverflow ? "overflow" : "primary";
+ }
+
+ private void AppendLog(string message)
+ {
+ _log.Add(message);
+
+ if (_log.Count > 12)
+ _log.RemoveAt(0);
+
+ EventLogText.Text = string.Join("\n", _log.Select((entry, index) => $"{index + 1,2}. {entry}"));
+ }
+ }
+}
diff --git a/samples/ControlCatalog/Pages/CommandBarPage.xaml.cs b/samples/ControlCatalog/Pages/CommandBarPage.xaml.cs
index b1e353dd38..7aaf6d22be 100644
--- a/samples/ControlCatalog/Pages/CommandBarPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/CommandBarPage.xaml.cs
@@ -22,6 +22,7 @@ namespace ControlCatalog.Pages
// Features
("Features", "Overflow Menu", "Secondary commands appear in an overflow popup. Configure visibility and sticky behavior.", () => new CommandBarOverflowPage()),
("Features", "Dynamic Overflow", "IsDynamicOverflowEnabled moves primary commands to overflow as space shrinks.", () => new CommandBarDynamicOverflowPage()),
+ ("Features", "Events & State", "Observe Opening, Opened, Closing, and Closed while tracking IsOpen, HasSecondaryCommands, and IsOverflowButtonVisible.", () => new CommandBarEventsPage()),
};
public CommandBarPage()
diff --git a/samples/ControlCatalog/Pages/ContentPage/ContentPageCommandBarPage.xaml b/samples/ControlCatalog/Pages/ContentPage/ContentPageCommandBarPage.xaml
index b5fed22feb..0a753d25ae 100644
--- a/samples/ControlCatalog/Pages/ContentPage/ContentPageCommandBarPage.xaml
+++ b/samples/ControlCatalog/Pages/ContentPage/ContentPageCommandBarPage.xaml
@@ -34,12 +34,32 @@
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/DrawerDemoPage.xaml.cs b/samples/ControlCatalog/Pages/DrawerDemoPage.xaml.cs
index d0c484cd2b..cd8aa9cd5b 100644
--- a/samples/ControlCatalog/Pages/DrawerDemoPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/DrawerDemoPage.xaml.cs
@@ -19,6 +19,9 @@ namespace ControlCatalog.Pages
("Features", "Compact Rail",
"CompactOverlay and CompactInline layout modes: a narrow icon rail is always visible and expands on open. Adjust rail width and open pane width.",
() => new DrawerPageCompactPage()),
+ ("Features", "Breakpoint",
+ "DrawerBreakpointLength: below the threshold the drawer switches to Overlay mode automatically. Resize the window or adjust the slider to see the layout switch in real time.",
+ () => new DrawerPageBreakpointPage()),
("Features", "Events",
"Opened, Closing, and Closed drawer events plus NavigatedTo and NavigatedFrom page lifecycle events. Enable 'Cancel next close' to prevent the drawer from closing.",
() => new DrawerPageEventsPage()),
diff --git a/samples/ControlCatalog/Pages/DrawerPage/DrawerPageBreakpointPage.xaml b/samples/ControlCatalog/Pages/DrawerPage/DrawerPageBreakpointPage.xaml
new file mode 100644
index 0000000000..0d1ccede79
--- /dev/null
+++ b/samples/ControlCatalog/Pages/DrawerPage/DrawerPageBreakpointPage.xaml
@@ -0,0 +1,113 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/DrawerPage/DrawerPageBreakpointPage.xaml.cs b/samples/ControlCatalog/Pages/DrawerPage/DrawerPageBreakpointPage.xaml.cs
new file mode 100644
index 0000000000..0da2eb2ed7
--- /dev/null
+++ b/samples/ControlCatalog/Pages/DrawerPage/DrawerPageBreakpointPage.xaml.cs
@@ -0,0 +1,84 @@
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Interactivity;
+
+namespace ControlCatalog.Pages
+{
+ public partial class DrawerPageBreakpointPage : UserControl
+ {
+ private bool _isLoaded;
+
+ public DrawerPageBreakpointPage()
+ {
+ InitializeComponent();
+ }
+
+ protected override void OnLoaded(RoutedEventArgs e)
+ {
+ base.OnLoaded(e);
+ _isLoaded = true;
+ DemoDrawer.PropertyChanged += OnDrawerPropertyChanged;
+ UpdateStatus();
+ }
+
+ protected override void OnUnloaded(RoutedEventArgs e)
+ {
+ base.OnUnloaded(e);
+ DemoDrawer.PropertyChanged -= OnDrawerPropertyChanged;
+ }
+
+ private void OnDrawerPropertyChanged(object? sender, Avalonia.AvaloniaPropertyChangedEventArgs e)
+ {
+ if (e.Property == DrawerPage.BoundsProperty)
+ UpdateStatus();
+ }
+
+ private void OnBreakpointChanged(object? sender, RangeBaseValueChangedEventArgs e)
+ {
+ if (!_isLoaded)
+ return;
+ var value = (int)e.NewValue;
+ DemoDrawer.DrawerBreakpointLength = value;
+ BreakpointText.Text = value.ToString();
+ UpdateStatus();
+ }
+
+ private void OnLayoutChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ if (!_isLoaded)
+ return;
+ DemoDrawer.DrawerLayoutBehavior = LayoutCombo.SelectedIndex switch
+ {
+ 0 => DrawerLayoutBehavior.Split,
+ 1 => DrawerLayoutBehavior.CompactInline,
+ 2 => DrawerLayoutBehavior.CompactOverlay,
+ _ => DrawerLayoutBehavior.Split
+ };
+ UpdateStatus();
+ }
+
+ private void OnMenuItemClick(object? sender, RoutedEventArgs e)
+ {
+ if (!_isLoaded || sender is not Button button)
+ return;
+ var item = button.Tag?.ToString() ?? "Home";
+ DetailTitleText.Text = item;
+ DetailPage.Header = item;
+ if (DemoDrawer.DrawerLayoutBehavior != DrawerLayoutBehavior.Split)
+ DemoDrawer.IsOpen = false;
+ }
+
+ private void UpdateStatus()
+ {
+ var isVertical = DemoDrawer.DrawerPlacement == DrawerPlacement.Top ||
+ DemoDrawer.DrawerPlacement == DrawerPlacement.Bottom;
+ var length = isVertical ? DemoDrawer.Bounds.Height : DemoDrawer.Bounds.Width;
+ var breakpoint = DemoDrawer.DrawerBreakpointLength;
+ WidthText.Text = $"{(isVertical ? "Height" : "Width")}: {(int)length} px";
+ var isOverlay = breakpoint > 0 && length > 0 && length < breakpoint;
+ ModeText.Text = isOverlay ?
+ "Mode: Overlay (below breakpoint)" :
+ $"Mode: {DemoDrawer.DrawerLayoutBehavior} (above breakpoint)";
+ }
+ }
+}
diff --git a/samples/ControlCatalog/Pages/DrawerPage/DrawerPageCustomizationPage.xaml b/samples/ControlCatalog/Pages/DrawerPage/DrawerPageCustomizationPage.xaml
index cba7837314..4987e8979e 100644
--- a/samples/ControlCatalog/Pages/DrawerPage/DrawerPageCustomizationPage.xaml
+++ b/samples/ControlCatalog/Pages/DrawerPage/DrawerPageCustomizationPage.xaml
@@ -118,6 +118,11 @@
Header="Customization"
DrawerLength="260"
DrawerHeaderBackground="{DynamicResource SystemControlHighlightAccentBrush}">
+
+
+
+
+
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/DrawerPage/EcoTrackerAppPage.xaml b/samples/ControlCatalog/Pages/DrawerPage/EcoTrackerAppPage.xaml
index 22320fbc8d..1e9106ccfe 100644
--- a/samples/ControlCatalog/Pages/DrawerPage/EcoTrackerAppPage.xaml
+++ b/samples/ControlCatalog/Pages/DrawerPage/EcoTrackerAppPage.xaml
@@ -52,9 +52,13 @@
-
+ M12 3C9 6 6 9 6 13C6 17.4 8.7 21 12 22C15.3 21 18 17.4 18 13C18 9 15 6 12 3Z
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/NavigationDemoPage.xaml.cs b/samples/ControlCatalog/Pages/NavigationDemoPage.xaml.cs
index b4ec1503bd..d65a43a6ad 100644
--- a/samples/ControlCatalog/Pages/NavigationDemoPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/NavigationDemoPage.xaml.cs
@@ -28,6 +28,9 @@ namespace ControlCatalog.Pages
// Data
("Data", "Pass Data", "Pass data during navigation via constructor arguments or DataContext.",
() => new NavigationPagePassDataPage()),
+ ("Data", "MVVM Navigation",
+ "Keep navigation decisions in view models by routing NavigationPage push and pop operations through a small INavigationService.",
+ () => new NavigationPageMvvmPage()),
// Features
("Features", "Attached Methods",
diff --git a/samples/ControlCatalog/Pages/NavigationPage/LAvenirAppPage.xaml.cs b/samples/ControlCatalog/Pages/NavigationPage/LAvenirAppPage.xaml.cs
index 6c4a67a473..beb0b2dccb 100644
--- a/samples/ControlCatalog/Pages/NavigationPage/LAvenirAppPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/NavigationPage/LAvenirAppPage.xaml.cs
@@ -59,6 +59,26 @@ public partial class LAvenirAppPage : UserControl
_infoPanel.IsVisible = Bounds.Width >= 650;
}
+ void ApplyRootNavigationBarAppearance()
+ {
+ if (_navPage == null)
+ return;
+
+ _navPage.Background = new SolidColorBrush(BgLight);
+ _navPage.Resources["NavigationBarBackground"] = new SolidColorBrush(BgLight);
+ _navPage.Resources["NavigationBarForeground"] = new SolidColorBrush(TextDark);
+ }
+
+ void ApplyDetailNavigationBarAppearance()
+ {
+ if (_navPage == null)
+ return;
+
+ _navPage.Background = new SolidColorBrush(BgDark);
+ _navPage.Resources["NavigationBarBackground"] = new SolidColorBrush(BgDark);
+ _navPage.Resources["NavigationBarForeground"] = Brushes.White;
+ }
+
TabbedPage BuildMenuTabbedPage()
{
var tp = new TabbedPage
@@ -92,6 +112,7 @@ public partial class LAvenirAppPage : UserControl
VerticalAlignment = VerticalAlignment.Center,
TextAlignment = TextAlignment.Center,
};
+ ApplyRootNavigationBarAppearance();
NavigationPage.SetTopCommandBar(tp, new Button
{
@@ -119,7 +140,7 @@ public partial class LAvenirAppPage : UserControl
Content = menuView,
Background = new SolidColorBrush(BgLight),
Header = "Menu",
- Icon = "M11 9H9V2H7v7H5V2H3v7c0 2.12 1.66 3.84 3.75 3.97V22h2.5v-9.03C11.34 12.84 13 11.12 13 9V2h-2v7zm5-3v8h2.5v8H21V2c-2.76 0-5 2.24-5 4z",
+ Icon = Geometry.Parse("M11 9H9V2H7v7H5V2H3v7c0 2.12 1.66 3.84 3.75 3.97V22h2.5v-9.03C11.34 12.84 13 11.12 13 9V2h-2v7zm5-3v8h2.5v8H21V2c-2.76 0-5 2.24-5 4z"),
};
var reservationsPage = new ContentPage
@@ -127,7 +148,7 @@ public partial class LAvenirAppPage : UserControl
Content = new LAvenirReservationsView(),
Background = new SolidColorBrush(BgLight),
Header = "Reservations",
- Icon = "M19 3h-1V1h-2v2H8V1H6v2H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11zM9 10H7v2h2v-2zm4 0h-2v2h2v-2zm4 0h-2v2h2v-2z",
+ Icon = Geometry.Parse("M19 3h-1V1h-2v2H8V1H6v2H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11zM9 10H7v2h2v-2zm4 0h-2v2h2v-2zm4 0h-2v2h2v-2z"),
};
var profilePage = new ContentPage
@@ -135,7 +156,7 @@ public partial class LAvenirAppPage : UserControl
Content = new LAvenirProfileView(),
Background = new SolidColorBrush(BgLight),
Header = "Profile",
- Icon = "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z",
+ Icon = Geometry.Parse("M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z"),
};
tp.Pages = new ObservableCollection { menuPage, reservationsPage, profilePage };
@@ -144,7 +165,8 @@ public partial class LAvenirAppPage : UserControl
async void PushDishDetail(string name, string price, string description, string imageFile)
{
- if (_navPage == null) return;
+ if (_navPage == null)
+ return;
var detail = new ContentPage
{
@@ -153,22 +175,19 @@ public partial class LAvenirAppPage : UserControl
Header = name,
};
NavigationPage.SetBottomCommandBar(detail, BuildFloatingBar(price));
-
- _navPage.Background = new SolidColorBrush(BgDark);
- _navPage.Resources["NavigationBarBackground"] = new SolidColorBrush(BgDark);
- _navPage.Resources["NavigationBarForeground"] = Brushes.White;
-
- detail.NavigatedFrom += (_, _) =>
+ detail.Navigating += args =>
{
- if (_navPage != null)
- {
- _navPage.Background = new SolidColorBrush(BgLight);
- _navPage.Resources["NavigationBarBackground"] = new SolidColorBrush(BgLight);
- _navPage.Resources["NavigationBarForeground"] = new SolidColorBrush(TextDark);
- }
+ if (args.NavigationType == NavigationType.Pop)
+ ApplyRootNavigationBarAppearance();
+
+ return Task.CompletedTask;
};
+ ApplyDetailNavigationBarAppearance();
await _navPage.PushAsync(detail);
+
+ if (!ReferenceEquals(_navPage.CurrentPage, detail))
+ ApplyRootNavigationBarAppearance();
}
Border BuildFloatingBar(string price)
diff --git a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageAppearancePage.xaml.cs b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageAppearancePage.xaml.cs
index 52e667b0bf..147dbe1f75 100644
--- a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageAppearancePage.xaml.cs
+++ b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageAppearancePage.xaml.cs
@@ -8,6 +8,7 @@ namespace ControlCatalog.Pages
{
public partial class NavigationPageAppearancePage : UserControl
{
+ private bool _initialized;
private int _pageCount;
private int _backButtonStyle;
@@ -19,6 +20,10 @@ namespace ControlCatalog.Pages
private async void OnLoaded(object? sender, RoutedEventArgs e)
{
+ if (_initialized)
+ return;
+
+ _initialized = true;
await DemoNav.PushAsync(NavigationDemoHelper.MakePage("Appearance", "Change bar properties using the options panel.", 0), null);
}
diff --git a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageAttachedMethodsPage.xaml.cs b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageAttachedMethodsPage.xaml.cs
index 5a868046f3..01aef5385b 100644
--- a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageAttachedMethodsPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageAttachedMethodsPage.xaml.cs
@@ -8,6 +8,7 @@ namespace ControlCatalog.Pages
{
public partial class NavigationPageAttachedMethodsPage : UserControl
{
+ private bool _initialized;
private int _pageCount;
public NavigationPageAttachedMethodsPage()
@@ -18,6 +19,10 @@ namespace ControlCatalog.Pages
private async void OnLoaded(object? sender, RoutedEventArgs e)
{
+ if (_initialized)
+ return;
+
+ _initialized = true;
await DemoNav.PushAsync(new ContentPage
{
Header = "Root Page",
diff --git a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageBackButtonPage.xaml.cs b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageBackButtonPage.xaml.cs
index 347b8e8010..5dd438750f 100644
--- a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageBackButtonPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageBackButtonPage.xaml.cs
@@ -8,6 +8,7 @@ namespace ControlCatalog.Pages
{
public partial class NavigationPageBackButtonPage : UserControl
{
+ private bool _initialized;
private int _pushCount;
public NavigationPageBackButtonPage()
@@ -18,6 +19,10 @@ namespace ControlCatalog.Pages
private async void OnLoaded(object? sender, RoutedEventArgs e)
{
+ if (_initialized)
+ return;
+
+ _initialized = true;
DemoNav.Pushed += (s, ev) => AddLog($"Pushed: \"{ev.Page?.Header}\"");
DemoNav.Popped += (s, ev) => AddLog($"Popped: \"{ev.Page?.Header}\"");
diff --git a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageEventsPage.xaml b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageEventsPage.xaml
index 74d0e58371..904d4310cc 100644
--- a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageEventsPage.xaml
+++ b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageEventsPage.xaml
@@ -51,6 +51,13 @@
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageEventsPage.xaml.cs b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageEventsPage.xaml.cs
index faa47f6eda..c1c439f6a4 100644
--- a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageEventsPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageEventsPage.xaml.cs
@@ -7,6 +7,7 @@ namespace ControlCatalog.Pages
{
public partial class NavigationPageEventsPage : UserControl
{
+ private bool _initialized;
private int _pageCount;
public NavigationPageEventsPage()
@@ -17,6 +18,10 @@ namespace ControlCatalog.Pages
private async void OnLoaded(object? sender, RoutedEventArgs e)
{
+ if (_initialized)
+ return;
+
+ _initialized = true;
DemoNav.Pushed += (s, ev) => AddLog($"Pushed → {ev.Page?.Header}");
DemoNav.Popped += (s, ev) => AddLog($"Popped ← {ev.Page?.Header}");
DemoNav.PoppedToRoot += (s, ev) => AddLog("PoppedToRoot");
diff --git a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageFirstLookPage.xaml.cs b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageFirstLookPage.xaml.cs
index 32b2e8927d..f9a6f9aa41 100644
--- a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageFirstLookPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageFirstLookPage.xaml.cs
@@ -6,6 +6,7 @@ namespace ControlCatalog.Pages
{
public partial class NavigationPageFirstLookPage : UserControl
{
+ private bool _initialized;
private int _pageCount;
public NavigationPageFirstLookPage()
@@ -16,6 +17,10 @@ namespace ControlCatalog.Pages
private async void OnLoaded(object? sender, RoutedEventArgs e)
{
+ if (_initialized)
+ return;
+
+ _initialized = true;
await DemoNav.PushAsync(NavigationDemoHelper.MakePage("Home", "Welcome!\nUse the buttons to push and pop pages.", 0), null);
UpdateStatus();
}
diff --git a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageGesturePage.xaml.cs b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageGesturePage.xaml.cs
index ff711f3a63..e185208119 100644
--- a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageGesturePage.xaml.cs
+++ b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageGesturePage.xaml.cs
@@ -1,18 +1,27 @@
+using System.Linq;
using Avalonia.Controls;
+using Avalonia.Input.GestureRecognizers;
using Avalonia.Interactivity;
namespace ControlCatalog.Pages
{
public partial class NavigationPageGesturePage : UserControl
{
+ private bool _initialized;
+
public NavigationPageGesturePage()
{
InitializeComponent();
+ EnableMouseSwipeGesture(DemoNav);
Loaded += OnLoaded;
}
private async void OnLoaded(object? sender, RoutedEventArgs e)
{
+ if (_initialized)
+ return;
+
+ _initialized = true;
await DemoNav.PushAsync(NavigationDemoHelper.MakePage("Page 1", "← Drag from the left edge to go back", 0), null);
await DemoNav.PushAsync(NavigationDemoHelper.MakePage("Page 2", "← Drag from the left edge to go back", 1), null);
await DemoNav.PushAsync(NavigationDemoHelper.MakePage("Page 3", "← Drag from the left edge to go back", 2), null);
@@ -43,5 +52,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/NavigationPage/NavigationPageInteractiveHeaderPage.xaml.cs b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageInteractiveHeaderPage.xaml.cs
index 1dc724128b..6a56beabe4 100644
--- a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageInteractiveHeaderPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageInteractiveHeaderPage.xaml.cs
@@ -37,6 +37,7 @@ namespace ControlCatalog.Pages
];
private readonly ObservableCollection _filteredItems = new(AllContacts);
+ private bool _initialized;
private string _searchText = "";
public NavigationPageInteractiveHeaderPage()
@@ -47,6 +48,10 @@ namespace ControlCatalog.Pages
private async void OnLoaded(object? sender, RoutedEventArgs e)
{
+ if (_initialized)
+ return;
+
+ _initialized = true;
var headerGrid = new Grid
{
ColumnDefinitions = new ColumnDefinitions("*, Auto"),
diff --git a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageModalPage.xaml.cs b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageModalPage.xaml.cs
index 1dd717234e..81ed6d5c1f 100644
--- a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageModalPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageModalPage.xaml.cs
@@ -7,6 +7,7 @@ namespace ControlCatalog.Pages
{
public partial class NavigationPageModalPage : UserControl
{
+ private bool _initialized;
private int _modalCount;
public NavigationPageModalPage()
@@ -17,6 +18,10 @@ namespace ControlCatalog.Pages
private async void OnLoaded(object? sender, RoutedEventArgs e)
{
+ if (_initialized)
+ return;
+
+ _initialized = true;
await DemoNav.PushAsync(NavigationDemoHelper.MakePage("Home", "Use Push Modal to show a modal on top.", 0), null);
}
diff --git a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageModalTransitionsPage.xaml.cs b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageModalTransitionsPage.xaml.cs
index ac4e8c985d..2c77798570 100644
--- a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageModalTransitionsPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageModalTransitionsPage.xaml.cs
@@ -20,6 +20,7 @@ namespace ControlCatalog.Pages
];
private int _modalCount;
+ private bool _initialized;
public NavigationPageModalTransitionsPage()
{
@@ -29,6 +30,13 @@ namespace ControlCatalog.Pages
private async void OnLoaded(object? sender, RoutedEventArgs e)
{
+ if (_initialized)
+ {
+ UpdateTransition();
+ return;
+ }
+
+ _initialized = true;
await DemoNav.PushAsync(new ContentPage
{
Header = "Modal Transitions",
diff --git a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageMvvmNavigation.cs b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageMvvmNavigation.cs
new file mode 100644
index 0000000000..c6262ca9f4
--- /dev/null
+++ b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageMvvmNavigation.cs
@@ -0,0 +1,95 @@
+using System;
+using System.Threading.Tasks;
+using Avalonia.Controls;
+using MiniMvvm;
+
+namespace ControlCatalog.Pages
+{
+ internal interface ISampleNavigationService
+ {
+ event EventHandler? StateChanged;
+
+ Task NavigateToAsync(ViewModelBase viewModel);
+
+ Task GoBackAsync();
+
+ Task PopToRootAsync();
+ }
+
+ internal interface ISamplePageFactory
+ {
+ ContentPage CreatePage(ViewModelBase viewModel);
+ }
+
+ internal sealed class NavigationStateChangedEventArgs : EventArgs
+ {
+ public NavigationStateChangedEventArgs(string currentPageHeader, int navigationDepth, string lastAction)
+ {
+ CurrentPageHeader = currentPageHeader;
+ NavigationDepth = navigationDepth;
+ LastAction = lastAction;
+ }
+
+ public string CurrentPageHeader { get; }
+
+ public int NavigationDepth { get; }
+
+ public string LastAction { get; }
+ }
+
+ internal sealed class SampleNavigationService : ISampleNavigationService
+ {
+ private readonly NavigationPage _navigationPage;
+ private readonly ISamplePageFactory _pageFactory;
+
+ public SampleNavigationService(NavigationPage navigationPage, ISamplePageFactory pageFactory)
+ {
+ _navigationPage = navigationPage;
+ _pageFactory = pageFactory;
+
+ _navigationPage.Pushed += (_, e) => PublishState($"Pushed {e.Page?.Header}");
+ _navigationPage.Popped += (_, e) => PublishState($"Popped {e.Page?.Header}");
+ _navigationPage.PoppedToRoot += (_, _) => PublishState("Popped to root");
+ }
+
+ public event EventHandler? StateChanged;
+
+ public async Task NavigateToAsync(ViewModelBase viewModel)
+ {
+ var page = _pageFactory.CreatePage(viewModel);
+ await _navigationPage.PushAsync(page);
+ }
+
+ public async Task GoBackAsync()
+ {
+ if (_navigationPage.NavigationStack.Count <= 1)
+ {
+ PublishState("Already at the root page");
+ return;
+ }
+
+ await _navigationPage.PopAsync();
+ }
+
+ public async Task PopToRootAsync()
+ {
+ if (_navigationPage.NavigationStack.Count <= 1)
+ {
+ PublishState("Already at the root page");
+ return;
+ }
+
+ await _navigationPage.PopToRootAsync();
+ }
+
+ private void PublishState(string lastAction)
+ {
+ var header = _navigationPage.CurrentPage?.Header?.ToString() ?? "None";
+
+ StateChanged?.Invoke(this, new NavigationStateChangedEventArgs(
+ header,
+ _navigationPage.NavigationStack.Count,
+ lastAction));
+ }
+ }
+}
diff --git a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageMvvmPage.xaml b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageMvvmPage.xaml
new file mode 100644
index 0000000000..7204b78a4d
--- /dev/null
+++ b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageMvvmPage.xaml
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageMvvmPage.xaml.cs b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageMvvmPage.xaml.cs
new file mode 100644
index 0000000000..6d6c18bbd6
--- /dev/null
+++ b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageMvvmPage.xaml.cs
@@ -0,0 +1,32 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+
+namespace ControlCatalog.Pages
+{
+ public partial class NavigationPageMvvmPage : UserControl
+ {
+ private readonly NavigationPageMvvmShellViewModel _viewModel;
+ private bool _initialized;
+
+ public NavigationPageMvvmPage()
+ {
+ InitializeComponent();
+
+ ISamplePageFactory pageFactory = new SamplePageFactory();
+ ISampleNavigationService navigationService = new SampleNavigationService(DemoNav, pageFactory);
+ _viewModel = new NavigationPageMvvmShellViewModel(navigationService);
+ DataContext = _viewModel;
+
+ Loaded += OnLoaded;
+ }
+
+ private async void OnLoaded(object? sender, RoutedEventArgs e)
+ {
+ if (_initialized)
+ return;
+
+ _initialized = true;
+ await _viewModel.InitializeAsync();
+ }
+ }
+}
diff --git a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageMvvmPageFactory.cs b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageMvvmPageFactory.cs
new file mode 100644
index 0000000000..f9a688d95c
--- /dev/null
+++ b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageMvvmPageFactory.cs
@@ -0,0 +1,252 @@
+using System;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+using Avalonia.Layout;
+using Avalonia.Media;
+using MiniMvvm;
+
+namespace ControlCatalog.Pages
+{
+ internal sealed class SamplePageFactory : ISamplePageFactory
+ {
+ public ContentPage CreatePage(ViewModelBase viewModel) =>
+ viewModel switch
+ {
+ WorkspaceViewModel workspace => CreateWorkspacePage(workspace),
+ ProjectDetailViewModel detail => CreateProjectDetailPage(detail),
+ ProjectActivityViewModel activity => CreateProjectActivityPage(activity),
+ _ => throw new InvalidOperationException($"Unsupported view model: {viewModel.GetType().Name}")
+ };
+
+ private static ContentPage CreateWorkspacePage(WorkspaceViewModel viewModel)
+ {
+ var stack = new StackPanel
+ {
+ Margin = new Thickness(20),
+ Spacing = 14,
+ };
+
+ stack.Children.Add(new TextBlock
+ {
+ Text = viewModel.Title,
+ FontSize = 24,
+ FontWeight = FontWeight.Bold,
+ });
+ stack.Children.Add(new TextBlock
+ {
+ Text = viewModel.Description,
+ FontSize = 13,
+ Opacity = 0.75,
+ TextWrapping = TextWrapping.Wrap,
+ });
+
+ stack.Children.Add(new ItemsControl
+ {
+ ItemsSource = viewModel.Projects,
+ ItemTemplate = new FuncDataTemplate((item, _) =>
+ {
+ if (item == null)
+ return new TextBlock();
+
+ var accentBrush = new SolidColorBrush(item.AccentColor);
+ var statusBadge = new Border
+ {
+ Background = accentBrush,
+ CornerRadius = new CornerRadius(999),
+ Padding = new Thickness(10, 4),
+ Child = new TextBlock
+ {
+ Text = item.Status,
+ Foreground = Brushes.White,
+ FontSize = 11,
+ FontWeight = FontWeight.SemiBold,
+ }
+ };
+ DockPanel.SetDock(statusBadge, Dock.Right);
+
+ var header = new DockPanel();
+ header.Children.Add(statusBadge);
+ header.Children.Add(new TextBlock
+ {
+ Text = item.Name,
+ FontSize = 17,
+ FontWeight = FontWeight.SemiBold,
+ });
+
+ return new Border
+ {
+ Background = new SolidColorBrush(Color.FromArgb(20, item.AccentColor.R, item.AccentColor.G, item.AccentColor.B)),
+ BorderBrush = accentBrush,
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(8),
+ Padding = new Thickness(14),
+ Margin = new Thickness(0, 0, 0, 8),
+ Child = new StackPanel
+ {
+ Spacing = 8,
+ Children =
+ {
+ header,
+ new TextBlock
+ {
+ Text = item.Summary,
+ FontSize = 13,
+ Opacity = 0.72,
+ TextWrapping = TextWrapping.Wrap,
+ },
+ new TextBlock
+ {
+ Text = $"Owner: {item.Owner} • Next: {item.NextMilestone}",
+ FontSize = 12,
+ Opacity = 0.6,
+ TextWrapping = TextWrapping.Wrap,
+ },
+ new Button
+ {
+ Content = "Open Project",
+ HorizontalAlignment = HorizontalAlignment.Left,
+ Command = item.OpenCommand,
+ }
+ }
+ }
+ };
+ })
+ });
+
+ var page = new ContentPage
+ {
+ Header = "Workspace",
+ Content = new ScrollViewer { Content = stack },
+ HorizontalContentAlignment = HorizontalAlignment.Stretch,
+ VerticalContentAlignment = VerticalAlignment.Stretch,
+ };
+
+ NavigationPage.SetHasBackButton(page, false);
+ return page;
+ }
+
+ private static ContentPage CreateProjectDetailPage(ProjectDetailViewModel viewModel)
+ {
+ var accentBrush = new SolidColorBrush(viewModel.AccentColor);
+ var panel = new StackPanel
+ {
+ Margin = new Thickness(24, 20),
+ Spacing = 12,
+ };
+
+ panel.Children.Add(new Border
+ {
+ Background = accentBrush,
+ CornerRadius = new CornerRadius(999),
+ Padding = new Thickness(12, 5),
+ HorizontalAlignment = HorizontalAlignment.Left,
+ Child = new TextBlock
+ {
+ Text = viewModel.Status,
+ Foreground = Brushes.White,
+ FontSize = 11,
+ FontWeight = FontWeight.SemiBold,
+ }
+ });
+ panel.Children.Add(new TextBlock
+ {
+ Text = viewModel.Name,
+ FontSize = 26,
+ FontWeight = FontWeight.Bold,
+ });
+ panel.Children.Add(new TextBlock
+ {
+ Text = viewModel.Summary,
+ FontSize = 14,
+ Opacity = 0.78,
+ TextWrapping = TextWrapping.Wrap,
+ });
+ panel.Children.Add(new TextBlock
+ {
+ Text = $"Owner: {viewModel.Owner}",
+ FontSize = 13,
+ Opacity = 0.68,
+ });
+ panel.Children.Add(new TextBlock
+ {
+ Text = $"Next milestone: {viewModel.NextMilestone}",
+ FontSize = 13,
+ Opacity = 0.68,
+ TextWrapping = TextWrapping.Wrap,
+ });
+ panel.Children.Add(new Separator { Margin = new Thickness(0, 4) });
+ panel.Children.Add(new TextBlock
+ {
+ Text = "This page is resolved by SamplePageFactory from a ProjectDetailViewModel. The view model only requests navigation through ISampleNavigationService.",
+ FontSize = 12,
+ Opacity = 0.7,
+ TextWrapping = TextWrapping.Wrap,
+ });
+ panel.Children.Add(new Button
+ {
+ Content = "Open Activity",
+ HorizontalAlignment = HorizontalAlignment.Left,
+ Command = viewModel.OpenActivityCommand,
+ });
+
+ return new ContentPage
+ {
+ Header = viewModel.Name,
+ Background = new SolidColorBrush(Color.FromArgb(18, viewModel.AccentColor.R, viewModel.AccentColor.G, viewModel.AccentColor.B)),
+ Content = new ScrollViewer { Content = panel },
+ HorizontalContentAlignment = HorizontalAlignment.Stretch,
+ VerticalContentAlignment = VerticalAlignment.Stretch,
+ };
+ }
+
+ private static ContentPage CreateProjectActivityPage(ProjectActivityViewModel viewModel)
+ {
+ var panel = new StackPanel
+ {
+ Margin = new Thickness(24, 20),
+ Spacing = 10,
+ };
+
+ panel.Children.Add(new TextBlock
+ {
+ Text = "Activity Timeline",
+ FontSize = 24,
+ FontWeight = FontWeight.Bold,
+ });
+ panel.Children.Add(new TextBlock
+ {
+ Text = $"Recent updates for {viewModel.Name}. This page was opened from a command on the detail view model.",
+ FontSize = 13,
+ Opacity = 0.74,
+ TextWrapping = TextWrapping.Wrap,
+ });
+
+ foreach (var item in viewModel.Items)
+ {
+ panel.Children.Add(new Border
+ {
+ Background = new SolidColorBrush(Color.FromArgb(14, viewModel.AccentColor.R, viewModel.AccentColor.G, viewModel.AccentColor.B)),
+ BorderBrush = new SolidColorBrush(Color.FromArgb(80, viewModel.AccentColor.R, viewModel.AccentColor.G, viewModel.AccentColor.B)),
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(6),
+ Padding = new Thickness(12, 10),
+ Child = new TextBlock
+ {
+ Text = item,
+ FontSize = 13,
+ TextWrapping = TextWrapping.Wrap,
+ }
+ });
+ }
+
+ return new ContentPage
+ {
+ Header = "Activity",
+ Content = new ScrollViewer { Content = panel },
+ HorizontalContentAlignment = HorizontalAlignment.Stretch,
+ VerticalContentAlignment = VerticalAlignment.Stretch,
+ };
+ }
+ }
+}
diff --git a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageMvvmViewModels.cs b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageMvvmViewModels.cs
new file mode 100644
index 0000000000..b2e8f5623f
--- /dev/null
+++ b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageMvvmViewModels.cs
@@ -0,0 +1,238 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Avalonia.Media;
+using MiniMvvm;
+
+namespace ControlCatalog.Pages
+{
+ public sealed class NavigationPageMvvmShellViewModel : ViewModelBase
+ {
+ private readonly ISampleNavigationService _navigationService;
+ private string _currentPageHeader = "Not initialized";
+ private string _lastAction = "Waiting for first load";
+ private int _navigationDepth;
+ private ProjectCardViewModel? _selectedProject;
+
+ internal NavigationPageMvvmShellViewModel(ISampleNavigationService navigationService)
+ {
+ _navigationService = navigationService;
+ _navigationService.StateChanged += OnStateChanged;
+
+ Workspace = new WorkspaceViewModel(CreateProjects(navigationService));
+ SelectedProject = Workspace.Projects[0];
+
+ OpenSelectedProjectCommand = MiniCommand.CreateFromTask(OpenSelectedProjectAsync);
+ GoBackCommand = MiniCommand.CreateFromTask(_navigationService.GoBackAsync);
+ PopToRootCommand = MiniCommand.CreateFromTask(_navigationService.PopToRootAsync);
+ }
+
+ internal WorkspaceViewModel Workspace { get; }
+
+ public IReadOnlyList Projects => Workspace.Projects;
+
+ public MiniCommand OpenSelectedProjectCommand { get; }
+
+ public MiniCommand GoBackCommand { get; }
+
+ public MiniCommand PopToRootCommand { get; }
+
+ public string CurrentPageHeader
+ {
+ get => _currentPageHeader;
+ set => this.RaiseAndSetIfChanged(ref _currentPageHeader, value);
+ }
+
+ public int NavigationDepth
+ {
+ get => _navigationDepth;
+ set => this.RaiseAndSetIfChanged(ref _navigationDepth, value);
+ }
+
+ public string LastAction
+ {
+ get => _lastAction;
+ set => this.RaiseAndSetIfChanged(ref _lastAction, value);
+ }
+
+ public ProjectCardViewModel? SelectedProject
+ {
+ get => _selectedProject;
+ set => this.RaiseAndSetIfChanged(ref _selectedProject, value);
+ }
+
+ public Task InitializeAsync() => _navigationService.NavigateToAsync(Workspace);
+
+ private async Task OpenSelectedProjectAsync()
+ {
+ if (SelectedProject == null)
+ return;
+
+ await SelectedProject.OpenCommandAsync();
+ }
+
+ private void OnStateChanged(object? sender, NavigationStateChangedEventArgs e)
+ {
+ CurrentPageHeader = e.CurrentPageHeader;
+ NavigationDepth = e.NavigationDepth;
+ LastAction = e.LastAction;
+ }
+
+ private static IReadOnlyList CreateProjects(ISampleNavigationService navigationService) =>
+ new[]
+ {
+ new ProjectCardViewModel(
+ "Release Radar",
+ "Marta Collins",
+ "Ready for QA",
+ "Coordinate the 11.0 release checklist and lock down the final regression window.",
+ "Freeze build on Friday",
+ Color.Parse("#0063B1"),
+ navigationService,
+ new[]
+ {
+ "Release notes draft updated with accessibility fixes.",
+ "Package validation finished for desktop artifacts.",
+ "Remaining task, confirm browser smoke test coverage."
+ }),
+ new ProjectCardViewModel(
+ "Support Console",
+ "Jae Kim",
+ "Active Sprint",
+ "Consolidate customer incidents into a triage board and route them to platform owners.",
+ "Triage review in 2 hours",
+ Color.Parse("#0F7B0F"),
+ navigationService,
+ new[]
+ {
+ "Five customer reports grouped under input routing.",
+ "Hotfix candidate approved for preview branch.",
+ "Awaiting macOS verification on native embed scenarios."
+ }),
+ new ProjectCardViewModel(
+ "Docs Refresh",
+ "Anika Patel",
+ "Needs Review",
+ "Refresh navigation samples and walkthrough docs so the gallery matches the current API.",
+ "Sample review tomorrow",
+ Color.Parse("#8E562E"),
+ navigationService,
+ new[]
+ {
+ "NavigationPage sample matrix reviewed with design.",
+ "MVVM walkthrough draft linked from the docs backlog.",
+ "Outstanding task, capture one more screenshot for drawer navigation."
+ }),
+ };
+ }
+
+ internal sealed class WorkspaceViewModel : ViewModelBase
+ {
+ public WorkspaceViewModel(IReadOnlyList projects)
+ {
+ Projects = projects;
+ }
+
+ public string Title => "Team Workspace";
+
+ public string Description =>
+ "Each card is a project view model with its own command. The command asks ISampleNavigationService to navigate with the next view model, and SamplePageFactory resolves the matching ContentPage.";
+
+ public IReadOnlyList Projects { get; }
+ }
+
+ public sealed class ProjectCardViewModel : ViewModelBase
+ {
+ private readonly ISampleNavigationService _navigationService;
+
+ internal ProjectCardViewModel(
+ string name,
+ string owner,
+ string status,
+ string summary,
+ string nextMilestone,
+ Color accentColor,
+ ISampleNavigationService navigationService,
+ IReadOnlyList activityItems)
+ {
+ Name = name;
+ Owner = owner;
+ Status = status;
+ Summary = summary;
+ NextMilestone = nextMilestone;
+ AccentColor = accentColor;
+ ActivityItems = activityItems;
+ _navigationService = navigationService;
+
+ OpenCommand = MiniCommand.CreateFromTask(OpenCommandAsync);
+ }
+
+ public string Name { get; }
+
+ public string Owner { get; }
+
+ public string Status { get; }
+
+ public string Summary { get; }
+
+ public string NextMilestone { get; }
+
+ public Color AccentColor { get; }
+
+ public IReadOnlyList ActivityItems { get; }
+
+ public MiniCommand OpenCommand { get; }
+
+ public Task OpenCommandAsync()
+ {
+ return _navigationService.NavigateToAsync(new ProjectDetailViewModel(this, _navigationService));
+ }
+ }
+
+ internal sealed class ProjectDetailViewModel : ViewModelBase
+ {
+ private readonly ProjectCardViewModel _project;
+ private readonly ISampleNavigationService _navigationService;
+
+ public ProjectDetailViewModel(ProjectCardViewModel project, ISampleNavigationService navigationService)
+ {
+ _project = project;
+ _navigationService = navigationService;
+ OpenActivityCommand = MiniCommand.CreateFromTask(OpenActivityAsync);
+ }
+
+ public string Name => _project.Name;
+
+ public string Owner => _project.Owner;
+
+ public string Status => _project.Status;
+
+ public string Summary => _project.Summary;
+
+ public string NextMilestone => _project.NextMilestone;
+
+ public Color AccentColor => _project.AccentColor;
+
+ public MiniCommand OpenActivityCommand { get; }
+
+ private Task OpenActivityAsync()
+ {
+ return _navigationService.NavigateToAsync(new ProjectActivityViewModel(_project));
+ }
+ }
+
+ internal sealed class ProjectActivityViewModel : ViewModelBase
+ {
+ public ProjectActivityViewModel(ProjectCardViewModel project)
+ {
+ Name = project.Name;
+ AccentColor = project.AccentColor;
+ Items = project.ActivityItems;
+ }
+
+ public string Name { get; }
+
+ public Color AccentColor { get; }
+
+ public IReadOnlyList Items { get; }
+ }
+}
diff --git a/samples/ControlCatalog/Pages/NavigationPage/NavigationPagePassDataPage.xaml.cs b/samples/ControlCatalog/Pages/NavigationPage/NavigationPagePassDataPage.xaml.cs
index 316de594eb..2d486c29f8 100644
--- a/samples/ControlCatalog/Pages/NavigationPage/NavigationPagePassDataPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/NavigationPage/NavigationPagePassDataPage.xaml.cs
@@ -19,6 +19,7 @@ namespace ControlCatalog.Pages
new("Emma Brown", "UX Researcher", "Germany", Color.Parse("#F44336")),
};
+ private bool _initialized;
private bool _isLoaded;
public NavigationPagePassDataPage()
@@ -31,6 +32,11 @@ namespace ControlCatalog.Pages
{
_isLoaded = true;
+ if (_initialized)
+ return;
+
+ _initialized = true;
+
DemoNav.Pushed += (s, ev) => AppendNavigationLog($"Pushed → {ev.Page?.Header}");
DemoNav.Popped += (s, ev) => AppendNavigationLog($"Popped ← {ev.Page?.Header}");
diff --git a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageScrollAwarePage.xaml.cs b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageScrollAwarePage.xaml.cs
index 2aeb1052bf..f4c550acd3 100644
--- a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageScrollAwarePage.xaml.cs
+++ b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageScrollAwarePage.xaml.cs
@@ -21,8 +21,10 @@ namespace ControlCatalog.Pages
}
private IDisposable? _scrollSubscription;
+ private ScrollViewer? _scrollViewer;
private double _lastScrollY;
private double _currentTranslateY;
+ private bool _initialized;
public NavigationPageScrollAwarePage()
{
@@ -33,12 +35,21 @@ namespace ControlCatalog.Pages
private async void OnLoaded(object? sender, RoutedEventArgs e)
{
- var scrollViewer = new ScrollViewer { Content = BuildLongContent() };
+ if (_initialized)
+ {
+ if (_scrollViewer != null)
+ Dispatcher.UIThread.Post(() => AttachScrollWatcher(_scrollViewer), DispatcherPriority.Loaded);
+ return;
+ }
+
+ _initialized = true;
+
+ _scrollViewer = new ScrollViewer { Content = BuildLongContent() };
var rootPage = new ContentPage
{
Header = "Scroll to Hide Bar",
- Content = scrollViewer,
+ Content = _scrollViewer,
HorizontalContentAlignment = HorizontalAlignment.Stretch,
VerticalContentAlignment = VerticalAlignment.Stretch,
};
@@ -46,13 +57,18 @@ namespace ControlCatalog.Pages
NavigationPage.SetBarLayoutBehavior(rootPage, BarLayoutBehavior.Overlay);
await DemoNav.PushAsync(rootPage, null);
- Dispatcher.UIThread.Post(() => AttachScrollWatcher(scrollViewer), DispatcherPriority.Loaded);
+ Dispatcher.UIThread.Post(() => AttachScrollWatcher(_scrollViewer), DispatcherPriority.Loaded);
}
private void OnUnloaded(object? sender, RoutedEventArgs e) => DetachScrollWatcher();
- private void AttachScrollWatcher(ScrollViewer sv)
+ private void AttachScrollWatcher(ScrollViewer? sv)
{
+ if (sv == null)
+ return;
+
+ DetachScrollWatcher();
+
var navBar = DemoNav.GetVisualDescendants().OfType()
.FirstOrDefault(b => b.Name == "PART_NavigationBar");
if (navBar == null)
diff --git a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageStackPage.xaml.cs b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageStackPage.xaml.cs
index 1aa64e996a..a7c78d9e48 100644
--- a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageStackPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageStackPage.xaml.cs
@@ -9,6 +9,7 @@ namespace ControlCatalog.Pages
{
public partial class NavigationPageStackPage : UserControl
{
+ private bool _initialized;
private int _pageCount;
public NavigationPageStackPage()
@@ -19,6 +20,10 @@ namespace ControlCatalog.Pages
private async void OnLoaded(object? sender, RoutedEventArgs e)
{
+ if (_initialized)
+ return;
+
+ _initialized = true;
DemoNav.Pushed += (s, ev) => RefreshStack();
DemoNav.Popped += (s, ev) => RefreshStack();
DemoNav.PoppedToRoot += (s, ev) => RefreshStack();
diff --git a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageTitlePage.xaml.cs b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageTitlePage.xaml.cs
index d533929b13..521cf55f04 100644
--- a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageTitlePage.xaml.cs
+++ b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageTitlePage.xaml.cs
@@ -8,6 +8,7 @@ namespace ControlCatalog.Pages
{
public partial class NavigationPageTitlePage : UserControl
{
+ private bool _initialized;
private int _pageCount;
public NavigationPageTitlePage()
@@ -18,6 +19,10 @@ namespace ControlCatalog.Pages
private async void OnLoaded(object? sender, RoutedEventArgs e)
{
+ if (_initialized)
+ return;
+
+ _initialized = true;
await DemoNav.PushAsync(NavigationDemoHelper.MakePage("Home", "Choose a header type and tap 'Push'.", 0), null);
StatusText.Text = "Current: Home";
}
diff --git a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageToolbarPage.xaml.cs b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageToolbarPage.xaml.cs
index 48501c5205..c141f47781 100644
--- a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageToolbarPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageToolbarPage.xaml.cs
@@ -7,6 +7,7 @@ namespace ControlCatalog.Pages
{
public partial class NavigationPageToolbarPage : UserControl
{
+ private bool _initialized;
private int _pageCount;
private int _itemCount;
private ContentPage? _rootPage;
@@ -20,6 +21,10 @@ namespace ControlCatalog.Pages
private async void OnLoaded(object? sender, RoutedEventArgs e)
{
+ if (_initialized)
+ return;
+
+ _initialized = true;
_rootPage = NavigationDemoHelper.MakePage("CommandBar Demo",
"Use the panel to add CommandBar items.\nTop items appear inside the navigation bar.\nBottom items appear as a separate bar.", 0);
ApplyPosition();
diff --git a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageTransitionsPage.xaml.cs b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageTransitionsPage.xaml.cs
index 6ce3b9992d..ae556cff57 100644
--- a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageTransitionsPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageTransitionsPage.xaml.cs
@@ -8,6 +8,7 @@ namespace ControlCatalog.Pages
{
public partial class NavigationPageTransitionsPage : UserControl
{
+ private bool _initialized;
private int _pageCount;
public NavigationPageTransitionsPage()
@@ -18,6 +19,13 @@ namespace ControlCatalog.Pages
private async void OnLoaded(object? sender, RoutedEventArgs e)
{
+ if (_initialized)
+ {
+ UpdateTransition();
+ return;
+ }
+
+ _initialized = true;
await DemoNav.PushAsync(NavigationDemoHelper.MakePage("Transitions", "Choose a transition type and push pages.", 0), null);
UpdateTransition();
}
diff --git a/samples/ControlCatalog/Pages/NavigationPage/PulseAppPage.xaml.cs b/samples/ControlCatalog/Pages/NavigationPage/PulseAppPage.xaml.cs
index 04e146d84b..f50d40f151 100644
--- a/samples/ControlCatalog/Pages/NavigationPage/PulseAppPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/NavigationPage/PulseAppPage.xaml.cs
@@ -99,7 +99,7 @@ public partial class PulseAppPage : UserControl
Content = homeView,
Background = new SolidColorBrush(BgDashboard),
Header = "Home",
- Icon = "M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z",
+ Icon = Geometry.Parse("M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"),
};
var workoutsPage = new ContentPage
@@ -107,7 +107,7 @@ public partial class PulseAppPage : UserControl
Content = new PulseWorkoutsView(),
Background = new SolidColorBrush(BgDashboard),
Header = "Workouts",
- Icon = "M20.57 14.86L22 13.43 20.57 12 17 15.57 8.43 7 12 3.43 10.57 2 9.14 3.43 7.71 2 5.57 4.14 4.14 2.71 2.71 4.14l1.43 1.43L2 7.71l1.43 1.43L2 10.57 3.43 12 7 8.43 15.57 17 12 20.57 13.43 22l1.43-1.43L16.29 22l2.14-2.14 1.43 1.43 1.43-1.43-1.43-1.43L22 16.29z",
+ Icon = Geometry.Parse("M20.57 14.86L22 13.43 20.57 12 17 15.57 8.43 7 12 3.43 10.57 2 9.14 3.43 7.71 2 5.57 4.14 4.14 2.71 2.71 4.14l1.43 1.43L2 7.71l1.43 1.43L2 10.57 3.43 12 7 8.43 15.57 17 12 20.57 13.43 22l1.43-1.43L16.29 22l2.14-2.14 1.43 1.43 1.43-1.43-1.43-1.43L22 16.29z"),
};
var profilePage = new ContentPage
@@ -115,7 +115,7 @@ public partial class PulseAppPage : UserControl
Content = new PulseProfileView(),
Background = new SolidColorBrush(BgDashboard),
Header = "Profile",
- Icon = "M12 2C9.243 2 7 4.243 7 7s2.243 5 5 5 5-2.243 5-5-2.243-5-5-5zM12 14c-5.523 0-10 3.582-10 8a1 1 0 001 1h18a1 1 0 001-1c0-4.418-4.477-8-10-8z",
+ Icon = Geometry.Parse("M12 2C9.243 2 7 4.243 7 7s2.243 5 5 5 5-2.243 5-5-2.243-5-5-5zM12 14c-5.523 0-10 3.582-10 8a1 1 0 001 1h18a1 1 0 001-1c0-4.418-4.477-8-10-8z"),
};
tp.Pages = new ObservableCollection { homePage, workoutsPage, profilePage };
diff --git a/samples/ControlCatalog/Pages/NavigationPage/RetroGamingAppPage.xaml b/samples/ControlCatalog/Pages/NavigationPage/RetroGamingAppPage.xaml
index 7cd254b415..23278161df 100644
--- a/samples/ControlCatalog/Pages/NavigationPage/RetroGamingAppPage.xaml
+++ b/samples/ControlCatalog/Pages/NavigationPage/RetroGamingAppPage.xaml
@@ -48,6 +48,7 @@
-
+
+
+
+
@@ -153,10 +175,5 @@
-
-
-
diff --git a/samples/ControlCatalog/Pages/NavigationPage/RetroGamingAppPage.xaml.cs b/samples/ControlCatalog/Pages/NavigationPage/RetroGamingAppPage.xaml.cs
index 46951950b7..25091493ea 100644
--- a/samples/ControlCatalog/Pages/NavigationPage/RetroGamingAppPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/NavigationPage/RetroGamingAppPage.xaml.cs
@@ -51,11 +51,30 @@ public partial class RetroGamingAppPage : UserControl
_infoPanel.IsVisible = Bounds.Width >= 650;
}
+ void ApplyHomeNavigationBarAppearance()
+ {
+ if (_nav == null)
+ return;
+
+ _nav.Resources["NavigationBarBackground"] = new SolidColorBrush(SurfaceColor);
+ _nav.Resources["NavigationBarForeground"] = new SolidColorBrush(CyanColor);
+ }
+
+ void ApplyDetailNavigationBarAppearance()
+ {
+ if (_nav == null)
+ return;
+
+ _nav.Resources["NavigationBarBackground"] = Brushes.Transparent;
+ _nav.Resources["NavigationBarForeground"] = new SolidColorBrush(CyanColor);
+ }
+
ContentPage BuildHomePage()
{
var page = new ContentPage { Background = new SolidColorBrush(BgColor) };
page.Header = BuildPixelArcadeLogo();
NavigationPage.SetTopCommandBar(page, BuildNavBarRight());
+ ApplyHomeNavigationBarAppearance();
var panel = new Panel();
panel.Children.Add(BuildHomeTabbedPage());
@@ -174,7 +193,7 @@ public partial class RetroGamingAppPage : UserControl
var homeTab = new ContentPage
{
Header = "Home",
- Icon = "M10,20V14H14V20H19V12H22L12,3L2,12H5V20H10Z",
+ Icon = Geometry.Parse("M10,20V14H14V20H19V12H22L12,3L2,12H5V20H10Z"),
Background = new SolidColorBrush(BgColor),
Content = homeView,
};
@@ -185,7 +204,7 @@ public partial class RetroGamingAppPage : UserControl
var gamesTab = new ContentPage
{
Header = "Games",
- Icon = "M7.97,16L5,19C4.67,19.3 4.23,19.5 3.75,19.5A1.75,1.75 0 0,1 2,17.75V17.5L3,10.12C3.21,7.81 5.14,6 7.5,6H16.5C18.86,6 20.79,7.81 21,10.12L22,17.5V17.75A1.75,1.75 0 0,1 20.25,19.5C19.77,19.5 19.33,19.3 19,19L16.03,16H7.97M7,9V11H5V13H7V15H9V13H11V11H9V9H7M14.5,12A1.5,1.5 0 0,0 13,13.5A1.5,1.5 0 0,0 14.5,15A1.5,1.5 0 0,0 16,13.5A1.5,1.5 0 0,0 14.5,12M17.5,9A1.5,1.5 0 0,0 16,10.5A1.5,1.5 0 0,0 17.5,12A1.5,1.5 0 0,0 19,10.5A1.5,1.5 0 0,0 17.5,9Z",
+ Icon = Geometry.Parse("M7.97,16L5,19C4.67,19.3 4.23,19.5 3.75,19.5A1.75,1.75 0 0,1 2,17.75V17.5L3,10.12C3.21,7.81 5.14,6 7.5,6H16.5C18.86,6 20.79,7.81 21,10.12L22,17.5V17.75A1.75,1.75 0 0,1 20.25,19.5C19.77,19.5 19.33,19.3 19,19L16.03,16H7.97M7,9V11H5V13H7V15H9V13H11V11H9V9H7M14.5,12A1.5,1.5 0 0,0 13,13.5A1.5,1.5 0 0,0 14.5,15A1.5,1.5 0 0,0 16,13.5A1.5,1.5 0 0,0 14.5,12M17.5,9A1.5,1.5 0 0,0 16,10.5A1.5,1.5 0 0,0 17.5,12A1.5,1.5 0 0,0 19,10.5A1.5,1.5 0 0,0 17.5,9Z"),
Background = new SolidColorBrush(BgColor),
Content = gamesView,
};
@@ -193,7 +212,7 @@ public partial class RetroGamingAppPage : UserControl
var favTab = new ContentPage
{
Header = "Favorites",
- Icon = "M12,21.35L10.55,20.03C5.4,15.36 2,12.27 2,8.5C2,5.41 4.42,3 7.5,3C9.24,3 10.91,3.81 12,5.08C13.09,3.81 14.76,3 16.5,3C19.58,3 22,5.41 22,8.5C22,12.27 18.6,15.36 13.45,20.03L12,21.35Z",
+ Icon = Geometry.Parse("M12,21.35L10.55,20.03C5.4,15.36 2,12.27 2,8.5C2,5.41 4.42,3 7.5,3C9.24,3 10.91,3.81 12,5.08C13.09,3.81 14.76,3 16.5,3C19.58,3 22,5.41 22,8.5C22,12.27 18.6,15.36 13.45,20.03L12,21.35Z"),
Background = new SolidColorBrush(BgColor),
Content = new RetroGamingFavoritesView(),
};
@@ -201,7 +220,7 @@ public partial class RetroGamingAppPage : UserControl
var profileTab = new ContentPage
{
Header = "Profile",
- Icon = "M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,14C16.42,14 20,15.79 20,18V20H4V18C4,15.79 7.58,14 12,14Z",
+ Icon = Geometry.Parse("M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,14C16.42,14 20,15.79 20,18V20H4V18C4,15.79 7.58,14 12,14Z"),
Background = new SolidColorBrush(BgColor),
Content = new RetroGamingProfileView(),
};
@@ -260,7 +279,8 @@ public partial class RetroGamingAppPage : UserControl
async void PushDetailPage(string gameTitle)
{
- if (_nav == null) return;
+ if (_nav == null)
+ return;
var detailView = new RetroGamingDetailView(gameTitle);
@@ -271,8 +291,13 @@ public partial class RetroGamingAppPage : UserControl
};
NavigationPage.SetBarLayoutBehavior(page, BarLayoutBehavior.Overlay);
- page.NavigatedTo += (_, _) => { if (_nav != null) _nav.Resources["NavigationBarBackground"] = Brushes.Transparent; };
- page.NavigatedFrom += (_, _) => { if (_nav != null) _nav.Resources["NavigationBarBackground"] = new SolidColorBrush(SurfaceColor); };
+ page.Navigating += args =>
+ {
+ if (args.NavigationType == NavigationType.Pop)
+ ApplyHomeNavigationBarAppearance();
+
+ return Task.CompletedTask;
+ };
var cmdBar = new StackPanel
{
@@ -301,6 +326,10 @@ public partial class RetroGamingAppPage : UserControl
cmdBar.Children.Add(shareBtn);
NavigationPage.SetTopCommandBar(page, cmdBar);
+ ApplyDetailNavigationBarAppearance();
await _nav.PushAsync(page);
+
+ if (!ReferenceEquals(_nav.CurrentPage, page))
+ ApplyHomeNavigationBarAppearance();
}
}
diff --git a/samples/ControlCatalog/Pages/PipsPagerPage.xaml.cs b/samples/ControlCatalog/Pages/PipsPagerPage.xaml.cs
index 8f27cc61f8..33cc6d0fdf 100644
--- a/samples/ControlCatalog/Pages/PipsPagerPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/PipsPagerPage.xaml.cs
@@ -31,6 +31,13 @@ namespace ControlCatalog.Pages
("Appearance", "Custom Templates",
"Override pip item templates to create squares, pills, numbers, or any custom shape.",
() => new PipsPagerCustomTemplatesPage()),
+
+ ("Showcases", "Care Companion",
+ "A health care onboarding flow using PipsPager as the page indicator for a CarouselPage.",
+ () => new CareCompanionAppPage()),
+ ("Showcases", "Sanctuary",
+ "A travel discovery app using PipsPager as the page indicator for a CarouselPage.",
+ () => new SanctuaryShowcasePage()),
};
public PipsPagerPage()
diff --git a/samples/ControlCatalog/Pages/TabbedDemoPage.xaml.cs b/samples/ControlCatalog/Pages/TabbedDemoPage.xaml.cs
index f4e65249ce..51da6330a1 100644
--- a/samples/ControlCatalog/Pages/TabbedDemoPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/TabbedDemoPage.xaml.cs
@@ -18,7 +18,7 @@ namespace ControlCatalog.Pages
"Populate a TabbedPage by adding ContentPage objects directly to the Pages collection.",
() => new TabbedPageCollectionPage()),
("Populate", "Data Templates",
- "Populate a TabbedPage with a data collection and a custom PageTemplate to render each item.",
+ "Bind TabbedPage to an ObservableCollection, add or remove tabs at runtime, and switch the page template.",
() => new TabbedPageDataTemplatePage()),
// Appearance
diff --git a/samples/ControlCatalog/Pages/TabbedPage/TabbedPageCustomTabBarPage.xaml.cs b/samples/ControlCatalog/Pages/TabbedPage/TabbedPageCustomTabBarPage.xaml.cs
index bb7302c1cd..b1a692b85a 100644
--- a/samples/ControlCatalog/Pages/TabbedPage/TabbedPageCustomTabBarPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/TabbedPage/TabbedPageCustomTabBarPage.xaml.cs
@@ -5,7 +5,6 @@ namespace ControlCatalog.Pages
{
public partial class TabbedPageCustomTabBarPage : UserControl
{
- // Fluent UI icon geometries (24x24 viewbox)
private static readonly StreamGeometry HomeGeometry =
StreamGeometry.Parse("M12.9942 2.79444C12.4118 2.30208 11.5882 2.30208 11.0058 2.79444L3.50582 9.39444C3.18607 9.66478 3 10.0634 3 10.4828V20.25C3 20.9404 3.55964 21.5 4.25 21.5H8.25C8.94036 21.5 9.5 20.9404 9.5 20.25V14.75C9.5 14.6119 9.61193 14.5 9.75 14.5H14.25C14.3881 14.5 14.5 14.6119 14.5 14.75V20.25C14.5 20.9404 15.0596 21.5 15.75 21.5H19.75C20.4404 21.5 21 20.9404 21 20.25V10.4828C21 10.0634 20.8139 9.66478 20.4942 9.39444L12.9942 2.79444Z");
private static readonly StreamGeometry WalletGeometry =
@@ -25,16 +24,11 @@ namespace ControlCatalog.Pages
private void SetupIcons()
{
- SetIcon(HomePage, HomeGeometry);
- SetIcon(WalletPage, WalletGeometry);
- SetIcon(SendPage, SendGeometry);
- SetIcon(ActivityPage, ActivityGeometry);
- SetIcon(ProfilePage, ProfileGeometry);
- }
-
- private static void SetIcon(ContentPage page, StreamGeometry geometry)
- {
- page.Icon = geometry;
+ HomePage.Icon = new PathIcon { Data = HomeGeometry };
+ WalletPage.Icon = new PathIcon { Data = WalletGeometry };
+ SendPage.Icon = new PathIcon { Data = SendGeometry };
+ ActivityPage.Icon = new PathIcon { Data = ActivityGeometry };
+ ProfilePage.Icon = new PathIcon { Data = ProfileGeometry };
}
}
}
diff --git a/samples/ControlCatalog/Pages/TabbedPage/TabbedPageCustomizationPage.xaml.cs b/samples/ControlCatalog/Pages/TabbedPage/TabbedPageCustomizationPage.xaml.cs
index dc72759c5e..b4eb6d9b49 100644
--- a/samples/ControlCatalog/Pages/TabbedPage/TabbedPageCustomizationPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/TabbedPage/TabbedPageCustomizationPage.xaml.cs
@@ -109,14 +109,9 @@ namespace ControlCatalog.Pages
private void OnShowIconsChanged(object? sender, RoutedEventArgs e)
{
bool show = ShowIconsCheck.IsChecked == true;
- SetIcon(HomePage, show ? HomeGeometry : null);
- SetIcon(SearchPage, show ? SearchGeometry : null);
- SetIcon(SettingsPage, show ? SettingsGeometry : null);
- }
-
- private static void SetIcon(ContentPage page, StreamGeometry? geometry)
- {
- page.Icon = geometry;
+ HomePage.Icon = show ? new PathIcon { Data = HomeGeometry } : null;
+ SearchPage.Icon = show ? new PathIcon { Data = SearchGeometry } : null;
+ SettingsPage.Icon = show ? new PathIcon { Data = SettingsGeometry } : null;
}
private void OnTabEnabledChanged(object? sender, RoutedEventArgs e)
diff --git a/samples/ControlCatalog/Pages/TabbedPage/TabbedPageDataTemplatePage.xaml b/samples/ControlCatalog/Pages/TabbedPage/TabbedPageDataTemplatePage.xaml
index e2f5771028..dcb16a99a5 100644
--- a/samples/ControlCatalog/Pages/TabbedPage/TabbedPageDataTemplatePage.xaml
+++ b/samples/ControlCatalog/Pages/TabbedPage/TabbedPageDataTemplatePage.xaml
@@ -8,16 +8,25 @@
Foreground="{DynamicResource SystemControlHighlightAccentBrush}" />
+ Text="Bind a TabbedPage to a data collection, render each item with PageTemplate, and switch templates at runtime." />
+
-
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/TabbedPage/TabbedPageDataTemplatePage.xaml.cs b/samples/ControlCatalog/Pages/TabbedPage/TabbedPageDataTemplatePage.xaml.cs
index 44f5179247..169735ce65 100644
--- a/samples/ControlCatalog/Pages/TabbedPage/TabbedPageDataTemplatePage.xaml.cs
+++ b/samples/ControlCatalog/Pages/TabbedPage/TabbedPageDataTemplatePage.xaml.cs
@@ -1,5 +1,7 @@
using System.Collections.ObjectModel;
+using Avalonia;
using Avalonia.Controls;
+using Avalonia.Controls.Templates;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
@@ -8,18 +10,32 @@ namespace ControlCatalog.Pages
{
public partial class TabbedPageDataTemplatePage : UserControl
{
- private static readonly (string Name, string Color)[] CategoryData =
+ private sealed class CategoryViewModel
{
- ("Electronics", "#1565C0"),
- ("Books", "#2E7D32"),
- ("Clothing", "#6A1B9A"),
+ public string Name { get; }
+ public string Color { get; }
+
+ public CategoryViewModel(string name, string color)
+ {
+ Name = name;
+ Color = color;
+ }
+ }
+
+ private static readonly CategoryViewModel[] InitialData =
+ {
+ new("Electronics", "#1565C0"), new("Books", "#2E7D32"), new("Clothing", "#6A1B9A"),
};
- private static readonly string[] AddNames = { "Sports", "Music", "Garden", "Toys", "Food" };
- private static readonly string[] AddColors = { "#E53935", "#F57C00", "#00796B", "#E91E63", "#3F51B5" };
+ private static readonly CategoryViewModel[] AddData =
+ {
+ new("Sports", "#E53935"), new("Music", "#F57C00"), new("Garden", "#00796B"), new("Toys", "#E91E63"),
+ new("Food", "#3F51B5")
+ };
- private readonly ObservableCollection _pages = new();
+ private readonly ObservableCollection _items = new();
private int _addCounter;
+ private bool _useDetailTemplate = true;
private TabbedPage? _tabbedPage;
public TabbedPageDataTemplatePage()
@@ -30,48 +46,99 @@ namespace ControlCatalog.Pages
private void OnLoaded(object? sender, RoutedEventArgs e)
{
- foreach (var (name, color) in CategoryData)
- _pages.Add(CreatePage(name, color));
+ if (_tabbedPage != null)
+ return;
- _addCounter = CategoryData.Length;
+ foreach (var vm in InitialData)
+ _items.Add(vm);
+ _addCounter = InitialData.Length;
+ _useDetailTemplate = true;
_tabbedPage = new TabbedPage
{
- TabPlacement = TabPlacement.Top,
- Pages = _pages
+ TabPlacement = TabPlacement.Top, ItemsSource = _items, PageTemplate = CreatePageTemplate()
};
+ _tabbedPage.SelectionChanged += OnSelectionChanged;
TabbedPageHost.Children.Add(_tabbedPage);
UpdateStatus();
}
private void OnAddCategory(object? sender, RoutedEventArgs e)
{
- var idx = _addCounter % AddNames.Length;
- var name = AddNames[idx] + (_addCounter >= AddNames.Length ? $" {_addCounter / AddNames.Length + 1}" : "");
- _pages.Add(CreatePage(name, AddColors[idx]));
+ var idx = _addCounter % AddData.Length;
+ var vm = AddData[idx];
+ var suffix = _addCounter >= AddData.Length ? $" {_addCounter / AddData.Length + 1}" : "";
+ _items.Add(new CategoryViewModel(vm.Name + suffix, vm.Color));
_addCounter++;
UpdateStatus();
}
private void OnRemoveCategory(object? sender, RoutedEventArgs e)
{
- if (_pages.Count > 0)
+ if (_items.Count > 0)
{
- _pages.RemoveAt(_pages.Count - 1);
+ _items.RemoveAt(_items.Count - 1);
UpdateStatus();
}
}
+ private void OnSelectionChanged(object? sender, PageSelectionChangedEventArgs e) => UpdateStatus();
+
+ private void OnSwitchTemplate(object? sender, RoutedEventArgs e)
+ {
+ if (_tabbedPage == null)
+ return;
+
+ _useDetailTemplate = !_useDetailTemplate;
+ _tabbedPage.PageTemplate = CreatePageTemplate();
+ UpdateStatus();
+ }
+
+ private void OnPrevious(object? sender, RoutedEventArgs e)
+ {
+ if (_tabbedPage == null)
+ return;
+
+ if (_tabbedPage.SelectedIndex > 0)
+ _tabbedPage.SelectedIndex--;
+ }
+
+ private void OnNext(object? sender, RoutedEventArgs e)
+ {
+ if (_tabbedPage == null)
+ return;
+
+ if (_tabbedPage.SelectedIndex < _items.Count - 1)
+ _tabbedPage.SelectedIndex++;
+ }
+
private void UpdateStatus()
{
- StatusText.Text = $"{_pages.Count} categor{(_pages.Count == 1 ? "y" : "ies")}";
+ var count = _items.Count;
+ var index = _tabbedPage?.SelectedIndex ?? -1;
+ StatusText.Text = count == 0 ? "No tabs" : $"Tab {index + 1} of {count} (index {index})";
}
- private static ContentPage CreatePage(string name, string color) => new()
+ private IDataTemplate CreatePageTemplate()
{
- Header = name,
- Content = new StackPanel
+ return new FuncDataTemplate((vm, _) => CreatePage(vm, _useDetailTemplate));
+ }
+
+ private static ContentPage CreatePage(CategoryViewModel? vm, bool useDetailTemplate)
+ {
+ if (vm is null)
+ return new ContentPage();
+
+ return new ContentPage
+ {
+ Header = vm.Name, Content = useDetailTemplate ? CreateDetailContent(vm) : CreateShowcaseContent(vm)
+ };
+ }
+
+ private static Control CreateDetailContent(CategoryViewModel vm)
+ {
+ return new StackPanel
{
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
@@ -80,15 +147,15 @@ namespace ControlCatalog.Pages
{
new TextBlock
{
- Text = name,
+ Text = vm.Name,
FontSize = 24,
FontWeight = FontWeight.SemiBold,
- Foreground = new SolidColorBrush(Color.Parse(color)),
+ Foreground = new SolidColorBrush(Color.Parse(vm.Color)),
HorizontalAlignment = HorizontalAlignment.Center
},
new TextBlock
{
- Text = $"Tab for category: {name}",
+ Text = $"Tab for category: {vm.Name}",
FontSize = 13,
Opacity = 0.7,
TextWrapping = TextWrapping.Wrap,
@@ -96,7 +163,45 @@ namespace ControlCatalog.Pages
MaxWidth = 280
}
}
- }
- };
+ };
+ }
+
+ private static Control CreateShowcaseContent(CategoryViewModel vm)
+ {
+ var accent = Color.Parse(vm.Color);
+
+ return new Border
+ {
+ Margin = new Thickness(24),
+ CornerRadius = new CornerRadius(18),
+ Background = new SolidColorBrush(accent),
+ Padding = new Thickness(28),
+ Child = new StackPanel
+ {
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ Spacing = 10,
+ Children =
+ {
+ new TextBlock
+ {
+ Text = vm.Name,
+ FontSize = 28,
+ FontWeight = FontWeight.Bold,
+ Foreground = Brushes.White,
+ HorizontalAlignment = HorizontalAlignment.Center
+ },
+ new TextBlock
+ {
+ Text = "Template switched at runtime",
+ FontSize = 14,
+ Foreground = Brushes.White,
+ Opacity = 0.9,
+ HorizontalAlignment = HorizontalAlignment.Center
+ }
+ }
+ }
+ };
+ }
}
}
diff --git a/samples/ControlCatalog/Pages/TabbedPage/TabbedPageFabPage.xaml.cs b/samples/ControlCatalog/Pages/TabbedPage/TabbedPageFabPage.xaml.cs
index 5c10a50df7..b52bfd4d8a 100644
--- a/samples/ControlCatalog/Pages/TabbedPage/TabbedPageFabPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/TabbedPage/TabbedPageFabPage.xaml.cs
@@ -28,10 +28,10 @@ namespace ControlCatalog.Pages
private void SetupIcons()
{
- FeedPage.Icon = FeedGeometry;
- DiscoverPage.Icon = DiscoverGeometry;
- AlertsPage.Icon = AlertsGeometry;
- ProfilePage.Icon = ProfileGeometry;
+ FeedPage.Icon = new PathIcon { Data = FeedGeometry };
+ DiscoverPage.Icon = new PathIcon { Data = DiscoverGeometry };
+ AlertsPage.Icon = new PathIcon { Data = AlertsGeometry };
+ ProfilePage.Icon = new PathIcon { Data = ProfileGeometry };
}
private void OnFabClicked(object? sender, RoutedEventArgs e)
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/TabbedPage/TabbedPageWithNavigationPage.xaml b/samples/ControlCatalog/Pages/TabbedPage/TabbedPageWithNavigationPage.xaml
index 1c2c51ae2a..22878ec3c4 100644
--- a/samples/ControlCatalog/Pages/TabbedPage/TabbedPageWithNavigationPage.xaml
+++ b/samples/ControlCatalog/Pages/TabbedPage/TabbedPageWithNavigationPage.xaml
@@ -29,27 +29,21 @@
CornerRadius="6"
ClipToBounds="True">
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/TabbedPage/TabbedPageWithNavigationPage.xaml.cs b/samples/ControlCatalog/Pages/TabbedPage/TabbedPageWithNavigationPage.xaml.cs
index e4efec576a..2b7a5a3a41 100644
--- a/samples/ControlCatalog/Pages/TabbedPage/TabbedPageWithNavigationPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/TabbedPage/TabbedPageWithNavigationPage.xaml.cs
@@ -7,6 +7,8 @@ namespace ControlCatalog.Pages
{
public partial class TabbedPageWithNavigationPage : UserControl
{
+ private bool _initialized;
+
public TabbedPageWithNavigationPage()
{
InitializeComponent();
@@ -15,6 +17,10 @@ namespace ControlCatalog.Pages
private async void OnLoaded(object? sender, RoutedEventArgs e)
{
+ if (_initialized)
+ return;
+
+ _initialized = true;
await BrowseNav.PushAsync(CreateListPage("Browse", "Items", BrowseNav), null);
await SearchNav.PushAsync(CreateListPage("Search", "Results", SearchNav), null);
await AccountNav.PushAsync(CreateListPage("Account", "Options", AccountNav), null);
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/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs b/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs
index 47d95d8da1..59ec332b2d 100644
--- a/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs
+++ b/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs
@@ -12,6 +12,7 @@ using Avalonia.Logging;
using Avalonia.Platform.Storage;
using Avalonia.Platform.Storage.FileIO;
using Java.Lang;
+using static Android.Provider.DocumentsContract;
using AndroidUri = Android.Net.Uri;
using Exception = System.Exception;
using JavaFile = Java.IO.File;
@@ -35,10 +36,10 @@ internal abstract class AndroidStorageItem : IStorageBookmarkItem
}
internal AndroidUri Uri { get; set; }
-
+
protected Activity Activity => _activity ?? throw new ObjectDisposedException(nameof(AndroidStorageItem));
- public virtual string Name => GetColumnValue(Activity, Uri, DocumentsContract.Document.ColumnDisplayName)
+ public virtual string Name => GetColumnValue(Activity, Uri, Document.ColumnDisplayName)
?? GetColumnValue(Activity, Uri, MediaStore.IMediaColumns.DisplayName)
?? Uri.PathSegments?.LastOrDefault()?.Split("/", StringSplitOptions.RemoveEmptyEntries).LastOrDefault() ?? string.Empty;
@@ -67,7 +68,7 @@ internal abstract class AndroidStorageItem : IStorageBookmarkItem
Activity.ContentResolver?.ReleasePersistableUriPermission(Uri, ActivityFlags.GrantWriteUriPermission | ActivityFlags.GrantReadUriPermission);
}
-
+
public abstract Task GetBasicPropertiesAsync();
protected static string? GetColumnValue(Context context, AndroidUri contentUri, string column, string? selection = null, string[]? selectionArgs = null)
@@ -98,7 +99,7 @@ internal abstract class AndroidStorageItem : IStorageBookmarkItem
return null;
}
- if(_parent != null)
+ if (_parent != null)
{
return _parent;
}
@@ -106,8 +107,8 @@ internal abstract class AndroidStorageItem : IStorageBookmarkItem
using var javaFile = new JavaFile(Uri.Path!);
// Java file represents files AND directories. Don't be confused.
- if (javaFile.ParentFile is {} parentFile
- && AndroidUri.FromFile(parentFile) is {} androidUri)
+ if (javaFile.ParentFile is { } parentFile
+ && AndroidUri.FromFile(parentFile) is { } androidUri)
{
return new AndroidStorageFolder(Activity, androidUri, false);
}
@@ -124,12 +125,12 @@ internal abstract class AndroidStorageItem : IStorageBookmarkItem
return await _activity!.CheckPermission(Manifest.Permission.ReadExternalStorage);
}
-
+
public void Dispose()
{
_activity = null;
}
-
+
internal AndroidUri? PermissionRoot => _permissionRoot;
public abstract Task DeleteAsync();
@@ -138,8 +139,8 @@ internal abstract class AndroidStorageItem : IStorageBookmarkItem
public static IStorageItem CreateItem(Activity activity, AndroidUri uri)
{
- var mimeType = GetColumnValue(activity, uri, DocumentsContract.Document.ColumnMimeType);
- if (mimeType == DocumentsContract.Document.MimeTypeDir)
+ var mimeType = GetColumnValue(activity, uri, Document.ColumnMimeType);
+ if (mimeType == Document.MimeTypeDir)
{
return new AndroidStorageFolder(activity, uri, false);
}
@@ -160,8 +161,8 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder
{
var mimeType = MimeTypeMap.Singleton?.GetMimeTypeFromExtension(MimeTypeMap.GetFileExtensionFromUrl(name)) ?? "application/octet-stream";
var treeUri = GetTreeUri().treeUri;
- var newFile = DocumentsContract.CreateDocument(Activity.ContentResolver!, treeUri!, mimeType, name);
- if(newFile == null)
+ var newFile = CreateDocument(Activity.ContentResolver!, treeUri!, mimeType, name);
+ if (newFile == null)
{
return Task.FromResult(null);
}
@@ -172,7 +173,7 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder
public Task CreateFolderAsync(string name)
{
var treeUri = GetTreeUri().treeUri;
- var newFolder = DocumentsContract.CreateDocument(Activity.ContentResolver!, treeUri!, DocumentsContract.Document.MimeTypeDir, name);
+ var newFolder = CreateDocument(Activity.ContentResolver!, treeUri!, Document.MimeTypeDir, name);
if (newFolder == null)
{
return Task.FromResult(null);
@@ -197,24 +198,76 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder
{
await foreach (var file in storageFolder.GetItemsAsync())
{
- if(file is AndroidStorageFolder folder)
+ if (file is AndroidStorageFolder folder)
{
await DeleteContents(folder);
}
- else if(file is AndroidStorageFile storageFile)
+ else if (file is AndroidStorageFile storageFile)
{
await storageFile.DeleteAsync();
}
}
var treeUri = GetTreeUri().treeUri;
- DocumentsContract.DeleteDocument(Activity.ContentResolver!, treeUri!);
+ DeleteDocument(Activity.ContentResolver!, treeUri!);
}
}
public override Task GetBasicPropertiesAsync()
{
- return Task.FromResult(new StorageItemProperties());
+ DateTimeOffset? dateModified = null;
+
+ AndroidUri? queryUri = null;
+
+ try
+ {
+ try
+ {
+ // When Uri is a tree URI, use its document id to build a document URI.
+ var folderId = GetTreeDocumentId(Uri);
+ queryUri = BuildDocumentUriUsingTree(Uri, folderId);
+ }
+ catch (UnsupportedOperationException)
+ {
+ // For non-root items, Uri may already be a document URI; use it directly.
+ queryUri = Uri;
+ }
+
+ if (queryUri != null)
+ {
+ var projection = new[]
+ {
+ Document.ColumnLastModified
+ };
+ using var cursor = Activity.ContentResolver!.Query(queryUri, projection, null, null, null);
+
+ if (cursor?.MoveToFirst() == true)
+ {
+ try
+ {
+ var columnIndex = cursor.GetColumnIndex(Document.ColumnLastModified);
+ if (columnIndex != -1)
+ {
+ var longValue = cursor.GetLong(columnIndex);
+ dateModified = longValue > 0 ? DateTimeOffset.FromUnixTimeMilliseconds(longValue) : null;
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)?
+ .Log(this, "Directory LastModified metadata reader failed: '{Exception}'", ex);
+ }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ // Data may not be available for this item or the URI may not be in the expected shape.
+ Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)?
+ .Log(this, "Directory basic properties metadata unavailable: '{Exception}'", ex);
+ }
+
+ return Task.FromResult(new StorageItemProperties(null, null, dateModified));
}
public async IAsyncEnumerable GetItemsAsync()
@@ -234,8 +287,8 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder
var projection = new[]
{
- DocumentsContract.Document.ColumnDocumentId,
- DocumentsContract.Document.ColumnMimeType
+ Document.ColumnDocumentId,
+ Document.ColumnMimeType
};
if (childrenUri != null)
{
@@ -247,8 +300,8 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder
var mime = cursor.GetString(1);
var id = cursor.GetString(0);
- bool isDirectory = mime == DocumentsContract.Document.MimeTypeDir;
- var uri = DocumentsContract.BuildDocumentUriUsingTree(root, id);
+ bool isDirectory = mime == Document.MimeTypeDir;
+ var uri = BuildDocumentUriUsingTree(root, id);
if (uri == null)
{
@@ -313,9 +366,9 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder
var projection = new[]
{
- DocumentsContract.Document.ColumnDocumentId,
- DocumentsContract.Document.ColumnMimeType,
- DocumentsContract.Document.ColumnDisplayName
+ Document.ColumnDocumentId,
+ Document.ColumnMimeType,
+ Document.ColumnDisplayName
};
if (childrenUri != null)
@@ -332,15 +385,15 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder
if (fileName != name)
{
continue;
- }
+ }
- bool mineDirectory = mime == DocumentsContract.Document.MimeTypeDir;
+ bool mineDirectory = mime == Document.MimeTypeDir;
if (isDirectory != mineDirectory)
{
return null;
}
- var uri = DocumentsContract.BuildDocumentUriUsingTree(root, id);
+ var uri = BuildDocumentUriUsingTree(root, id);
if (uri == null)
{
return null;
@@ -370,8 +423,8 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder
private (AndroidUri root, AndroidUri? treeUri) GetTreeUri()
{
var root = PermissionRoot ?? Uri;
- var folderId = root != Uri ? DocumentsContract.GetDocumentId(Uri) : DocumentsContract.GetTreeDocumentId(Uri);
- return (root, DocumentsContract.BuildChildDocumentsUriUsingTree(root, folderId));
+ var folderId = root != Uri ? GetDocumentId(Uri) : GetTreeDocumentId(Uri);
+ return (root, BuildChildDocumentsUriUsingTree(root, folderId));
}
}
@@ -419,10 +472,10 @@ internal sealed class AndroidStorageFile : AndroidStorageItem, IStorageBookmarkF
if (!OperatingSystem.IsAndroidVersionAtLeast(24))
return false;
- if (!DocumentsContract.IsDocumentUri(context, uri))
+ if (!IsDocumentUri(context, uri))
return false;
- var value = GetColumnValue(context, uri, DocumentsContract.Document.ColumnFlags);
+ var value = GetColumnValue(context, uri, Document.ColumnFlags);
if (!string.IsNullOrEmpty(value) && int.TryParse(value, out var flagsInt))
{
var flags = (DocumentContractFlags)flagsInt;
@@ -530,7 +583,7 @@ internal sealed class AndroidStorageFile : AndroidStorageItem, IStorageBookmarkF
if (Activity != null)
{
- DocumentsContract.DeleteDocument(Activity.ContentResolver!, Uri);
+ DeleteDocument(Activity.ContentResolver!, Uri);
}
}
@@ -553,7 +606,7 @@ internal sealed class AndroidStorageFile : AndroidStorageItem, IStorageBookmarkF
storageFolder.Uri is { } targetParentUri &&
await GetParentAsync() is AndroidStorageFolder parentFolder)
{
- movedUri = DocumentsContract.MoveDocument(contentResolver, Uri, parentFolder.Uri, targetParentUri);
+ movedUri = MoveDocument(contentResolver, Uri, parentFolder.Uri, targetParentUri);
}
}
catch (Exception)
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..001c64f648 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,56 @@ 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..41aa205547 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;
@@ -6,8 +7,10 @@ using Avalonia.Styling;
namespace Avalonia.Animation;
-public class Rotate3DTransition: PageSlide
+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/DragEventArgs.cs b/src/Avalonia.Base/Input/DragEventArgs.cs
index e68a6138e0..d4e0cb1bce 100644
--- a/src/Avalonia.Base/Input/DragEventArgs.cs
+++ b/src/Avalonia.Base/Input/DragEventArgs.cs
@@ -1,6 +1,5 @@
using System;
using Avalonia.Interactivity;
-using Avalonia.Metadata;
namespace Avalonia.Input
{
@@ -25,9 +24,8 @@ namespace Avalonia.Input
return _target.TranslatePoint(_targetLocation, relativeTo) ?? new Point(0, 0);
}
- [Unstable("This constructor might be removed in 12.0. For unit testing, consider using DragDrop.DoDragDrop or IHeadlessWindow.DragDrop.")]
public DragEventArgs(
- RoutedEvent routedEvent,
+ RoutedEvent? routedEvent,
IDataTransfer dataTransfer,
Interactive target,
Point targetLocation,
diff --git a/src/Avalonia.Base/Input/FindNextElementOptions.cs b/src/Avalonia.Base/Input/FindNextElementOptions.cs
index e6062daf9b..ee9c2e6fef 100644
--- a/src/Avalonia.Base/Input/FindNextElementOptions.cs
+++ b/src/Avalonia.Base/Input/FindNextElementOptions.cs
@@ -1,17 +1,82 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-
-namespace Avalonia.Input
+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 element that will be treated as the starting point of the search
+ /// for the next focusable element. This does not need to be the element that is
+ /// currently focused. If null, is used.
+ ///
+ public IInputElement? FocusedElement { get; init; }
+
+ ///
+ /// 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.
+ ///
+ ///
+ /// This option is only used with , ,
+ /// , and . It is ignored for other
+ /// directions.
+ ///
+ ///
public InputElement? SearchRoot { get; init; }
+
+ ///
+ /// Gets or sets the rectangular region within the visual hierarchy that will be excluded
+ /// from consideration during focus navigation.
+ ///
+ ///
+ /// This option is only used with , ,
+ /// , and . It is ignored for other
+ /// directions.
+ ///
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.
+ ///
+ ///
+ /// This option is only used with , ,
+ /// , and . It is ignored for other
+ /// directions.
+ ///
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.
+ ///
+ ///
+ /// This option is only used with , ,
+ /// , and . It is ignored for other
+ /// directions.
+ ///
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.
+ ///
+ ///
+ /// This option is only used with , ,
+ /// , and . It is ignored for other
+ /// directions.
+ ///
public bool IgnoreOcclusivity { get; init; }
}
}
diff --git a/src/Avalonia.Base/Input/FocusChangedEventArgs.cs b/src/Avalonia.Base/Input/FocusChangedEventArgs.cs
index ade599ee08..ecca7750ed 100644
--- a/src/Avalonia.Base/Input/FocusChangedEventArgs.cs
+++ b/src/Avalonia.Base/Input/FocusChangedEventArgs.cs
@@ -11,7 +11,7 @@ namespace Avalonia.Input
/// Initializes a new instance of .
///
/// The routed event associated with these event args.
- public FocusChangedEventArgs(RoutedEvent routedEvent)
+ public FocusChangedEventArgs(RoutedEvent? routedEvent)
: base(routedEvent)
{
}
diff --git a/src/Avalonia.Base/Input/FocusChangingEventArgs.cs b/src/Avalonia.Base/Input/FocusChangingEventArgs.cs
index 372ddf38b6..ed237265a6 100644
--- a/src/Avalonia.Base/Input/FocusChangingEventArgs.cs
+++ b/src/Avalonia.Base/Input/FocusChangingEventArgs.cs
@@ -12,7 +12,7 @@ namespace Avalonia.Input
///
/// Provides data for focus changing.
///
- internal FocusChangingEventArgs(RoutedEvent routedEvent) : base(routedEvent)
+ public FocusChangingEventArgs(RoutedEvent? routedEvent) : base(routedEvent)
{
}
diff --git a/src/Avalonia.Base/Input/FocusManager.cs b/src/Avalonia.Base/Input/FocusManager.cs
index 15b8fea77d..a273ff6d89 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(options?.FocusedElement ?? Current, 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,75 +313,84 @@ 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(options?.FocusedElement ?? Current, 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)
+ private IInputElement? FindNextFocus(
+ IInputElement? focusedElement,
+ NavigationDirection direction,
+ XYFocusOptions focusOptions,
+ bool updateManifolds = true)
{
- IInputElement? nextFocusedElement = null;
+ IInputElement? nextFocusedElement;
- var currentlyFocusedElement = Current;
-
- if (direction is NavigationDirection.Previous or NavigationDirection.Next || currentlyFocusedElement == null)
+ if (direction is NavigationDirection.Previous or NavigationDirection.Next || focusedElement == null)
{
var isReverse = direction == NavigationDirection.Previous;
- nextFocusedElement = ProcessTabStopInternal(isReverse, true);
+ nextFocusedElement = ProcessTabStopInternal(focusedElement, isReverse, true);
}
else
{
- if (currentlyFocusedElement is InputElement inputElement &&
+ if (focusedElement is InputElement inputElement &&
XYFocus.GetBoundsForRanking(inputElement, focusOptions.IgnoreClipping) is { } bounds)
{
focusOptions.FocusedElementBounds = bounds;
}
nextFocusedElement = _xyFocus.GetNextFocusableElement(direction,
- currentlyFocusedElement as InputElement,
+ focusedElement as InputElement,
null,
updateManifolds,
focusOptions);
@@ -528,14 +511,14 @@ namespace Avalonia.Input
return lastFocus;
}
- private IInputElement? ProcessTabStopInternal(bool isReverse, bool queryOnly)
+ private IInputElement? ProcessTabStopInternal(IInputElement? focusedElement, bool isReverse, bool queryOnly)
{
IInputElement? newTabStop = null;
- var defaultCandidateTabStop = GetTabStopCandidateElement(isReverse, queryOnly, out var didCycleFocusAtRootVisualScope);
+ var defaultCandidateTabStop = GetTabStopCandidateElement(focusedElement, isReverse, queryOnly, out var didCycleFocusAtRootVisualScope);
var isTabStopOverriden = InputElement.ProcessTabStop(_contentRoot,
- Current,
+ focusedElement,
defaultCandidateTabStop,
isReverse,
didCycleFocusAtRootVisualScope,
@@ -554,24 +537,27 @@ namespace Avalonia.Input
return newTabStop;
}
- private IInputElement? GetTabStopCandidateElement(bool isReverse, bool queryOnly, out bool didCycleFocusAtRootVisualScope)
+ private IInputElement? GetTabStopCandidateElement(
+ IInputElement? focusedElement,
+ bool isReverse,
+ bool queryOnly,
+ out bool didCycleFocusAtRootVisualScope)
{
didCycleFocusAtRootVisualScope = false;
- var currentFocus = Current;
- IInputElement? newTabStop = null;
- var root = this._contentRoot as IInputElement;
+ IInputElement? newTabStop;
+ var root = _contentRoot;
if (root == null)
return null;
bool internalCycleWorkaround = false;
- if (Current != null)
+ if (focusedElement != null)
{
- internalCycleWorkaround = CanProcessTabStop(isReverse);
+ internalCycleWorkaround = CanProcessTabStop(focusedElement, isReverse);
}
- if (currentFocus == null)
+ if (focusedElement == null)
{
if (!isReverse)
{
@@ -586,7 +572,7 @@ namespace Avalonia.Input
}
else if (!isReverse)
{
- newTabStop = GetNextTabStop();
+ newTabStop = GetNextTabStop(focusedElement);
if (newTabStop == null && (internalCycleWorkaround || queryOnly))
{
@@ -597,7 +583,7 @@ namespace Avalonia.Input
}
else
{
- newTabStop = GetPreviousTabStop();
+ newTabStop = GetPreviousTabStop(focusedElement);
if (newTabStop == null && (internalCycleWorkaround || queryOnly))
{
@@ -609,9 +595,9 @@ namespace Avalonia.Input
return newTabStop;
}
- private IInputElement? GetNextTabStop(IInputElement? currentTabStop = null, bool ignoreCurrentTabStop = false)
+ private IInputElement? GetNextTabStop(IInputElement? currentTabStop, bool ignoreCurrentTabStop = false)
{
- var focused = currentTabStop ?? Current;
+ var focused = currentTabStop;
if (focused == null || _contentRoot == null)
{
return null;
@@ -717,9 +703,9 @@ namespace Avalonia.Input
return newTabStop;
}
- private IInputElement? GetPreviousTabStop(IInputElement? currentTabStop = null, bool ignoreCurrentTabStop = false)
+ private IInputElement? GetPreviousTabStop(IInputElement? currentTabStop, bool ignoreCurrentTabStop = false)
{
- var focused = currentTabStop ?? Current;
+ var focused = currentTabStop;
if (focused == null || _contentRoot == null)
{
return null;
@@ -949,23 +935,23 @@ namespace Avalonia.Input
return int.MaxValue;
}
- private bool CanProcessTabStop(bool isReverse)
+ private bool CanProcessTabStop(IInputElement? focusedElement, bool isReverse)
{
bool isFocusOnFirst = false;
bool isFocusOnLast = false;
bool canProcessTab = true;
- if (IsFocusedElementInPopup())
+ if (IsFocusedElementInPopup(focusedElement))
{
return true;
}
if (isReverse)
{
- isFocusOnFirst = IsFocusOnFirstTabStop();
+ isFocusOnFirst = IsFocusOnFirstTabStop(focusedElement);
}
else
{
- isFocusOnLast = IsFocusOnLastTabStop();
+ isFocusOnLast = IsFocusOnLastTabStop(focusedElement);
}
if (isFocusOnFirst || isFocusOnLast)
@@ -980,7 +966,7 @@ namespace Avalonia.Input
if (edge != null)
{
var edgeParent = GetParentTabStopElement(edge);
- if (edgeParent is InputElement inputElement && KeyboardNavigation.GetTabNavigation(inputElement) == KeyboardNavigationMode.Once && edgeParent == GetParentTabStopElement(Current))
+ if (edgeParent is InputElement inputElement && KeyboardNavigation.GetTabNavigation(inputElement) == KeyboardNavigationMode.Once && edgeParent == GetParentTabStopElement(focusedElement))
{
canProcessTab = false;
}
@@ -994,13 +980,13 @@ namespace Avalonia.Input
{
if (isFocusOnLast || isFocusOnFirst)
{
- if (Current is InputElement inputElement && KeyboardNavigation.GetTabNavigation(inputElement) == KeyboardNavigationMode.Cycle)
+ if (focusedElement is InputElement inputElement && KeyboardNavigation.GetTabNavigation(inputElement) == KeyboardNavigationMode.Cycle)
{
canProcessTab = true;
}
else
{
- var focusedParent = GetParentTabStopElement(Current);
+ var focusedParent = GetParentTabStopElement(focusedElement);
while (focusedParent != null)
{
if (focusedParent is InputElement iE && KeyboardNavigation.GetTabNavigation(iE) == KeyboardNavigationMode.Cycle)
@@ -1060,9 +1046,9 @@ namespace Avalonia.Input
return null;
}
- private bool IsFocusOnLastTabStop()
+ private bool IsFocusOnLastTabStop(IInputElement? focusedElement)
{
- if (Current == null || _contentRoot is not Visual visual)
+ if (focusedElement == null || _contentRoot is not Visual visual)
return false;
var root = visual.VisualRoot as IInputElement;
@@ -1070,12 +1056,12 @@ namespace Avalonia.Input
var lastFocus = GetLastFocusableElement(root, null);
- return lastFocus == Current;
+ return lastFocus == focusedElement;
}
- private bool IsFocusOnFirstTabStop()
+ private bool IsFocusOnFirstTabStop(IInputElement? focusedElement)
{
- if (Current == null || _contentRoot is not Visual visual)
+ if (focusedElement == null || _contentRoot is not Visual visual)
return false;
var root = visual.VisualRoot as IInputElement;
@@ -1083,7 +1069,7 @@ namespace Avalonia.Input
var firstFocus = GetFirstFocusableElement(root, null);
- return firstFocus == Current;
+ return firstFocus == focusedElement;
}
private static IInputElement? GetFirstFocusableElement(IInputElement searchStart, IInputElement? firstFocus = null)
@@ -1110,7 +1096,7 @@ namespace Avalonia.Input
return lastFocus;
}
- private bool IsFocusedElementInPopup() => Current != null && GetRootOfPopupSubTree(Current) != null;
+ private bool IsFocusedElementInPopup(IInputElement? focusedElement) => focusedElement != null && GetRootOfPopupSubTree(focusedElement) != null;
private Visual? GetRootOfPopupSubTree(IInputElement? current)
{
@@ -1118,7 +1104,7 @@ namespace Avalonia.Input
return null;
}
- private bool FindAndSetNextFocus(NavigationDirection direction, XYFocusOptions xYFocusOptions)
+ private bool FindAndSetNextFocus(IInputElement? focusedElement, NavigationDirection direction, XYFocusOptions xYFocusOptions)
{
var focusChanged = false;
if (xYFocusOptions.UpdateManifoldsFromFocusHintRect && xYFocusOptions.FocusHintRectangle != null)
@@ -1126,7 +1112,7 @@ namespace Avalonia.Input
_xyFocus.SetManifoldsFromBounds(xYFocusOptions.FocusHintRectangle ?? default);
}
- if (FindNextFocus(direction, xYFocusOptions, false) is { } nextFocusedElement)
+ if (FindNextFocus(focusedElement, direction, xYFocusOptions, false) is { } nextFocusedElement)
{
focusChanged = nextFocusedElement.Focus();
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/Rect.cs b/src/Avalonia.Base/Rect.cs
index 58a8c56c8b..9c901254a6 100644
--- a/src/Avalonia.Base/Rect.cs
+++ b/src/Avalonia.Base/Rect.cs
@@ -1,7 +1,6 @@
using System;
using System.Globalization;
using System.Numerics;
-using Avalonia.Animation.Animators;
using Avalonia.Utilities;
namespace Avalonia
diff --git a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionTarget.cs b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionTarget.cs
index 81a3c09b35..e8ae84eb03 100644
--- a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionTarget.cs
+++ b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionTarget.cs
@@ -149,12 +149,16 @@ namespace Avalonia.Rendering.Composition.Server
try
{
- if (_renderTarget == null && !_compositor.IsReadyToCreateRenderTarget(_surfaces()))
+ if (_renderTarget == null)
{
- IsWaitingForReadyRenderTarget = IsEnabled;
- return;
+ if (!_compositor.IsReadyToCreateRenderTarget(_surfaces()))
+ {
+ IsWaitingForReadyRenderTarget = IsEnabled;
+ return;
+ }
+
+ _renderTarget = _compositor.CreateRenderTarget(_surfaces());
}
- _renderTarget ??= _compositor.CreateRenderTarget(_surfaces());
}
catch (RenderTargetNotReadyException)
{
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.Base/Threading/Dispatcher.Invoke.cs b/src/Avalonia.Base/Threading/Dispatcher.Invoke.cs
index fbd96ed7d8..90167fa8a1 100644
--- a/src/Avalonia.Base/Threading/Dispatcher.Invoke.cs
+++ b/src/Avalonia.Base/Threading/Dispatcher.Invoke.cs
@@ -737,9 +737,6 @@ public partial class Dispatcher
///
public static DispatcherPriorityAwaitable Yield(DispatcherPriority priority)
{
- // TODO12: Update to use Dispatcher.CurrentDispatcher once multi-dispatcher support is merged
- var current = UIThread;
- current.VerifyAccess();
- return UIThread.Resume(priority);
+ return CurrentDispatcher.Resume(priority);
}
}
diff --git a/src/Avalonia.Controls/Automation/Peers/CarouselPageAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/CarouselPageAutomationPeer.cs
new file mode 100644
index 0000000000..0744e43125
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/Peers/CarouselPageAutomationPeer.cs
@@ -0,0 +1,26 @@
+using Avalonia.Controls;
+
+namespace Avalonia.Automation.Peers;
+
+public class CarouselPageAutomationPeer : ControlAutomationPeer
+{
+ public CarouselPageAutomationPeer(CarouselPage owner)
+ : base(owner)
+ {
+ }
+
+ public new CarouselPage Owner => (CarouselPage)base.Owner;
+
+ protected override AutomationControlType GetAutomationControlTypeCore()
+ => AutomationControlType.Pane;
+
+ protected override string? GetNameCore()
+ {
+ var result = base.GetNameCore();
+
+ if (string.IsNullOrEmpty(result))
+ result = Owner.Header?.ToString();
+
+ return result;
+ }
+}
diff --git a/src/Avalonia.Controls/Automation/Peers/DrawerPageAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/DrawerPageAutomationPeer.cs
index 35477ae7d4..39a009967e 100644
--- a/src/Avalonia.Controls/Automation/Peers/DrawerPageAutomationPeer.cs
+++ b/src/Avalonia.Controls/Automation/Peers/DrawerPageAutomationPeer.cs
@@ -1,16 +1,24 @@
+using Avalonia.Automation.Provider;
using Avalonia.Controls;
namespace Avalonia.Automation.Peers;
-public class DrawerPageAutomationPeer : ControlAutomationPeer
+public class DrawerPageAutomationPeer : ControlAutomationPeer,
+ IExpandCollapseProvider
{
public DrawerPageAutomationPeer(DrawerPage owner)
: base(owner)
{
+ owner.PropertyChanged += OwnerPropertyChanged;
}
public new DrawerPage Owner => (DrawerPage)base.Owner;
+ public ExpandCollapseState ExpandCollapseState => ToState(Owner.IsOpen);
+ public bool ShowsMenu => false;
+ public void Collapse() => Owner.IsOpen = false;
+ public void Expand() => Owner.IsOpen = true;
+
protected override AutomationControlType GetAutomationControlTypeCore()
=> AutomationControlType.Pane;
@@ -23,4 +31,20 @@ public class DrawerPageAutomationPeer : ControlAutomationPeer
return result;
}
+
+ private void OwnerPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
+ {
+ if (e.Property == DrawerPage.IsOpenProperty)
+ {
+ RaisePropertyChangedEvent(
+ ExpandCollapsePatternIdentifiers.ExpandCollapseStateProperty,
+ ToState(e.GetOldValue()),
+ ToState(e.GetNewValue()));
+ }
+ }
+
+ private static ExpandCollapseState ToState(bool value)
+ {
+ return value ? ExpandCollapseState.Expanded : ExpandCollapseState.Collapsed;
+ }
}
diff --git a/src/Avalonia.Controls/Automation/Peers/TabbedPageAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/TabbedPageAutomationPeer.cs
index 44c0b35af6..e679d7109b 100644
--- a/src/Avalonia.Controls/Automation/Peers/TabbedPageAutomationPeer.cs
+++ b/src/Avalonia.Controls/Automation/Peers/TabbedPageAutomationPeer.cs
@@ -21,6 +21,22 @@ public class TabbedPageAutomationPeer : ControlAutomationPeer
if (string.IsNullOrEmpty(result))
result = Owner.Header?.ToString();
+ var index = Owner.SelectedIndex;
+ var tabCount = GetTabCount();
+
+ if (index >= 0 && tabCount > 0)
+ {
+ var header = Owner.SelectedPage?.Header?.ToString();
+ var position = $"Tab {index + 1} of {tabCount}";
+ var tabName = string.IsNullOrEmpty(header) ? position : $"{position}: {header}";
+ return string.IsNullOrEmpty(result) ? tabName : $"{result} {tabName}";
+ }
+
return result;
}
+
+ private int GetTabCount()
+ {
+ return Owner.GetTabCount();
+ }
}
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/Button.cs b/src/Avalonia.Controls/Button.cs
index ceadae432c..c094db57ec 100644
--- a/src/Avalonia.Controls/Button.cs
+++ b/src/Avalonia.Controls/Button.cs
@@ -543,10 +543,13 @@ namespace Avalonia.Controls
oldFlyout.Hide();
}
+ (oldFlyout as PopupFlyoutBase)?.SetDefaultPlacementTarget(null);
+
// Must unregister events here while a reference to the old flyout still exists
UnregisterFlyoutEvents(oldFlyout);
RegisterFlyoutEvents(newFlyout);
+ (newFlyout as PopupFlyoutBase)?.SetDefaultPlacementTarget(this);
UpdatePseudoClasses();
}
}
diff --git a/src/Avalonia.Controls/Carousel.cs b/src/Avalonia.Controls/Carousel.cs
index 533f7bb626..6e8ce8a287 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;
}
@@ -78,17 +199,63 @@ namespace Avalonia.Controls
{
base.OnApplyTemplate(e);
_scroller = e.NameScope.Find("PART_ScrollViewer");
+
+ if (ItemsPanelRoot is VirtualizingCarouselPanel panel)
+ panel.RefreshGestureRecognizer();
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
- if (change.Property == SelectedIndexProperty && _scroller is not null)
+ if (change.Property == SelectedIndexProperty)
{
- var value = change.GetNewValue();
- _scroller.Offset = new(value, 0);
+ 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)
+ {
+ 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 057a2371eb..d68e2c6f5e 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/ComboBox.cs b/src/Avalonia.Controls/ComboBox.cs
index c977ca0a38..eb1eab5856 100644
--- a/src/Avalonia.Controls/ComboBox.cs
+++ b/src/Avalonia.Controls/ComboBox.cs
@@ -62,13 +62,13 @@ namespace Avalonia.Controls
/// Defines the property.
///
public static readonly StyledProperty PlaceholderTextProperty =
- AvaloniaProperty.Register(nameof(PlaceholderText));
+ TextBox.PlaceholderTextProperty.AddOwner();
///
/// Defines the property.
///
public static readonly StyledProperty PlaceholderForegroundProperty =
- AvaloniaProperty.Register(nameof(PlaceholderForeground));
+ TextBox.PlaceholderForegroundProperty.AddOwner();
///
/// Defines the property.
diff --git a/src/Avalonia.Controls/CommandBar/CommandBar.cs b/src/Avalonia.Controls/CommandBar/CommandBar.cs
index 2f068cbb9f..392ffb7ddc 100644
--- a/src/Avalonia.Controls/CommandBar/CommandBar.cs
+++ b/src/Avalonia.Controls/CommandBar/CommandBar.cs
@@ -142,11 +142,9 @@ namespace Avalonia.Controls
OverflowItems = new ReadOnlyObservableCollection(_overflowItems);
var primaryCommands = new ObservableCollection();
- primaryCommands.CollectionChanged += OnPrimaryCommandsChanged;
SetCurrentValue(PrimaryCommandsProperty, (IList)primaryCommands);
var secondaryCommands = new ObservableCollection();
- secondaryCommands.CollectionChanged += OnSecondaryCommandsChanged;
SetCurrentValue(SecondaryCommandsProperty, (IList)secondaryCommands);
SizeChanged += CommandBar_SizeChanged;
diff --git a/src/Avalonia.Controls/Converters/BorderGapMaskConverter.cs b/src/Avalonia.Controls/Converters/BorderGapMaskConverter.cs
new file mode 100644
index 0000000000..913b2f1534
--- /dev/null
+++ b/src/Avalonia.Controls/Converters/BorderGapMaskConverter.cs
@@ -0,0 +1,126 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using Avalonia.Controls.Shapes;
+using Avalonia.Data.Converters;
+using Avalonia.Media;
+
+namespace Avalonia.Controls.Converters
+{
+ // Ported from https://github.com/dotnet/wpf/blob/main/src/Microsoft.DotNet.Wpf/src/PresentationFramework/System/Windows/Controls/BorderGapMaskConverter.cs
+
+ ///
+ /// Converter that generates the visual brush for
+ ///
+ public class BorderGapMaskConverter : IMultiValueConverter
+ {
+ ///
+ /// Convert a value.
+ ///
+ /// values as produced by source binding
+ /// target type
+ /// converter parameter
+ /// culture information
+ ///
+ /// Converted value.
+ /// Visual Brush that is used as the opacity mask for the Border
+ /// in the style for GroupBox.
+ ///
+ public object? Convert(IList values, Type targetType, object? parameter, CultureInfo culture)
+ {
+ //
+ // Parameter Validation
+ //
+ if (parameter == null ||
+ values == null ||
+ values.Count != 3 ||
+ values[0] is not double ||
+ values[1] is not double ||
+ values[2] is not double)
+ {
+ return AvaloniaProperty.UnsetValue;
+ }
+
+ if (parameter is not double && parameter is not string)
+ {
+ return AvaloniaProperty.UnsetValue;
+ }
+
+ //
+ // Conversion
+ //
+ double? headerWidth = (double?)values[0];
+ double? borderWidth = (double?)values[1];
+ double? borderHeight = (double?)values[2];
+
+ // Doesn't make sense to have a Grid
+ // with 0 as width or height
+ if (borderWidth == 0
+ || borderHeight == 0)
+ {
+ return null;
+ }
+
+ // Width of the line to the left of the header
+ // to be used to set the width of the first column of the Grid
+ double lineWidth;
+ if (parameter is string)
+ {
+ lineWidth = Double.Parse(((string)parameter), NumberFormatInfo.InvariantInfo);
+ }
+ else
+ {
+ lineWidth = (double)parameter;
+ }
+
+ Grid grid = new Grid
+ {
+ Width = borderWidth ?? 0,
+ Height = borderHeight ?? 0
+ };
+
+ ColumnDefinition colDef1 = new ColumnDefinition();
+ ColumnDefinition colDef2 = new ColumnDefinition();
+ ColumnDefinition colDef3 = new ColumnDefinition();
+ colDef1.Width = new GridLength(lineWidth);
+ colDef2.Width = new GridLength(headerWidth ?? 0);
+ colDef3.Width = new GridLength(1, GridUnitType.Star);
+ grid.ColumnDefinitions.Add(colDef1);
+ grid.ColumnDefinitions.Add(colDef2);
+ grid.ColumnDefinitions.Add(colDef3);
+ RowDefinition rowDef1 = new RowDefinition();
+ RowDefinition rowDef2 = new RowDefinition();
+ rowDef1.Height = new GridLength((borderHeight ?? 0) / 2);
+ rowDef2.Height = new GridLength(1, GridUnitType.Star);
+ grid.RowDefinitions.Add(rowDef1);
+ grid.RowDefinitions.Add(rowDef2);
+
+ Rectangle rectColumn1 = new Rectangle();
+ Rectangle rectColumn2 = new Rectangle();
+ Rectangle rectColumn3 = new Rectangle();
+ rectColumn1.Fill = Brushes.Black;
+ rectColumn2.Fill = Brushes.Black;
+ rectColumn3.Fill = Brushes.Black;
+
+ Grid.SetRowSpan(rectColumn1, 2);
+ Grid.SetRow(rectColumn1, 0);
+ Grid.SetColumn(rectColumn1, 0);
+
+ Grid.SetRow(rectColumn2, 1);
+ Grid.SetColumn(rectColumn2, 1);
+
+ Grid.SetRowSpan(rectColumn3, 2);
+ Grid.SetRow(rectColumn3, 0);
+ Grid.SetColumn(rectColumn3, 2);
+
+ grid.Children.Add(rectColumn1);
+ grid.Children.Add(rectColumn2);
+ grid.Children.Add(rectColumn3);
+
+ return (new VisualBrush(grid));
+ }
+ }
+}
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/Flyouts/FlyoutBase.cs b/src/Avalonia.Controls/Flyouts/FlyoutBase.cs
index b5328ccab8..b7de300e84 100644
--- a/src/Avalonia.Controls/Flyouts/FlyoutBase.cs
+++ b/src/Avalonia.Controls/Flyouts/FlyoutBase.cs
@@ -7,9 +7,8 @@ namespace Avalonia.Controls.Primitives
///
/// Defines the property
///
- public static readonly DirectProperty IsOpenProperty =
- AvaloniaProperty.RegisterDirect(nameof(IsOpen),
- x => x.IsOpen);
+ public static readonly StyledProperty IsOpenProperty =
+ AvaloniaProperty.Register(nameof(IsOpen));
///
/// Defines the property
@@ -23,19 +22,23 @@ namespace Avalonia.Controls.Primitives
public static readonly AttachedProperty AttachedFlyoutProperty =
AvaloniaProperty.RegisterAttached("AttachedFlyout", null);
- private bool _isOpen;
private Control? _target;
public event EventHandler? Opened;
public event EventHandler? Closed;
-
+
///
- /// Gets whether this Flyout is currently Open
+ /// Gets or sets whether this Flyout is currently open.
///
+ ///
+ /// Setting this property to true will show the flyout at the last known
+ /// placement target. If no target has been set via ,
+ /// setting this to true will have no effect.
+ ///
public bool IsOpen
{
- get => _isOpen;
- protected set => SetAndRaise(IsOpenProperty, ref _isOpen, value);
+ get => GetValue(IsOpenProperty);
+ set => SetValue(IsOpenProperty, value);
}
///
diff --git a/src/Avalonia.Controls/Flyouts/PopupFlyoutBase.cs b/src/Avalonia.Controls/Flyouts/PopupFlyoutBase.cs
index 19d1f52850..cea69524f1 100644
--- a/src/Avalonia.Controls/Flyouts/PopupFlyoutBase.cs
+++ b/src/Avalonia.Controls/Flyouts/PopupFlyoutBase.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.ComponentModel;
using System.Linq;
using Avalonia.Controls.Diagnostics;
@@ -67,9 +67,14 @@ namespace Avalonia.Controls.Primitives
private PixelRect? _enlargePopupRectScreenPixelRect;
private IDisposable? _transientDisposable;
private Action? _popupHostChangedHandler;
+ private bool _isOpen;
+ private bool _ignoreIsOpenChanged;
+ private Control? _lastPlacementTarget;
static PopupFlyoutBase()
{
+ IsOpenProperty.Changed.AddClassHandler(
+ (x, e) => x.IsOpenChanged((AvaloniaPropertyChangedEventArgs)e));
Control.ContextFlyoutProperty.Changed.Subscribe(OnContextFlyoutPropertyChanged);
}
@@ -136,9 +141,9 @@ namespace Avalonia.Controls.Primitives
/// through to the parent window.
///
///
- /// Clicks outside the popup cause the popup to close. When
- /// is set to false, these clicks will be
- /// handled by the popup and not be registered by the parent window. When set to true,
+ /// Clicks outside the popup cause the popup to close. When
+ /// is set to false, these clicks will be
+ /// handled by the popup and not be registered by the parent window. When set to true,
/// the events will be passed through to the parent window.
///
public bool OverlayDismissEventPassThrough
@@ -175,6 +180,16 @@ namespace Avalonia.Controls.Primitives
public event EventHandler? Closing;
public event EventHandler? Opening;
+ ///
+ /// Pre-registers a control as the default placement target for this flyout.
+ /// Used by owning controls (e.g. ) so that setting
+ /// to true works on first use.
+ ///
+ internal void SetDefaultPlacementTarget(Control? target)
+ {
+ _lastPlacementTarget = target;
+ }
+
///
/// Shows the Flyout at the given Control
///
@@ -205,7 +220,7 @@ namespace Avalonia.Controls.Primitives
/// True, if action was handled
protected virtual bool HideCore(bool canCancel = true)
{
- if (!IsOpen)
+ if (!_isOpen)
{
return false;
}
@@ -218,7 +233,11 @@ namespace Avalonia.Controls.Primitives
}
}
- IsOpen = false;
+ _isOpen = false;
+ using (BeginIgnoringIsOpen())
+ {
+ SetCurrentValue(IsOpenProperty, false);
+ }
Popup.IsOpen = false;
Popup.PlacementTarget = null;
@@ -251,7 +270,9 @@ namespace Avalonia.Controls.Primitives
throw new ArgumentNullException(nameof(placementTarget));
}
- if (IsOpen)
+ _lastPlacementTarget = placementTarget;
+
+ if (_isOpen)
{
if (placementTarget == Target)
{
@@ -280,7 +301,12 @@ namespace Avalonia.Controls.Primitives
}
PositionPopup(showAtPointer);
- IsOpen = Popup.IsOpen = true;
+ _isOpen = true;
+ using (BeginIgnoringIsOpen())
+ {
+ SetCurrentValue(IsOpenProperty, true);
+ }
+ Popup.IsOpen = true;
OnOpened();
placementTarget.DetachedFromVisualTree += PlacementTarget_DetachedFromVisualTree;
@@ -310,6 +336,7 @@ namespace Avalonia.Controls.Primitives
private void PlacementTarget_DetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_ = HideCore(false);
+ _lastPlacementTarget = null;
}
private void HandleTransientDismiss(RawInputEventArgs args)
@@ -318,7 +345,7 @@ namespace Avalonia.Controls.Primitives
{
// In ShowMode = TransientWithDismissOnPointerMoveAway, the Flyout is kept
// shown as long as the pointer is within a certain px distance from the
- // flyout itself. I'm not sure what WinUI uses, but I'm defaulting to
+ // flyout itself. I'm not sure what WinUI uses, but I'm defaulting to
// 100px, which seems about right
// enlargedPopupRect is the Flyout bounds enlarged 100px
// For windowed popups, enlargedPopupRect is in screen coordinates,
@@ -348,7 +375,7 @@ namespace Avalonia.Controls.Primitives
// As long as the pointer stays within the enlargedPopupRect
// the flyout stays open. If it leaves, close it
// Despite working in screen coordinates, leaving the TopLevel
- // window will not close this (as pointer events stop), which
+ // window will not close this (as pointer events stop), which
// does match UWP
var pt = eventRoot.PointToScreen(pArgs.Position);
if (!_enlargePopupRectScreenPixelRect?.Contains(pt) ?? false)
@@ -401,14 +428,18 @@ namespace Avalonia.Controls.Primitives
private void OnPopupOpened(object? sender, EventArgs e)
{
- IsOpen = true;
+ _isOpen = true;
+ using (BeginIgnoringIsOpen())
+ {
+ SetCurrentValue(IsOpenProperty, true);
+ }
_popupHostChangedHandler?.Invoke(Popup.Host);
}
private void OnPopupClosing(object? sender, CancelEventArgs e)
{
- if (IsOpen)
+ if (_isOpen)
{
e.Cancel = CancelClosing();
}
@@ -425,7 +456,7 @@ namespace Avalonia.Controls.Primitives
private void OnPlacementTargetOrPopupKeyUp(object? sender, KeyEventArgs e)
{
if (!e.Handled
- && IsOpen
+ && _isOpen
&& Target?.ContextFlyout == this)
{
var keymap = Application.Current!.PlatformSettings?.HotkeyConfiguration;
@@ -437,6 +468,60 @@ namespace Avalonia.Controls.Primitives
}
}
+ private void IsOpenChanged(AvaloniaPropertyChangedEventArgs e)
+ {
+ if (_ignoreIsOpenChanged)
+ {
+ return;
+ }
+
+ if (e.NewValue.Value)
+ {
+ if (_lastPlacementTarget != null && ShowAtCore(_lastPlacementTarget))
+ {
+ return;
+ }
+
+ // No target, or opening was cancelled — revert so IsOpen stays honest
+ using (BeginIgnoringIsOpen())
+ {
+ SetCurrentValue(IsOpenProperty, false);
+ }
+ }
+ else
+ {
+ if (!HideCore())
+ {
+ // Closing was cancelled — revert so IsOpen stays honest
+ using (BeginIgnoringIsOpen())
+ {
+ SetCurrentValue(IsOpenProperty, true);
+ }
+ }
+ }
+ }
+
+ private IgnoreIsOpenScope BeginIgnoringIsOpen()
+ {
+ return new IgnoreIsOpenScope(this);
+ }
+
+ private readonly struct IgnoreIsOpenScope : IDisposable
+ {
+ private readonly PopupFlyoutBase _owner;
+
+ public IgnoreIsOpenScope(PopupFlyoutBase owner)
+ {
+ _owner = owner;
+ _owner._ignoreIsOpenChanged = true;
+ }
+
+ public void Dispose()
+ {
+ _owner._ignoreIsOpenChanged = false;
+ }
+ }
+
private void PositionPopup(bool showAtPointer)
{
Size sz;
diff --git a/src/Avalonia.Controls/NumericUpDown/NumericUpDown.cs b/src/Avalonia.Controls/NumericUpDown/NumericUpDown.cs
index ddaa57f2f2..2f901860ba 100644
--- a/src/Avalonia.Controls/NumericUpDown/NumericUpDown.cs
+++ b/src/Avalonia.Controls/NumericUpDown/NumericUpDown.cs
@@ -112,7 +112,9 @@ namespace Avalonia.Controls
/// Defines the property.
///
public static readonly StyledProperty PlaceholderTextProperty =
- AvaloniaProperty.Register(nameof(PlaceholderText));
+#pragma warning disable AVP1013
+ TextBox.PlaceholderTextProperty.AddOwner();
+#pragma warning restore AVP1013
///
/// Defines the property.
diff --git a/src/Avalonia.Controls/NumericUpDown/NumericUpDownValueChangedEventArgs.cs b/src/Avalonia.Controls/NumericUpDown/NumericUpDownValueChangedEventArgs.cs
index af835541ae..a5cc9de8b1 100644
--- a/src/Avalonia.Controls/NumericUpDown/NumericUpDownValueChangedEventArgs.cs
+++ b/src/Avalonia.Controls/NumericUpDown/NumericUpDownValueChangedEventArgs.cs
@@ -4,7 +4,7 @@ namespace Avalonia.Controls
{
public class NumericUpDownValueChangedEventArgs : RoutedEventArgs
{
- public NumericUpDownValueChangedEventArgs(RoutedEvent routedEvent, decimal? oldValue, decimal? newValue) : base(routedEvent)
+ public NumericUpDownValueChangedEventArgs(RoutedEvent? routedEvent, decimal? oldValue, decimal? newValue) : base(routedEvent)
{
OldValue = oldValue;
NewValue = newValue;
diff --git a/src/Avalonia.Controls/Page/CarouselPage.cs b/src/Avalonia.Controls/Page/CarouselPage.cs
new file mode 100644
index 0000000000..1b640fd528
--- /dev/null
+++ b/src/Avalonia.Controls/Page/CarouselPage.cs
@@ -0,0 +1,418 @@
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+using Avalonia.Animation;
+using Avalonia.Automation;
+using Avalonia.Automation.Peers;
+using Avalonia.Collections;
+using Avalonia.Controls.Metadata;
+using Avalonia.Controls.Presenters;
+using Avalonia.Controls.Primitives;
+using Avalonia.Controls.Templates;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Media;
+using Avalonia.Threading;
+
+namespace Avalonia.Controls
+{
+ ///
+ /// A page that displays its child pages in a horizontally scrollable carousel,
+ /// with optional animated page transitions.
+ ///
+ [TemplatePart("PART_Carousel", typeof(Carousel))]
+ public class CarouselPage : SelectingMultiPage
+ {
+ private Carousel? _carousel;
+
+ private static readonly FuncTemplate DefaultPanel =
+ new FuncTemplate(() => new VirtualizingCarouselPanel());
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty> ItemsPanelProperty =
+ ItemsControl.ItemsPanelProperty.AddOwner();
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty PageTransitionProperty =
+ AvaloniaProperty.Register(nameof(PageTransition));
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty IsGestureEnabledProperty =
+ AvaloniaProperty.Register(nameof(IsGestureEnabled), true);
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty IsKeyboardNavigationEnabledProperty =
+ AvaloniaProperty.Register(nameof(IsKeyboardNavigationEnabled), true);
+
+ static CarouselPage()
+ {
+ ItemsPanelProperty.OverrideDefaultValue(DefaultPanel);
+ FocusableProperty.OverrideDefaultValue(true);
+ }
+
+ public CarouselPage()
+ {
+ SetCurrentValue(PagesProperty, new AvaloniaList());
+ }
+
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ AddHandler(PointerWheelChangedEvent, OnPointerWheelChanged, RoutingStrategies.Bubble);
+ }
+
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnDetachedFromVisualTree(e);
+ RemoveHandler(PointerWheelChangedEvent, OnPointerWheelChanged);
+ }
+
+ ///
+ /// Gets or sets the items panel template used to arrange page items.
+ ///
+ public ITemplate ItemsPanel
+ {
+ get => GetValue(ItemsPanelProperty);
+ set => SetValue(ItemsPanelProperty, value);
+ }
+
+ ///
+ /// Gets or sets the animated page transition used when the selected page changes.
+ ///
+ public IPageTransition? PageTransition
+ {
+ get => GetValue(PageTransitionProperty);
+ set => SetValue(PageTransitionProperty, value);
+ }
+
+ ///
+ /// Gets or sets whether swipe and scroll gestures can be used to navigate between pages.
+ ///
+ public bool IsGestureEnabled
+ {
+ get => GetValue(IsGestureEnabledProperty);
+ set => SetValue(IsGestureEnabledProperty, value);
+ }
+
+ ///
+ /// Gets or sets whether keyboard shortcuts (arrow keys, Home/End) can be used to navigate between pages.
+ ///
+ public bool IsKeyboardNavigationEnabled
+ {
+ get => GetValue(IsKeyboardNavigationEnabledProperty);
+ set => SetValue(IsKeyboardNavigationEnabledProperty, value);
+ }
+
+ protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
+ {
+ base.OnApplyTemplate(e);
+
+ if (_carousel != null)
+ {
+ _carousel.SelectionChanged -= OnCarouselSelectionChanged;
+ _carousel.ContainerPrepared -= OnCarouselContainerPrepared;
+ }
+
+ _carousel = e.NameScope.Find("PART_Carousel");
+
+ if (_carousel != null)
+ {
+ _carousel.SelectionChanged += OnCarouselSelectionChanged;
+ _carousel.ContainerPrepared += OnCarouselContainerPrepared;
+ _carousel.PageTransition = PageTransition;
+ _carousel.ItemsPanel = ItemsPanel;
+ _carousel.ItemTemplate = PageTemplate;
+ _carousel.IsSwipeEnabled = IsGestureEnabled;
+ _carousel.ItemsSource = (IEnumerable?)ItemsSource ?? Pages;
+
+ if (SelectedIndex >= 0)
+ {
+ _carousel.SelectedIndex = SelectedIndex;
+ }
+
+ UpdateActivePage();
+ }
+ }
+
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+
+ if (change.Property == PageTransitionProperty && _carousel != null)
+ _carousel.PageTransition = change.GetNewValue();
+ else if (change.Property == ItemsPanelProperty && _carousel != null)
+ _carousel.ItemsPanel = change.GetNewValue>();
+ else if (change.Property == PageTemplateProperty && _carousel != null)
+ {
+ _carousel.ItemTemplate = change.GetNewValue();
+ if (ItemsSource != null)
+ UpdateActivePage();
+ }
+ else if (change.Property == IsGestureEnabledProperty && _carousel != null)
+ _carousel.IsSwipeEnabled = change.GetNewValue();
+ else if (change.Property == ItemsSourceProperty && _carousel != null)
+ {
+ _carousel.ItemsSource = change.GetNewValue() ?? Pages;
+ UpdateActivePage();
+ }
+ else if (change.Property == PagesProperty && ItemsSource == null && _carousel != null)
+ _carousel.ItemsSource = change.GetNewValue?>();
+ }
+
+ protected override void UpdateActivePage(NavigationType navigationType)
+ {
+ if (_carousel != null)
+ {
+ var index = _carousel.SelectedIndex;
+ if (index >= 0)
+ {
+ UpdateSelection(index, navigationType);
+ }
+ else if (GetPageCount() > 0)
+ {
+ ApplySelectedIndex(0);
+ }
+ }
+ else if (GetPageCount() > 0)
+ {
+ var index = CoercePreTemplateSelectedIndex(SelectedIndex);
+ if (ItemsSource != null)
+ {
+ StoreSelectedIndex(index);
+ }
+ else
+ {
+ var page = ResolvePageAtIndex(index);
+
+ if (index != SelectedIndex || !ReferenceEquals(SelectedPage, page))
+ CommitSelection(index, page, navigationType);
+ }
+ }
+ }
+
+ protected override void ApplySelectedIndex(int index)
+ {
+ if (_carousel != null)
+ {
+ _carousel.SelectedIndex = index;
+ }
+ else
+ {
+ var pageCount = GetPageCount();
+
+ if (pageCount > 0)
+ {
+ var coercedIndex = CoercePreTemplateSelectedIndex(index);
+ if (ItemsSource != null)
+ {
+ StoreSelectedIndex(coercedIndex);
+ }
+ else
+ {
+ var newPage = ResolvePageAtIndex(coercedIndex);
+ CommitSelection(coercedIndex, newPage);
+ }
+ }
+ else
+ {
+ // Preserve preselection until pages exist.
+ StoreSelectedIndex(index);
+ }
+ }
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+ if (e.Handled || !IsKeyboardNavigationEnabled)
+ return;
+
+ bool isRtl = IsRightToLeft;
+ bool next = isRtl ? e.Key == Key.Left : e.Key == Key.Right;
+ bool prev = isRtl ? e.Key == Key.Right : e.Key == Key.Left;
+
+ var pageCount = GetPageCount();
+ if (next || e.Key == Key.Down)
+ {
+ if (SelectedIndex < pageCount - 1)
+ {
+ ApplySelectedIndex(SelectedIndex + 1);
+ e.Handled = true;
+ }
+ }
+ else if (prev || e.Key == Key.Up)
+ {
+ if (SelectedIndex > 0)
+ {
+ ApplySelectedIndex(SelectedIndex - 1);
+ e.Handled = true;
+ }
+ }
+ else if (e.Key == Key.Home)
+ {
+ if (pageCount > 0 && SelectedIndex != 0)
+ {
+ ApplySelectedIndex(0);
+ e.Handled = true;
+ }
+ }
+ else if (e.Key == Key.End)
+ {
+ if (pageCount > 0 && SelectedIndex != pageCount - 1)
+ {
+ ApplySelectedIndex(pageCount - 1);
+ e.Handled = true;
+ }
+ }
+ }
+
+ private void OnCarouselSelectionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ if (_carousel == null)
+ return;
+
+ var newIndex = _carousel.SelectedIndex;
+ var newPage = ResolveDisplayedPageAtIndex(newIndex);
+ if (newIndex == SelectedIndex && ReferenceEquals(newPage, SelectedPage))
+ return;
+
+ UpdateSelection(newIndex, NavigationType.Replace);
+ }
+
+ private void OnCarouselContainerPrepared(object? sender, ContainerPreparedEventArgs e)
+ {
+ if (_carousel == null || e.Index != _carousel.SelectedIndex)
+ return;
+
+ Dispatcher.UIThread.Post(
+ () =>
+ {
+ if (_carousel != null && _carousel.SelectedIndex == e.Index)
+ UpdateSelection(e.Index, NavigationType.Replace);
+ },
+ DispatcherPriority.Loaded);
+ }
+
+ private void OnPointerWheelChanged(object? sender, PointerWheelEventArgs e)
+ {
+ if (!IsGestureEnabled)
+ return;
+
+ bool isRtl = IsRightToLeft;
+ var pageCount = GetPageCount();
+ bool goNext = e.Delta.Y < 0 || (isRtl ? e.Delta.X < 0 : e.Delta.X > 0);
+ bool goPrev = e.Delta.Y > 0 || (isRtl ? e.Delta.X > 0 : e.Delta.X < 0);
+
+ if (goNext && SelectedIndex < pageCount - 1)
+ {
+ ApplySelectedIndex(SelectedIndex + 1);
+ e.Handled = true;
+ }
+ else if (goPrev && SelectedIndex > 0)
+ {
+ ApplySelectedIndex(SelectedIndex - 1);
+ e.Handled = true;
+ }
+ }
+
+ protected override AutomationPeer OnCreateAutomationPeer() => new CarouselPageAutomationPeer(this);
+
+ private void UpdateAccessibilityName(int index, int pageCount, Page? page)
+ {
+ var header = page?.Header?.ToString();
+ var position = pageCount > 0 ? $"Page {index + 1} of {pageCount}" : string.Empty;
+ var name = string.IsNullOrEmpty(header) ? position : string.IsNullOrEmpty(position) ? header : $"{position}: {header}";
+ // CarouselPageAutomationPeer.GetNameCore reads this via base.GetNameCore(), which returns
+ // AutomationProperties.Name when set. Position and header are encoded here rather than in the
+ // peer so that the name stays current without requiring the peer to re-query the carousel state.
+ AutomationProperties.SetName(this, name);
+ }
+
+ private bool IsRightToLeft => FlowDirection == Media.FlowDirection.RightToLeft;
+
+ private int GetPageCount()
+ {
+ if (_carousel != null)
+ return _carousel.ItemCount;
+
+ var source = (IEnumerable?)ItemsSource ?? Pages;
+
+ if (source is ICollection nonGenericCol)
+ return nonGenericCol.Count;
+ if (source is ICollection col)
+ return col.Count;
+ if (source != null)
+ {
+ int count = 0;
+ foreach (var _ in source)
+ count++;
+ return count;
+ }
+ return 0;
+ }
+
+ private int CoercePreTemplateSelectedIndex(int index)
+ {
+ var pageCount = GetPageCount();
+ if (pageCount <= 0)
+ return index;
+
+ return (uint)index < (uint)pageCount ? index : 0;
+ }
+
+ private void UpdateSelection(int index, NavigationType navigationType)
+ {
+ var page = ResolveDisplayedPageAtIndex(index);
+ var pageCount = GetPageCount();
+
+ if (page == null && ItemsSource != null)
+ {
+ StoreSelectedIndex(index);
+ UpdateAccessibilityName(index, pageCount, null);
+ return;
+ }
+
+ CommitSelection(index, page, navigationType);
+ UpdateAccessibilityName(index, pageCount, page);
+ }
+
+ private Page? ResolveDisplayedPageAtIndex(int index)
+ {
+ if (index < 0)
+ return null;
+
+ if (_carousel?.ContainerFromIndex(index) is { } container)
+ return TryGetPageFromContainer(container);
+
+ return ItemsSource == null ? ResolvePageAtIndex(index) : null;
+ }
+
+ private static Page? TryGetPageFromContainer(Control container)
+ {
+ if (container is Page page)
+ return page;
+
+ if (container is ContentPresenter presenter)
+ {
+ if (presenter.Child == null)
+ presenter.UpdateChild();
+
+ return presenter.Child as Page;
+ }
+
+ if (container is ContentControl contentControl)
+ return contentControl.Content as Page;
+
+ return null;
+ }
+
+ }
+}
diff --git a/src/Avalonia.Controls/Page/ContentPage.cs b/src/Avalonia.Controls/Page/ContentPage.cs
index 62f6c82dad..b7523b4688 100644
--- a/src/Avalonia.Controls/Page/ContentPage.cs
+++ b/src/Avalonia.Controls/Page/ContentPage.cs
@@ -1,3 +1,4 @@
+using System;
using Avalonia.Automation.Peers;
using Avalonia.Controls.Metadata;
using Avalonia.Controls.Presenters;
@@ -25,7 +26,14 @@ namespace Avalonia.Controls
/// Defines the property.
///
public static readonly StyledProperty ContentProperty =
- ContentControl.ContentProperty.AddOwner();
+ ContentControl.ContentProperty.AddOwner(new StyledPropertyMetadata(
+ coerce: (_, val) =>
+ {
+ if (val is Page)
+ throw new InvalidOperationException(
+ "A Page cannot be used as the content of a ContentPage. Use a MultiPage subclass such as NavigationPage, TabbedPage, DrawerPage, or CarouselPage to host child pages.");
+ return val;
+ }));
///
/// Defines the property.
@@ -89,6 +97,14 @@ namespace Avalonia.Controls
///
/// Gets or sets the page content.
///
+ ///
+ /// The content must not be another . Use a
+ /// implementation such as , ,
+ /// , or to host child pages.
+ ///
+ ///
+ /// Thrown when the assigned value is a .
+ ///
[Content]
[DependsOn(nameof(ContentTemplate))]
public object? Content
@@ -172,8 +188,6 @@ namespace Avalonia.Controls
: Padding;
_contentPresenter.InvalidateMeasure();
- if (Content is Page childPage)
- childPage.SafeAreaPadding = Padding.GetRemainingSafeAreaPadding(SafeAreaPadding);
}
}
@@ -182,9 +196,6 @@ namespace Avalonia.Controls
if (e.OldValue is ILogical oldChild)
LogicalChildren.Remove(oldChild);
- if (e.OldValue is Page oldPage)
- oldPage.SafeAreaPadding = default;
-
if (e.NewValue is ILogical newChild)
LogicalChildren.Add(newChild);
}
diff --git a/src/Avalonia.Controls/Page/DrawerBehavior.cs b/src/Avalonia.Controls/Page/DrawerBehavior.cs
index dda02ab3e8..cfc09819da 100644
--- a/src/Avalonia.Controls/Page/DrawerBehavior.cs
+++ b/src/Avalonia.Controls/Page/DrawerBehavior.cs
@@ -16,10 +16,17 @@ namespace Avalonia.Controls
///
/// The drawer is permanently open and cannot be closed.
///
+ ///
+ /// Setting this value forces to .
+ ///
Locked,
///
/// The drawer is hidden and cannot be opened.
///
+ ///
+ /// Setting this value forces to .
+ /// The event is not raised.
+ ///
Disabled
}
}
diff --git a/src/Avalonia.Controls/Page/DrawerClosingEventArgs.cs b/src/Avalonia.Controls/Page/DrawerClosingEventArgs.cs
index 574ebbd406..7c9d3de86d 100644
--- a/src/Avalonia.Controls/Page/DrawerClosingEventArgs.cs
+++ b/src/Avalonia.Controls/Page/DrawerClosingEventArgs.cs
@@ -7,7 +7,11 @@ namespace Avalonia.Controls
///
public class DrawerClosingEventArgs : RoutedEventArgs
{
- internal DrawerClosingEventArgs(RoutedEvent routedEvent) : base(routedEvent) { }
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The routed event associated with these event data.
+ public DrawerClosingEventArgs(RoutedEvent? routedEvent) : base(routedEvent) { }
///
/// Gets or sets a value indicating whether the closing should be cancelled.
diff --git a/src/Avalonia.Controls/Page/DrawerPage.cs b/src/Avalonia.Controls/Page/DrawerPage.cs
index 814e533939..954ec9c918 100644
--- a/src/Avalonia.Controls/Page/DrawerPage.cs
+++ b/src/Avalonia.Controls/Page/DrawerPage.cs
@@ -1,4 +1,5 @@
using System;
+using Avalonia.Automation;
using Avalonia.Automation.Peers;
using Avalonia.Controls.Documents;
using Avalonia.Controls.Metadata;
@@ -28,9 +29,6 @@ namespace Avalonia.Controls
[TemplatePart("PART_PaneButton", typeof(ToggleButton))]
[TemplatePart("PART_CompactPaneToggle", typeof(ToggleButton))]
[TemplatePart("PART_Backdrop", typeof(Border))]
- [TemplatePart("PART_CompactPaneIconPresenter", typeof(ContentPresenter))]
- [TemplatePart("PART_PaneIconPresenter", typeof(ContentPresenter))]
- [TemplatePart("PART_BottomPaneIconPresenter", typeof(ContentPresenter))]
[PseudoClasses(":placement-right", ":placement-top", ":placement-bottom", ":detail-is-navpage")]
public class DrawerPage : Page
{
@@ -85,10 +83,10 @@ namespace Avalonia.Controls
validate: ValidateLength);
///
- /// Defines the property.
+ /// Defines the property.
///
- public static readonly StyledProperty DrawerBreakpointWidthProperty =
- AvaloniaProperty.Register(nameof(DrawerBreakpointWidth), 0d);
+ public static readonly StyledProperty DrawerBreakpointLengthProperty =
+ AvaloniaProperty.Register(nameof(DrawerBreakpointLength), 0d);
///
/// Defines the property.
@@ -132,6 +130,12 @@ namespace Avalonia.Controls
public static readonly StyledProperty DrawerIconProperty =
AvaloniaProperty.Register(nameof(DrawerIcon));
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty DrawerIconTemplateProperty =
+ AvaloniaProperty.Register(nameof(DrawerIconTemplate));
+
private static readonly DefaultPageDataTemplate s_defaultPageDataTemplate = new DefaultPageDataTemplate();
///
@@ -200,17 +204,16 @@ namespace Avalonia.Controls
public static readonly StyledProperty DisplayModeProperty =
SplitView.DisplayModeProperty.AddOwner();
+
private ContentPresenter? _contentPresenter;
private ContentPresenter? _drawerPresenter;
private ContentPresenter? _drawerHeaderPresenter;
private ContentPresenter? _drawerFooterPresenter;
- private ContentPresenter? _compactPaneIconPresenter;
- private ContentPresenter? _paneIconPresenter;
- private ContentPresenter? _bottomPaneIconPresenter;
private SplitView? _splitView;
private Border? _topBar;
private ToggleButton? _paneButton;
private Border? _backdrop;
+ private Point _swipeStartPoint;
private IDisposable? _navBarVisibleSub;
private const double EdgeGestureWidth = 20;
@@ -292,6 +295,7 @@ namespace Avalonia.Controls
public DrawerPage()
{
GestureRecognizers.Add(_swipeRecognizer);
+ UpdateSwipeRecognizerAxes();
}
///
@@ -318,6 +322,15 @@ namespace Avalonia.Controls
///
/// Gets or sets whether the drawer pane is currently open.
///
+ ///
+ /// Setting this property to while is
+ /// is a no-op; the value is coerced back to
+ /// . When closing programmatically, the event
+ /// is raised and can be cancelled; if cancelled, the property reverts to
+ /// . The event is not raised when the drawer
+ /// is forced closed because is set to
+ /// .
+ ///
public bool IsOpen
{
get => GetValue(IsOpenProperty);
@@ -343,12 +356,12 @@ namespace Avalonia.Controls
}
///
- /// Gets or sets the width threshold for switching to overlay mode.
+ /// Gets or sets the size threshold for switching to overlay mode. Set to 0 to disable.
///
- public double DrawerBreakpointWidth
+ public double DrawerBreakpointLength
{
- get => GetValue(DrawerBreakpointWidthProperty);
- set => SetValue(DrawerBreakpointWidthProperty, value);
+ get => GetValue(DrawerBreakpointLengthProperty);
+ set => SetValue(DrawerBreakpointLengthProperty, value);
}
///
@@ -414,6 +427,15 @@ namespace Avalonia.Controls
set => SetValue(DrawerIconProperty, value);
}
+ ///
+ /// Gets or sets the data template used to display the drawer icon.
+ ///
+ public IDataTemplate? DrawerIconTemplate
+ {
+ get => GetValue(DrawerIconTemplateProperty);
+ set => SetValue(DrawerIconTemplateProperty, value);
+ }
+
///
/// Gets or sets the data template used to display content.
///
@@ -517,25 +539,24 @@ namespace Avalonia.Controls
{
base.OnApplyTemplate(e);
- if (_backdrop != null)
- _backdrop.PointerPressed -= OnBackdropPressed;
+ DetachBackdropPointerPressed();
_contentPresenter = e.NameScope.Find("PART_ContentPresenter");
_drawerPresenter = e.NameScope.Find("PART_DrawerPresenter");
_drawerHeaderPresenter = e.NameScope.Find("PART_DrawerHeader");
_drawerFooterPresenter = e.NameScope.Find("PART_DrawerFooter");
- _compactPaneIconPresenter = e.NameScope.Find("PART_CompactPaneIconPresenter");
- _paneIconPresenter = e.NameScope.Find("PART_PaneIconPresenter");
- _bottomPaneIconPresenter = e.NameScope.Find("PART_BottomPaneIconPresenter");
_splitView = e.NameScope.Find("PART_SplitView");
_topBar = e.NameScope.Find("PART_TopBar");
_paneButton = e.NameScope.Find("PART_PaneButton");
_backdrop = e.NameScope.Find("PART_Backdrop");
- UpdateIconPresenters();
-
if (_backdrop != null)
- _backdrop.PointerPressed += OnBackdropPressed;
+ {
+ if (IsAttachedToVisualTree)
+ AttachBackdropPointerPressed();
+
+ AutomationProperties.SetAccessibilityView(_backdrop, AccessibilityView.Raw);
+ }
ApplyForeground(_drawerHeaderPresenter, DrawerHeaderForeground);
ApplyForeground(_drawerFooterPresenter, DrawerFooterForeground);
@@ -551,11 +572,7 @@ namespace Avalonia.Controls
{
base.OnPropertyChanged(change);
- if (change.Property == DrawerIconProperty)
- {
- UpdateIconPresenters();
- }
- else if (change.Property == DrawerProperty || change.Property == ContentProperty)
+ if (change.Property == DrawerProperty || change.Property == ContentProperty)
{
if (change.OldValue is ILogical oldLogical)
LogicalChildren.Remove(oldLogical);
@@ -565,6 +582,7 @@ namespace Avalonia.Controls
if (change.Property == ContentProperty)
{
+ _hasHadFirstPage = false;
_navBarVisibleSub?.Dispose();
_navBarVisibleSub = null;
@@ -603,11 +621,14 @@ namespace Avalonia.Controls
}
else if (change.Property == DrawerBehaviorProperty ||
change.Property == DrawerLayoutBehaviorProperty ||
- change.Property == DrawerBreakpointWidthProperty)
+ change.Property == DrawerBreakpointLengthProperty)
{
UpdateSplitViewDisplayMode();
+
+ if (change.Property == DrawerBehaviorProperty && Content is NavigationPage nav)
+ nav.SetDrawerPage(this);
}
- else if (change.Property == BoundsProperty && DrawerBreakpointWidth > 0)
+ else if (change.Property == BoundsProperty && DrawerBreakpointLength > 0)
{
UpdateSplitViewDisplayMode();
}
@@ -617,6 +638,7 @@ namespace Avalonia.Controls
}
else if (change.Property == DrawerPlacementProperty)
{
+ UpdateSwipeRecognizerAxes();
UpdatePanePlacement();
UpdateContentSafeAreaPadding();
}
@@ -643,9 +665,10 @@ namespace Avalonia.Controls
base.OnAttachedToVisualTree(e);
AddHandler(InputElement.SwipeGestureEvent, OnSwipeGesture);
+ AddHandler(PointerPressedEvent, OnSwipePointerPressed, handledEventsToo: true);
- if (_backdrop != null)
- _backdrop.PointerPressed += OnBackdropPressed;
+ AttachBackdropPointerPressed();
+ RestoreNavigationState();
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
@@ -653,10 +676,27 @@ namespace Avalonia.Controls
base.OnDetachedFromVisualTree(e);
RemoveHandler(InputElement.SwipeGestureEvent, OnSwipeGesture);
+ RemoveHandler(PointerPressedEvent, OnSwipePointerPressed);
- if (_backdrop != null)
- _backdrop.PointerPressed -= OnBackdropPressed;
+ DetachBackdropPointerPressed();
+
+ ClearNavigationState();
+ }
+
+ private void RestoreNavigationState()
+ {
+ if (Content is not NavigationPage nav)
+ return;
+ _navBarVisibleSub?.Dispose();
+ nav.SetDrawerPage(this);
+ _navBarVisibleSub = nav.GetObservable(NavigationPage.IsNavBarEffectivelyVisibleProperty)
+ .Subscribe(new AnonymousObserver(_ => UpdateDetailNavBarVisiblePseudoClass()));
+ UpdateDetailNavBarVisiblePseudoClass();
+ }
+
+ private void ClearNavigationState()
+ {
_navBarVisibleSub?.Dispose();
_navBarVisibleSub = null;
@@ -664,6 +704,25 @@ namespace Avalonia.Controls
nav.SetDrawerPage(null);
}
+ private void AttachBackdropPointerPressed()
+ {
+ if (_backdrop != null)
+ _backdrop.PointerPressed += OnBackdropPressed;
+ }
+
+ private void DetachBackdropPointerPressed()
+ {
+ if (_backdrop != null)
+ _backdrop.PointerPressed -= OnBackdropPressed;
+ }
+
+
+ private void UpdateSwipeRecognizerAxes()
+ {
+ _swipeRecognizer.CanVerticallySwipe = IsVerticalPlacement;
+ _swipeRecognizer.CanHorizontallySwipe = !IsVerticalPlacement;
+ }
+
protected override void OnLoaded(RoutedEventArgs e)
{
base.OnLoaded(e);
@@ -675,6 +734,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 +778,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 +810,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)
{
@@ -860,7 +924,7 @@ namespace Avalonia.Controls
if (_splitView == null)
return;
- if (DrawerBreakpointWidth > 0 && previousMode != mode)
+ if (DrawerBreakpointLength > 0 && previousMode != mode)
{
if (mode == SplitViewDisplayMode.Inline)
{
@@ -888,9 +952,13 @@ namespace Avalonia.Controls
return SplitViewDisplayMode.Overlay;
}
- var breakpoint = DrawerBreakpointWidth;
- if (!IsVerticalPlacement && breakpoint > 0 && Bounds.Width > 0 && Bounds.Width < breakpoint)
- return SplitViewDisplayMode.Overlay;
+ var breakpoint = DrawerBreakpointLength;
+ if (breakpoint > 0)
+ {
+ var length = IsVerticalPlacement ? Bounds.Height : Bounds.Width;
+ if (length > 0 && length < breakpoint)
+ return SplitViewDisplayMode.Overlay;
+ }
switch (DrawerLayoutBehavior)
{
@@ -938,51 +1006,6 @@ namespace Avalonia.Controls
e.Handled = true;
}
- private void UpdateIconPresenters()
- {
- if (_compactPaneIconPresenter != null)
- _compactPaneIconPresenter.Content = CreateIconContent(DrawerIcon);
- if (_paneIconPresenter != null)
- _paneIconPresenter.Content = CreateIconContent(DrawerIcon);
- if (_bottomPaneIconPresenter != null)
- _bottomPaneIconPresenter.Content = CreateIconContent(DrawerIcon);
- }
-
- private static object? CreateIconContent(object? icon)
- {
- // Non-visual data (Geometry, IImage, string, etc.) can be shared across presenters.
- if (icon is not Control)
- return icon;
-
- // For Control-typed icons, create an independent copy per presenter to avoid
- // the "already has a visual parent" exception when the same instance is used
- // in multiple ContentPresenters simultaneously.
- if (icon is PathIcon pathIcon)
- {
- var clone = new PathIcon
- {
- Data = pathIcon.Data,
- Width = pathIcon.Width,
- Height = pathIcon.Height,
- };
-
- if (pathIcon.IsSet(PathIcon.ForegroundProperty))
- clone.Foreground = pathIcon.Foreground;
- if (pathIcon.IsSet(PathIcon.OpacityProperty))
- clone.Opacity = pathIcon.Opacity;
- if (pathIcon.IsSet(PathIcon.RenderTransformProperty))
- clone.RenderTransform = pathIcon.RenderTransform;
- if (pathIcon.IsSet(PathIcon.RenderTransformOriginProperty))
- clone.RenderTransformOrigin = pathIcon.RenderTransformOrigin;
-
- return clone;
- }
-
- // For other Control subtypes, return null to avoid a crash.
- // Users should pass non-Control icon data instead.
- return null;
- }
-
private void ApplyDrawerBackground()
{
if (_splitView == null)
diff --git a/src/Avalonia.Controls/Page/INavigation.cs b/src/Avalonia.Controls/Page/INavigation.cs
index b36bc053b6..e617c37b45 100644
--- a/src/Avalonia.Controls/Page/INavigation.cs
+++ b/src/Avalonia.Controls/Page/INavigation.cs
@@ -1,3 +1,4 @@
+using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Avalonia.Animation;
@@ -117,11 +118,15 @@ namespace Avalonia.Controls
/// Inserts immediately before in the stack.
/// Does not change the currently visible page.
///
+ /// or is .
+ /// is not in the navigation stack, or is already hosted by this navigation page.
void InsertPage(Page page, Page before);
///
/// Removes from the navigation stack without animation.
+ /// If is not in the stack the call is a no-op.
///
+ /// is .
void RemovePage(Page page);
}
}
diff --git a/src/Avalonia.Controls/Page/MultiPage.cs b/src/Avalonia.Controls/Page/MultiPage.cs
index 817f1306aa..a9912ced2d 100644
--- a/src/Avalonia.Controls/Page/MultiPage.cs
+++ b/src/Avalonia.Controls/Page/MultiPage.cs
@@ -5,7 +5,6 @@ using System.Collections.Specialized;
using Avalonia.Controls.Templates;
using Avalonia.LogicalTree;
using Avalonia.Metadata;
-using Avalonia.VisualTree;
namespace Avalonia.Controls
{
@@ -20,6 +19,12 @@ namespace Avalonia.Controls
public static readonly StyledProperty?> PagesProperty =
AvaloniaProperty.Register?>(nameof(Pages));
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty ItemsSourceProperty =
+ AvaloniaProperty.Register(nameof(ItemsSource));
+
///
/// Defines the property.
///
@@ -43,6 +48,18 @@ namespace Avalonia.Controls
set => SetValue(PagesProperty, value);
}
+ ///
+ /// Gets or sets a view-model collection to bind to. Use together with
+ /// to convert each item into a .
+ /// When set, takes precedence over as the item source for
+ /// the inner tab or carousel control.
+ ///
+ public IEnumerable? ItemsSource
+ {
+ get => GetValue(ItemsSourceProperty);
+ set => SetValue(ItemsSourceProperty, value);
+ }
+
///
/// Gets or sets the data template used to create pages from view-model items.
///
@@ -68,18 +85,18 @@ namespace Avalonia.Controls
if (change.Property == PagesProperty)
{
+ if (ReferenceEquals(change.OldValue, change.NewValue))
+ return;
+
if (change.OldValue is INotifyCollectionChanged oldNotifyCollection)
oldNotifyCollection.CollectionChanged -= NotifyCollection_CollectionChanged;
LogicalChildren.Clear();
- if (change.NewValue is IEnumerable newPages)
+ if (change.NewValue is IEnumerable newItems)
{
- foreach (var page in newPages)
- {
- if (page is ILogical logical)
- LogicalChildren.Add(logical);
- }
+ foreach (var page in newItems)
+ LogicalChildren.Add(page);
}
if (change.NewValue is INotifyCollectionChanged newNotifyCollection)
@@ -87,6 +104,8 @@ namespace Avalonia.Controls
if (change.NewValue != null)
UpdateActivePage();
+ else
+ SetCurrentValue(CurrentPageProperty, null);
}
else if (change.Property == CurrentPageProperty)
CurrentPageChanged?.Invoke(this, EventArgs.Empty);
@@ -103,6 +122,24 @@ namespace Avalonia.Controls
/// Called when the active child page changes.
///
/// The reason for the page change.
+ ///
+ /// The base class calls this method in the following situations, passing the corresponding
+ /// value:
+ ///
+ /// -
+ /// . The collection was
+ /// assigned, a new item was added, or the active page changed for any reason not
+ /// covered below.
+ ///
+ /// -
+ /// . The currently active page was removed from
+ /// the collection or the collection was reset and the active page
+ /// is no longer present. Subclasses should select a replacement page.
+ ///
+ ///
+ /// Subclasses may also call this method directly with any
+ /// value appropriate for their own navigation model.
+ ///
protected virtual void UpdateActivePage(NavigationType navigationType) { }
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
@@ -132,33 +169,8 @@ namespace Avalonia.Controls
LogicalChildren.Clear();
if (Pages != null)
{
- foreach (var item in Pages)
- {
- if (item is ILogical logical)
- LogicalChildren.Add(logical);
- }
- }
- break;
-
- case NotifyCollectionChangedAction.Remove:
- case NotifyCollectionChangedAction.Replace:
- if (e.OldItems != null)
- {
- foreach (var old in e.OldItems)
- {
- if (old is ILogical logical)
- LogicalChildren.Remove(logical);
- }
- }
-
- if (e.NewItems != null)
- {
- int insertIdx = e.NewStartingIndex >= 0 ? e.NewStartingIndex : LogicalChildren.Count;
- foreach (var newItem in e.NewItems)
- {
- if (newItem is ILogical logical)
- LogicalChildren.Insert(insertIdx++, logical);
- }
+ foreach (var page in Pages)
+ LogicalChildren.Add(page);
}
break;
@@ -167,8 +179,8 @@ namespace Avalonia.Controls
{
foreach (var old in e.OldItems)
{
- if (old is ILogical logical)
- LogicalChildren.Remove(logical);
+ if (old is Page page)
+ LogicalChildren.Remove(page);
}
}
@@ -177,8 +189,8 @@ namespace Avalonia.Controls
int insertIdx = e.NewStartingIndex >= 0 ? e.NewStartingIndex : LogicalChildren.Count;
foreach (var newItem in e.NewItems)
{
- if (newItem is ILogical logical)
- LogicalChildren.Insert(insertIdx++, logical);
+ if (newItem is Page page)
+ LogicalChildren.Insert(insertIdx++, page);
}
}
break;
@@ -196,14 +208,22 @@ namespace Avalonia.Controls
{
if (e.OldItems != null)
foreach (var old in e.OldItems)
- if (ReferenceEquals(old, current)) { currentRemoved = true; break; }
+ if (ReferenceEquals(old, current))
+ {
+ currentRemoved = true;
+ break;
+ }
}
else if (e.Action == NotifyCollectionChangedAction.Reset)
{
currentRemoved = true;
if (Pages != null)
- foreach (var item in Pages)
- if (ReferenceEquals(item, current)) { currentRemoved = false; break; }
+ foreach (var page in Pages)
+ if (ReferenceEquals(page, current))
+ {
+ currentRemoved = false;
+ break;
+ }
}
if (currentRemoved)
diff --git a/src/Avalonia.Controls/Page/NavigationPage.cs b/src/Avalonia.Controls/Page/NavigationPage.cs
index dd14d71a04..8e38fbbdbc 100644
--- a/src/Avalonia.Controls/Page/NavigationPage.cs
+++ b/src/Avalonia.Controls/Page/NavigationPage.cs
@@ -1,7 +1,6 @@
using System;
using System.Collections;
using System.Collections.Generic;
-using System.Collections.Specialized;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Animation;
@@ -12,6 +11,7 @@ using Avalonia.Logging;
using Avalonia.LogicalTree;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives;
+using Avalonia.Controls.Shapes;
using Avalonia.Input;
using Avalonia.Input.GestureRecognizers;
using Avalonia.Interactivity;
@@ -29,16 +29,21 @@ namespace Avalonia.Controls
[TemplatePart("PART_ContentHost", typeof(Panel))]
[TemplatePart("PART_PagePresenter", typeof(ContentPresenter))]
[TemplatePart("PART_PageBackPresenter", typeof(ContentPresenter))]
+ [TemplatePart("PART_BackButtonDefaultIcon", typeof(Control))]
+ [TemplatePart("PART_BackButtonContentPresenter", typeof(ContentPresenter))]
[TemplatePart("PART_TopCommandBar", typeof(ContentPresenter))]
[TemplatePart("PART_BottomCommandBar", typeof(ContentPresenter))]
[TemplatePart("PART_ModalBackPresenter", typeof(ContentPresenter))]
[TemplatePart("PART_ModalPresenter", typeof(ContentPresenter))]
+ [TemplatePart("PART_NavBarShadow", typeof(Border))]
[PseudoClasses(":nav-bar-inset", ":nav-bar-compact")]
public class NavigationPage : MultiPage, INavigation
{
private const double EdgeGestureWidth = 20;
private Button? _backButton;
+ private Control? _backButtonDefaultIcon;
+ private ContentPresenter? _backButtonContentPresenter;
private Panel? _contentHost;
private ContentPresenter? _pagePresenter;
private ContentPresenter? _pageBackPresenter;
@@ -50,6 +55,7 @@ namespace Avalonia.Controls
private bool _isPop;
private bool _hasHadFirstPage;
private BarLayoutBehavior _effectiveBarLayoutBehavior = BarLayoutBehavior.Inset;
+ private readonly Stack _navigationStack = new();
private readonly Stack _modalStack = new();
private IReadOnlyList? _cachedNavigationStack;
private IReadOnlyList? _cachedModalStack;
@@ -60,6 +66,7 @@ namespace Avalonia.Controls
private IDisposable? _isBackButtonEnabledSub;
private IDisposable? _barLayoutBehaviorSub;
private IDisposable? _barHeightSub;
+ private IDisposable? _backButtonContentSub;
private bool _isNavigating;
private bool _canGoBack;
private bool? _isBackButtonEffectivelyVisible;
@@ -68,8 +75,11 @@ 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);
+ private bool _restoringPagesProperty;
private bool IsRtl => FlowDirection == FlowDirection.RightToLeft;
@@ -181,6 +191,12 @@ namespace Avalonia.Controls
public static readonly StyledProperty IsGestureEnabledProperty =
AvaloniaProperty.Register(nameof(IsGestureEnabled), true);
+ ///
+ /// Defines the property.
+ ///
+ public static readonly DirectProperty IsNavigatingProperty =
+ AvaloniaProperty.RegisterDirect(nameof(IsNavigating), o => o._isNavigating);
+
///
/// Defines the property.
///
@@ -203,6 +219,22 @@ namespace Avalonia.Controls
{
PageNavigationSystemBackButtonPressedEvent.AddClassHandler((sender, eventArgs) =>
{
+ if (eventArgs.Handled)
+ return;
+
+ if (sender._modalStack.Count > 0)
+ {
+ var forwarded = new RoutedEventArgs(PageNavigationSystemBackButtonPressedEvent);
+ sender._modalStack.Peek().RaiseEvent(forwarded);
+
+ eventArgs.Handled = true;
+
+ if (!forwarded.Handled)
+ _ = sender.PopModalAsync();
+
+ return;
+ }
+
if (sender.StackDepth > 1)
{
eventArgs.Handled = true;
@@ -247,7 +279,7 @@ namespace Avalonia.Controls
{
if (e.NewValue is not Page page || x.StackDepth > 0)
return;
- _ = x.PushAsync(page);
+ _ = x.PushAsync(page); // property-changed handler cannot be async; fire-and-forget is intentional
});
}
@@ -256,8 +288,13 @@ namespace Avalonia.Controls
///
public NavigationPage()
{
- SetCurrentValue(PagesProperty, new Stack());
- GestureRecognizers.Add(new SwipeGestureRecognizer { EdgeSize = EdgeGestureWidth });
+ SetCurrentValue(PagesProperty, _navigationStack);
+ GestureRecognizers.Add(new SwipeGestureRecognizer
+ {
+ CanHorizontallySwipe = true,
+ CanVerticallySwipe = false
+ });
+ AddHandler(PointerPressedEvent, OnSwipePointerPressed, handledEventsToo: true);
}
///
@@ -364,6 +401,11 @@ namespace Avalonia.Controls
set => SetValue(IsGestureEnabledProperty, value);
}
+ ///
+ /// Gets whether a navigation operation is currently in progress.
+ ///
+ public bool IsNavigating => _isNavigating;
+
///
/// Gets whether the navigation stack has more than one entry.
///
@@ -385,18 +427,9 @@ namespace Avalonia.Controls
if (_cachedNavigationStack != null)
return _cachedNavigationStack;
- if (Pages is Stack stack)
- {
- var result = new List(stack);
- result.Reverse();
- _cachedNavigationStack = result.AsReadOnly();
- }
- else if (Pages is IEnumerable enumerable)
- {
- _cachedNavigationStack = new List(enumerable).AsReadOnly();
- }
- else
- _cachedNavigationStack = Array.Empty();
+ var result = new List(_navigationStack);
+ result.Reverse();
+ _cachedNavigationStack = result.AsReadOnly();
return _cachedNavigationStack;
}
@@ -422,10 +455,7 @@ namespace Avalonia.Controls
///
/// Gets the number of pages in the navigation stack.
///
- public int StackDepth
- {
- get => Pages is System.Collections.ICollection c ? c.Count : 0;
- }
+ public int StackDepth => _navigationStack.Count;
///
/// Gets the custom back-button content for the specified page.
@@ -451,9 +481,15 @@ namespace Avalonia.Controls
public static void SetHasBackButton(Page page, bool value) =>
page.SetValue(HasBackButtonProperty, value);
+ ///
+ /// Gets the header of the specified page.
+ ///
public static object? GetHeader(Page page) =>
page.GetValue(Page.HeaderProperty);
+ ///
+ /// Sets the header of the specified page.
+ ///
public static void SetHeader(Page page, object? header) =>
page.SetValue(Page.HeaderProperty, header);
@@ -542,6 +578,11 @@ namespace Avalonia.Controls
///
/// Occurs when the stack is popped to root.
///
+ ///
+ /// The property holds the root page that is now
+ /// current, not any of the pages that were popped. To observe each popped page
+ /// individually, subscribe to .
+ ///
public event EventHandler? PoppedToRoot;
///
@@ -577,11 +618,36 @@ namespace Avalonia.Controls
}
}
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ if (change.Property == PagesProperty &&
+ !_restoringPagesProperty &&
+ !ReferenceEquals(change.NewValue, _navigationStack))
+ {
+ try
+ {
+ _restoringPagesProperty = true;
+ SetCurrentValue(PagesProperty, _navigationStack);
+ }
+ finally
+ {
+ _restoringPagesProperty = false;
+ }
+
+ throw new InvalidOperationException(
+ "Direct assignment to NavigationPage.Pages is not supported. Use PushAsync, PopAsync, InsertPage, RemovePage, or ReplaceAsync to modify the navigation stack.");
+ }
+
+ base.OnPropertyChanged(change);
+ }
+
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
BackButton = e.NameScope.Get("PART_BackButton");
+ _backButtonDefaultIcon = e.NameScope.Find("PART_BackButtonDefaultIcon");
+ _backButtonContentPresenter = e.NameScope.Find("PART_BackButtonContentPresenter");
_contentHost = e.NameScope.Find("PART_ContentHost");
_pagePresenter = e.NameScope.Find("PART_PagePresenter");
@@ -603,11 +669,7 @@ namespace Avalonia.Controls
_modalPresenter.IsVisible = IsModalVisible;
}
- foreach (var p in NavigationStack)
- {
- p.Navigation = this;
- p.SetInNavigationPage(true);
- }
+ RestoreNavigationState();
ApplyNavBarVisibility();
ApplyBackButtonEnabled(IsBackButtonEffectivelyEnabled);
@@ -646,6 +708,7 @@ namespace Avalonia.Controls
{
base.OnAttachedToVisualTree(e);
+ RestoreNavigationState();
AddHandler(InputElement.SwipeGestureEvent, OnSwipeGesture);
}
@@ -673,20 +736,10 @@ namespace Avalonia.Controls
_barLayoutBehaviorSub = null;
_barHeightSub?.Dispose();
_barHeightSub = null;
+ _backButtonContentSub?.Dispose();
+ _backButtonContentSub = null;
- while (_modalStack.Count > 0)
- {
- var modal = _modalStack.Pop();
- modal.Navigation = null;
- modal.SetInNavigationPage(false);
- }
- _cachedModalStack = null;
-
- foreach (var p in NavigationStack)
- {
- p.Navigation = null;
- p.SetInNavigationPage(false);
- }
+ ClearNavigationState();
InvalidateNavigationStackCache();
}
@@ -730,18 +783,24 @@ namespace Avalonia.Controls
///
private Page? PeekDestinationPage()
{
- if (Pages is Stack stack)
+ if (_navigationStack.Count < 2)
+ return null;
+ using var enumerator = _navigationStack.GetEnumerator();
+ enumerator.MoveNext();
+ enumerator.MoveNext();
+ return enumerator.Current;
+ }
+
+ private void ThrowIfPageIsAlreadyPresent(Page page)
+ {
+ if (_pageSet.Contains(page))
+ throw new InvalidOperationException("The page is already hosted by this NavigationPage.");
+
+ foreach (var modal in _modalStack)
{
- if (stack.Count < 2)
- return null;
- using var enumerator = stack.GetEnumerator();
- enumerator.MoveNext();
- enumerator.MoveNext();
- return enumerator.Current;
+ if (ReferenceEquals(modal, page))
+ throw new InvalidOperationException("The page is already hosted by this NavigationPage.");
}
- if (Pages is IList list)
- return list.Count >= 2 ? list[list.Count - 2] : null;
- return null;
}
///
@@ -752,18 +811,13 @@ namespace Avalonia.Controls
{
ArgumentNullException.ThrowIfNull(page);
- if (_pageSet.Contains(page))
- throw new InvalidOperationException("The page is already in the navigation stack.");
-
- if (Pages is Stack pages)
- pages.Push(page);
- else if (Pages is IList list)
- list.Add(page);
+ ThrowIfPageIsAlreadyPresent(page);
+ _navigationStack.Push(page);
_pageSet.Add(page);
InvalidateNavigationStackCache();
- if (page is ILogical logical && Pages is not INotifyCollectionChanged)
+ if (page is ILogical logical)
LogicalChildren.Add(logical);
page.Navigation = this;
@@ -780,25 +834,12 @@ namespace Avalonia.Controls
///
private Page? ExecutePopCore()
{
- Page? old = null;
-
- if (Pages is Stack pages)
- {
- old = pages.Pop();
- }
- else if (Pages is IList list)
- {
- if (list.Count > 0)
- {
- old = list[list.Count - 1];
- list.RemoveAt(list.Count - 1);
- }
- }
+ Page? old = _navigationStack.Count > 0 ? _navigationStack.Pop() : null;
if (old != null)
_pageSet.Remove(old);
- if (old is ILogical oldLogical && Pages is not INotifyCollectionChanged)
+ if (old is ILogical oldLogical)
LogicalChildren.Remove(oldLogical);
InvalidateNavigationStackCache();
@@ -809,6 +850,7 @@ namespace Avalonia.Controls
{
old.Navigation = null;
old.SetInNavigationPage(false);
+ old.SafeAreaPadding = default;
}
return old;
@@ -817,13 +859,20 @@ namespace Avalonia.Controls
///
/// Pushes onto the navigation stack asynchronously using .
///
+ ///
+ /// If a navigation transition is already in progress ( is ),
+ /// the call returns immediately without pushing the page and without raising any events.
+ /// If the outgoing page's handler sets
+ /// to , the push is
+ /// silently aborted: the stack is not modified, no events are raised, and no exception is thrown.
+ ///
public async Task PushAsync(Page page)
{
ArgumentNullException.ThrowIfNull(page);
if (_isNavigating)
return;
- _isNavigating = true;
+ SetAndRaise(IsNavigatingProperty, ref _isNavigating, true);
try
{
var previousPage = CurrentPage;
@@ -847,7 +896,7 @@ namespace Avalonia.Controls
}
finally
{
- _isNavigating = false;
+ SetAndRaise(IsNavigatingProperty, ref _isNavigating, false);
}
}
@@ -856,15 +905,28 @@ namespace Avalonia.Controls
///
public async Task PushAsync(Page page, IPageTransition? transition)
{
+ if (_isNavigating)
+ return;
_overrideTransition = transition;
_hasOverrideTransition = true;
- try { await PushAsync(page); }
- finally { _hasOverrideTransition = false; _overrideTransition = null; }
+ try
+ {
+ await PushAsync(page);
+ }
+ finally
+ {
+ _hasOverrideTransition = false;
+ _overrideTransition = null;
+ }
}
///
/// Pops the top page from the navigation stack asynchronously using .
///
+ ///
+ /// Returns if the stack has only the root page or if a navigation transition
+ /// is already in progress ( is ).
+ ///
public async Task PopAsync()
{
if (StackDepth <= 1)
@@ -872,7 +934,7 @@ namespace Avalonia.Controls
if (_isNavigating)
return null;
- _isNavigating = true;
+ SetAndRaise(IsNavigatingProperty, ref _isNavigating, true);
try
{
var currentPage = CurrentPage;
@@ -897,7 +959,7 @@ namespace Avalonia.Controls
}
finally
{
- _isNavigating = false;
+ SetAndRaise(IsNavigatingProperty, ref _isNavigating, false);
}
}
@@ -906,15 +968,28 @@ namespace Avalonia.Controls
///
public async Task PopAsync(IPageTransition? transition)
{
+ if (_isNavigating)
+ return null;
_overrideTransition = transition;
_hasOverrideTransition = true;
- try { return await PopAsync(); }
- finally { _hasOverrideTransition = false; _overrideTransition = null; }
+ try
+ {
+ return await PopAsync();
+ }
+ finally
+ {
+ _hasOverrideTransition = false;
+ _overrideTransition = null;
+ }
}
///
/// Pops all pages to the root page using .
///
+ ///
+ /// If a navigation transition is already in progress ( is ),
+ /// the call returns immediately without modifying the stack and without raising any events.
+ ///
public async Task PopToRootAsync()
{
if (StackDepth <= 1)
@@ -922,7 +997,7 @@ namespace Avalonia.Controls
if (_isNavigating)
return;
- _isNavigating = true;
+ SetAndRaise(IsNavigatingProperty, ref _isNavigating, true);
try
{
var navigationStack = NavigationStack;
@@ -937,33 +1012,21 @@ namespace Avalonia.Controls
return;
}
- bool isIncc = Pages is INotifyCollectionChanged;
var poppedPages = new List();
void TearDownPopped(Page popped)
{
_pageSet.Remove(popped);
- if (!isIncc && popped is ILogical poppedLogical)
+ if (popped is ILogical poppedLogical)
LogicalChildren.Remove(poppedLogical);
popped.Navigation = null;
popped.SetInNavigationPage(false);
+ popped.SafeAreaPadding = default;
poppedPages.Add(popped);
}
- if (Pages is Stack stack)
- {
- while (stack.Count > 1)
- TearDownPopped(stack.Pop());
- }
- else if (Pages is IList list)
- {
- while (list.Count > 1)
- {
- var last = list[list.Count - 1];
- list.RemoveAt(list.Count - 1);
- TearDownPopped(last);
- }
- }
+ while (_navigationStack.Count > 1)
+ TearDownPopped(_navigationStack.Pop());
InvalidateNavigationStackCache();
_isPop = true;
@@ -987,7 +1050,7 @@ namespace Avalonia.Controls
}
finally
{
- _isNavigating = false;
+ SetAndRaise(IsNavigatingProperty, ref _isNavigating, false);
}
}
@@ -996,15 +1059,36 @@ namespace Avalonia.Controls
///
public async Task PopToRootAsync(IPageTransition? transition)
{
+ if (_isNavigating)
+ return;
_overrideTransition = transition;
_hasOverrideTransition = true;
- try { await PopToRootAsync(); }
- finally { _hasOverrideTransition = false; _overrideTransition = null; }
+ try
+ {
+ await PopToRootAsync();
+ }
+ finally
+ {
+ _hasOverrideTransition = false;
+ _overrideTransition = null;
+ }
}
///
/// Pops to a specific page in the stack using .
///
+ ///
+ /// All pages above are removed from the stack. Each removed page
+ /// receives a call with ,
+ /// and is raised for each one. The target page receives
+ /// with . If
+ /// is already the top of the stack the method returns immediately
+ /// without raising any events.
+ ///
+ /// If a navigation transition is already in progress ( is ),
+ /// the call returns immediately without modifying the stack and without raising any events.
+ ///
+ ///
public async Task PopToPageAsync(Page page)
{
ArgumentNullException.ThrowIfNull(page);
@@ -1012,10 +1096,13 @@ namespace Avalonia.Controls
if (!_pageSet.Contains(page))
throw new ArgumentException("Page is not in the navigation stack.", nameof(page));
+ if (_navigationStack.Count > 0 && ReferenceEquals(_navigationStack.Peek(), page))
+ return;
+
if (_isNavigating)
return;
- _isNavigating = true;
+ SetAndRaise(IsNavigatingProperty, ref _isNavigating, true);
try
{
var currentPage = CurrentPage;
@@ -1027,33 +1114,21 @@ namespace Avalonia.Controls
return;
}
- bool isIncc = Pages is INotifyCollectionChanged;
var poppedPages = new List();
void TearDownPopped(Page popped)
{
_pageSet.Remove(popped);
- if (!isIncc && popped is ILogical poppedLogical)
+ if (popped is ILogical poppedLogical)
LogicalChildren.Remove(poppedLogical);
popped.Navigation = null;
popped.SetInNavigationPage(false);
+ popped.SafeAreaPadding = default;
poppedPages.Add(popped);
}
- if (Pages is Stack stack)
- {
- while (stack.Count > 1 && stack.Peek() != page)
- TearDownPopped(stack.Pop());
- }
- else if (Pages is IList list)
- {
- while (list.Count > 1 && list[list.Count - 1] != page)
- {
- var last = list[list.Count - 1];
- list.RemoveAt(list.Count - 1);
- TearDownPopped(last);
- }
- }
+ while (_navigationStack.Count > 1 && _navigationStack.Peek() != page)
+ TearDownPopped(_navigationStack.Pop());
InvalidateNavigationStackCache();
_isPop = true;
@@ -1075,7 +1150,7 @@ namespace Avalonia.Controls
}
finally
{
- _isNavigating = false;
+ SetAndRaise(IsNavigatingProperty, ref _isNavigating, false);
}
}
@@ -1084,28 +1159,50 @@ namespace Avalonia.Controls
///
public async Task PopToPageAsync(Page page, IPageTransition? transition)
{
+ if (_isNavigating)
+ return;
_overrideTransition = transition;
_hasOverrideTransition = true;
- try { await PopToPageAsync(page); }
- finally { _hasOverrideTransition = false; _overrideTransition = null; }
+ try
+ {
+ await PopToPageAsync(page);
+ }
+ finally
+ {
+ _hasOverrideTransition = false;
+ _overrideTransition = null;
+ }
}
///
/// Pushes a modal page using .
///
+ ///
+ /// If a navigation transition is already in progress ( is ),
+ /// the call returns immediately without pushing the page and without raising any events.
+ ///
public async Task PushModalAsync(Page page)
{
ArgumentNullException.ThrowIfNull(page);
if (_isNavigating)
return;
+ ThrowIfPageIsAlreadyPresent(page);
- _isNavigating = true;
+ SetAndRaise(IsNavigatingProperty, ref _isNavigating, true);
try
{
var previousModal = _modalStack.Count > 0 ? (Page?)_modalStack.Peek() : null;
-
var coveredPage = previousModal ?? CurrentPage;
+ if (coveredPage != null)
+ {
+ var navigatingArgs = new NavigatingFromEventArgs(page, NavigationType.PushModal);
+ await coveredPage.SendNavigatingAsync(navigatingArgs);
+
+ if (navigatingArgs.Cancel)
+ return;
+ }
+
_modalStack.Push(page);
_cachedModalStack = null;
page.Navigation = this;
@@ -1133,11 +1230,11 @@ namespace Avalonia.Controls
}
_currentModalTransition?.Cancel();
_currentModalTransition?.Dispose();
- _currentModalTransition = new CancellationTokenSource();
- var modalCt = _currentModalTransition.Token;
+ var modalCts = new CancellationTokenSource();
+ _currentModalTransition = modalCts;
try
{
- await effectiveModalTransition.Start(null, _modalPresenter, forward: true, modalCt);
+ await effectiveModalTransition.Start(null, _modalPresenter, forward: true, modalCts.Token);
}
catch (OperationCanceledException) { /* Transition cancelled; lifecycle events still fire below. */ }
catch (Exception ex)
@@ -1145,11 +1242,18 @@ namespace Avalonia.Controls
Logger.TryGet(LogEventLevel.Error, LogArea.Control)
?.Log(this, "Modal transition threw an unhandled exception: {Exception}", ex);
}
-
- if (_modalBackPresenter != null)
+ finally
{
- _modalBackPresenter.IsVisible = false;
- _modalBackPresenter.Content = null;
+ if (_modalBackPresenter != null)
+ {
+ _modalBackPresenter.IsVisible = false;
+ _modalBackPresenter.Content = null;
+ }
+ if (ReferenceEquals(_currentModalTransition, modalCts))
+ {
+ _currentModalTransition = null;
+ modalCts.Dispose();
+ }
}
}
else
@@ -1165,7 +1269,7 @@ namespace Avalonia.Controls
}
finally
{
- _isNavigating = false;
+ SetAndRaise(IsNavigatingProperty, ref _isNavigating, false);
}
}
@@ -1174,15 +1278,28 @@ namespace Avalonia.Controls
///
public async Task PushModalAsync(Page page, IPageTransition? transition)
{
+ if (_isNavigating)
+ return;
_overrideTransition = transition;
_hasOverrideTransition = true;
- try { await PushModalAsync(page); }
- finally { _hasOverrideTransition = false; _overrideTransition = null; }
+ try
+ {
+ await PushModalAsync(page);
+ }
+ finally
+ {
+ _hasOverrideTransition = false;
+ _overrideTransition = null;
+ }
}
///
/// Pops the top modal page using .
///
+ ///
+ /// Returns if there are no modal pages or if a navigation transition
+ /// is already in progress ( is ).
+ ///
public async Task PopModalAsync()
{
if (_modalStack.Count == 0)
@@ -1190,10 +1307,30 @@ namespace Avalonia.Controls
if (_isNavigating)
return null;
- _isNavigating = true;
+ SetAndRaise(IsNavigatingProperty, ref _isNavigating, true);
try
{
- var modal = _modalStack.Pop();
+ var modal = _modalStack.Peek();
+ Page? revealedPage;
+ if (_modalStack.Count < 2)
+ {
+ revealedPage = CurrentPage;
+ }
+ else
+ {
+ using var enumerator = _modalStack.GetEnumerator();
+ enumerator.MoveNext();
+ enumerator.MoveNext();
+ revealedPage = enumerator.Current;
+ }
+
+ var navigatingArgs = new NavigatingFromEventArgs(revealedPage, NavigationType.PopModal);
+ await modal.SendNavigatingAsync(navigatingArgs);
+
+ if (navigatingArgs.Cancel)
+ return null;
+
+ modal = _modalStack.Pop();
_cachedModalStack = null;
modal.Navigation = null;
@@ -1216,11 +1353,11 @@ namespace Avalonia.Controls
_currentModalTransition?.Cancel();
_currentModalTransition?.Dispose();
- _currentModalTransition = new CancellationTokenSource();
- var popCt1 = _currentModalTransition.Token;
+ var popCts1 = new CancellationTokenSource();
+ _currentModalTransition = popCts1;
try
{
- await effectiveModalTransition.Start(_modalPresenter, null, forward: false, popCt1);
+ await effectiveModalTransition.Start(_modalPresenter, null, forward: false, popCts1.Token);
SwapModalPresenters();
if (_modalBackPresenter != null)
_modalBackPresenter.Content = null;
@@ -1234,6 +1371,11 @@ namespace Avalonia.Controls
finally
{
SetCurrentValue(ModalContentProperty, (object?)next);
+ if (ReferenceEquals(_currentModalTransition, popCts1))
+ {
+ _currentModalTransition = null;
+ popCts1.Dispose();
+ }
}
}
else
@@ -1247,11 +1389,11 @@ namespace Avalonia.Controls
{
_currentModalTransition?.Cancel();
_currentModalTransition?.Dispose();
- _currentModalTransition = new CancellationTokenSource();
- var popCt2 = _currentModalTransition.Token;
+ var popCts2 = new CancellationTokenSource();
+ _currentModalTransition = popCts2;
try
{
- await effectiveModalTransition.Start(_modalPresenter, null, forward: false, popCt2);
+ await effectiveModalTransition.Start(_modalPresenter, null, forward: false, popCts2.Token);
}
catch (OperationCanceledException) { /* Transition cancelled; lifecycle events still fire below. */ }
catch (Exception ex)
@@ -1263,6 +1405,11 @@ namespace Avalonia.Controls
{
SetCurrentValue(IsModalVisibleProperty, false);
SetCurrentValue(ModalContentProperty, (object?)null);
+ if (ReferenceEquals(_currentModalTransition, popCts2))
+ {
+ _currentModalTransition = null;
+ popCts2.Dispose();
+ }
}
}
else
@@ -1272,7 +1419,6 @@ namespace Avalonia.Controls
}
}
- var revealedPage = _modalStack.Count > 0 ? (Page?)_modalStack.Peek() : CurrentPage;
modal.SendNavigatedFrom(new NavigatedFromEventArgs(revealedPage, NavigationType.PopModal));
revealedPage?.SendNavigatedTo(new NavigatedToEventArgs(modal, NavigationType.PopModal));
@@ -1281,7 +1427,7 @@ namespace Avalonia.Controls
}
finally
{
- _isNavigating = false;
+ SetAndRaise(IsNavigatingProperty, ref _isNavigating, false);
}
}
@@ -1290,15 +1436,41 @@ namespace Avalonia.Controls
///
public async Task PopModalAsync(IPageTransition? transition)
{
+ if (_isNavigating)
+ return null;
_overrideTransition = transition;
_hasOverrideTransition = true;
- try { return await PopModalAsync(); }
- finally { _hasOverrideTransition = false; _overrideTransition = null; }
+ try
+ {
+ return await PopModalAsync();
+ }
+ finally
+ {
+ _hasOverrideTransition = false;
+ _overrideTransition = null;
+ }
}
///
/// Pops all modal pages using .
///
+ ///
+ /// All modals are dismissed in a single transition rather than one-by-one, so lifecycle
+ /// events differ from calling in a loop:
+ ///
+ /// -
+ /// is consulted only on the topmost modal. Intermediate
+ /// modals do not receive a cancellation opportunity. If the top modal cancels, the
+ /// entire dismiss is aborted and no modals are popped.
+ ///
+ /// -
+ /// fires on every dismissed modal in LIFO order.
+ ///
+ /// -
+ /// fires only on .
+ ///
+ ///
+ ///
public async Task PopAllModalsAsync()
{
if (_modalStack.Count == 0)
@@ -1306,9 +1478,18 @@ namespace Avalonia.Controls
if (_isNavigating)
return;
- _isNavigating = true;
+ SetAndRaise(IsNavigatingProperty, ref _isNavigating, true);
try
{
+ var topModal = _modalStack.Peek();
+ var revealedPage = CurrentPage;
+
+ var navigatingArgs = new NavigatingFromEventArgs(revealedPage, NavigationType.PopModal);
+ await topModal.SendNavigatingAsync(navigatingArgs);
+
+ if (navigatingArgs.Cancel)
+ return;
+
var effectiveModalTransition = _hasOverrideTransition ? _overrideTransition : ModalTransition;
_hasOverrideTransition = false;
_overrideTransition = null;
@@ -1317,10 +1498,11 @@ namespace Avalonia.Controls
{
_currentModalTransition?.Cancel();
_currentModalTransition?.Dispose();
- _currentModalTransition = new CancellationTokenSource();
+ var allModalsCts = new CancellationTokenSource();
+ _currentModalTransition = allModalsCts;
try
{
- await effectiveModalTransition.Start(_modalPresenter, null, forward: false, _currentModalTransition.Token);
+ await effectiveModalTransition.Start(_modalPresenter, null, forward: false, allModalsCts.Token);
}
catch (OperationCanceledException) { /* Transition cancelled; lifecycle events still fire below. */ }
catch (Exception ex)
@@ -1328,13 +1510,19 @@ namespace Avalonia.Controls
Logger.TryGet(LogEventLevel.Error, LogArea.Control)
?.Log(this, "Modal transition threw an unhandled exception: {Exception}", ex);
}
+ finally
+ {
+ if (ReferenceEquals(_currentModalTransition, allModalsCts))
+ {
+ _currentModalTransition = null;
+ allModalsCts.Dispose();
+ }
+ }
}
SetCurrentValue(ModalContentProperty, (object?)null);
SetCurrentValue(IsModalVisibleProperty, false);
- Page? topModal = _modalStack.Count > 0 ? _modalStack.Peek() : null;
-
while (_modalStack.Count > 0)
{
var modal = _modalStack.Pop();
@@ -1346,82 +1534,74 @@ namespace Avalonia.Controls
}
_cachedModalStack = null;
- var newCurrentPage = CurrentPage;
- newCurrentPage?.SendNavigatedTo(new NavigatedToEventArgs(topModal, NavigationType.PopModal));
+ revealedPage?.SendNavigatedTo(new NavigatedToEventArgs(topModal, NavigationType.PopModal));
}
finally
{
- _isNavigating = false;
+ SetAndRaise(IsNavigatingProperty, ref _isNavigating, false);
}
}
///
/// Pops all modal pages using .
///
+ ///
public async Task PopAllModalsAsync(IPageTransition? transition)
{
+ if (_isNavigating)
+ return;
_overrideTransition = transition;
_hasOverrideTransition = true;
- try { await PopAllModalsAsync(); }
- finally { _hasOverrideTransition = false; _overrideTransition = null; }
+ try
+ {
+ await PopAllModalsAsync();
+ }
+ finally
+ {
+ _hasOverrideTransition = false;
+ _overrideTransition = null;
+ }
}
///
/// Removes a page from the navigation stack without animation.
///
+ ///
+ /// If a navigation transition is already in progress ( is
+ /// ), this call is a no-op: the page is not removed and no events
+ /// are raised.
+ ///
public void RemovePage(Page page)
{
ArgumentNullException.ThrowIfNull(page);
if (_isNavigating)
return;
- if (Pages is Stack stack)
+ if (_navigationStack.Count > 0 && ReferenceEquals(_navigationStack.Peek(), page))
{
- if (stack.Count > 0 && ReferenceEquals(stack.Peek(), page))
- {
- var old = ExecutePopCore();
- if (old != null)
- SendPopLifecycleEvents(old, NavigationType.Pop);
- PageRemoved?.Invoke(this, new PageRemovedEventArgs(page));
- return;
- }
-
- bool found = false;
- foreach (var p in stack)
- if (ReferenceEquals(p, page)) { found = true; break; }
- if (!found)
- return;
-
- var retained = new List(stack.Count - 1);
- foreach (var p in stack)
+ var old = ExecutePopCore();
+ if (old != null)
{
- if (!ReferenceEquals(p, page))
- retained.Add(p);
+ var newCurrentPage = CurrentPage;
+ old.SendNavigatedFrom(new NavigatedFromEventArgs(newCurrentPage, NavigationType.Remove));
+ newCurrentPage?.SendNavigatedTo(new NavigatedToEventArgs(old, NavigationType.Remove));
}
- stack.Clear();
- for (int i = retained.Count - 1; i >= 0; i--)
- stack.Push(retained[i]);
+ PageRemoved?.Invoke(this, new PageRemovedEventArgs(page));
+ return;
}
- else if (Pages is IList list)
- {
- int idx = -1;
- for (int i = 0; i < list.Count; i++)
- if (ReferenceEquals(list[i], page)) { idx = i; break; }
- if (idx < 0)
- return;
- if (idx == list.Count - 1)
- {
- var old = ExecutePopCore();
- if (old != null)
- SendPopLifecycleEvents(old, NavigationType.Pop);
- PageRemoved?.Invoke(this, new PageRemovedEventArgs(page));
- return;
- }
+ if (!_pageSet.Contains(page))
+ return;
- list.RemoveAt(idx);
+ var retained = new List(_navigationStack.Count - 1);
+ foreach (var p in _navigationStack)
+ {
+ if (!ReferenceEquals(p, page))
+ retained.Add(p);
}
- else return;
+ _navigationStack.Clear();
+ for (int i = retained.Count - 1; i >= 0; i--)
+ _navigationStack.Push(retained[i]);
_pageSet.Remove(page);
@@ -1431,8 +1611,7 @@ namespace Avalonia.Controls
page.SetInNavigationPage(false);
page.SafeAreaPadding = default;
- if (Pages is not INotifyCollectionChanged)
- LogicalChildren.Remove(page);
+ LogicalChildren.Remove(page);
InvalidateNavigationStackCache();
UpdateIsBackButtonEffectivelyVisible();
@@ -1443,6 +1622,11 @@ namespace Avalonia.Controls
///
/// Inserts a page into the stack before the specified page.
///
+ ///
+ /// If a navigation transition is already in progress ( is
+ /// ), this call is a no-op: the page is not inserted and no events
+ /// are raised.
+ ///
public void InsertPage(Page page, Page before)
{
ArgumentNullException.ThrowIfNull(page);
@@ -1450,52 +1634,35 @@ namespace Avalonia.Controls
if (_isNavigating)
return;
- if (_pageSet.Contains(page))
- throw new InvalidOperationException("The page is already in the navigation stack.");
-
- bool inserted = false;
+ ThrowIfPageIsAlreadyPresent(page);
- if (Pages is Stack stack)
+ var arr = _navigationStack.ToArray();
+ int beforeIdx = -1;
+ for (int i = 0; i < arr.Length; i++)
{
- var arr = stack.ToArray();
- int beforeIdx = -1;
- for (int i = 0; i < arr.Length; i++)
- if (ReferenceEquals(arr[i], before)) { beforeIdx = i; break; }
- if (beforeIdx < 0)
- return;
-
- stack.Clear();
- for (int i = arr.Length - 1; i >= 0; i--)
+ if (ReferenceEquals(arr[i], before))
{
- if (i == beforeIdx)
- stack.Push(page);
- stack.Push(arr[i]);
+ beforeIdx = i;
+ break;
}
-
- inserted = true;
}
- else if (Pages is IList list)
- {
- int beforeIdx = -1;
- for (int i = 0; i < list.Count; i++)
- if (ReferenceEquals(list[i], before)) { beforeIdx = i; break; }
- if (beforeIdx < 0)
- return;
+ if (beforeIdx < 0)
+ throw new InvalidOperationException("The 'before' page is not in the navigation stack.");
- list.Insert(beforeIdx, page);
- inserted = true;
+ _navigationStack.Clear();
+ for (int i = arr.Length - 1; i >= 0; i--)
+ {
+ if (i == beforeIdx)
+ _navigationStack.Push(page);
+ _navigationStack.Push(arr[i]);
}
- if (!inserted)
- return;
-
_pageSet.Add(page);
page.Navigation = this;
page.SetInNavigationPage(true);
page.SafeAreaPadding = SafeAreaPadding;
- if (Pages is not System.Collections.Specialized.INotifyCollectionChanged)
- LogicalChildren.Add(page);
+ LogicalChildren.Add(page);
InvalidateNavigationStackCache();
UpdateIsBackButtonEffectivelyVisible();
@@ -1506,14 +1673,25 @@ namespace Avalonia.Controls
///
/// Replaces the top page with using .
///
+ ///
+ /// If a navigation transition is already in progress ( is ),
+ /// the call returns immediately without modifying the stack and without raising any events.
+ ///
public async Task ReplaceAsync(Page page)
{
ArgumentNullException.ThrowIfNull(page);
- if (StackDepth == 0) { await PushAsync(page); return; }
+ if (StackDepth == 0)
+ {
+ await PushAsync(page);
+ return;
+ }
+ if (ReferenceEquals(page, CurrentPage))
+ return;
if (_isNavigating)
return;
+ ThrowIfPageIsAlreadyPresent(page);
- _isNavigating = true;
+ SetAndRaise(IsNavigatingProperty, ref _isNavigating, true);
try
{
var previousPage = CurrentPage;
@@ -1527,10 +1705,22 @@ namespace Avalonia.Controls
}
ExecuteReplaceCore(page, previousPage);
+
+ await AwaitPageTransitionAsync();
+
+ if (previousPage != null)
+ {
+ previousPage.Navigation = null;
+ previousPage.SetInNavigationPage(false);
+ previousPage.SafeAreaPadding = default;
+ previousPage.SendNavigatedFrom(new NavigatedFromEventArgs(page, NavigationType.Replace));
+ }
+
+ page.SendNavigatedTo(new NavigatedToEventArgs(previousPage, NavigationType.Replace));
}
finally
{
- _isNavigating = false;
+ SetAndRaise(IsNavigatingProperty, ref _isNavigating, false);
}
}
@@ -1539,10 +1729,19 @@ namespace Avalonia.Controls
///
public async Task ReplaceAsync(Page page, IPageTransition? transition)
{
+ if (_isNavigating)
+ return;
_overrideTransition = transition;
_hasOverrideTransition = true;
- try { await ReplaceAsync(page); }
- finally { _hasOverrideTransition = false; _overrideTransition = null; }
+ try
+ {
+ await ReplaceAsync(page);
+ }
+ finally
+ {
+ _hasOverrideTransition = false;
+ _overrideTransition = null;
+ }
}
// navigationType is intentionally unused; lifecycle events are fired in each navigation
@@ -1557,16 +1756,7 @@ namespace Avalonia.Controls
_hasOverrideTransition = false;
_overrideTransition = null;
- Page? page = null;
- if (Pages is Stack pages)
- {
- pages.TryPeek(out page);
- }
- else if (Pages is IList list)
- {
- if (list.Count > 0)
- page = list[list.Count - 1];
- }
+ _navigationStack.TryPeek(out var page);
if (_contentHost != null && _pagePresenter != null && _pageBackPresenter != null)
{
@@ -1659,6 +1849,9 @@ namespace Avalonia.Controls
_barHeightSub?.Dispose();
_barHeightSub = null;
+ _backButtonContentSub?.Dispose();
+ _backButtonContentSub = null;
+
if (page != null)
{
_hasNavigationBarSub = page.GetObservable(HasNavigationBarProperty)
@@ -1672,6 +1865,9 @@ namespace Avalonia.Controls
_barHeightSub = page.GetObservable(BarHeightOverrideProperty)
.Subscribe(new AnonymousObserver(_ => UpdateEffectiveBarHeight()));
+
+ _backButtonContentSub = page.GetObservable(BackButtonContentProperty)
+ .Subscribe(new AnonymousObserver(_ => UpdateBackButtonContent()));
}
UpdateIsNavBarEffectivelyVisible();
@@ -1682,7 +1878,7 @@ namespace Avalonia.Controls
UpdateContentSafeAreaPadding();
UpdateIsBackButtonEffectivelyVisible();
UpdateIsBackButtonEffectivelyEnabled();
- UpdateDrawerToggleIcon();
+ UpdateBackButtonContent();
}
private async Task RunPageTransitionAsync(
@@ -1741,47 +1937,25 @@ namespace Avalonia.Controls
{
Page? removed = null;
- if (Pages is Stack pagesStack && pagesStack.Count > 0)
+ if (_navigationStack.Count > 0)
{
- removed = pagesStack.Pop();
+ removed = _navigationStack.Pop();
_pageSet.Remove(removed);
}
- else if (Pages is IList pagesList && pagesList.Count > 0)
- {
- removed = pagesList[pagesList.Count - 1];
- _pageSet.Remove(removed);
- pagesList.RemoveAt(pagesList.Count - 1);
- }
-
- if (Pages is Stack pushStack)
- pushStack.Push(page);
- else if (Pages is IList pushList)
- pushList.Add(page);
+ _navigationStack.Push(page);
_pageSet.Add(page);
InvalidateNavigationStackCache();
- if (Pages is not INotifyCollectionChanged)
- {
- if (removed is ILogical removedLogical)
- LogicalChildren.Remove(removedLogical);
- if (page is ILogical addedLogical)
- LogicalChildren.Add(addedLogical);
- }
+ if (removed is ILogical removedLogical)
+ LogicalChildren.Remove(removedLogical);
+ if (page is ILogical addedLogical)
+ LogicalChildren.Add(addedLogical);
page.Navigation = this;
page.SetInNavigationPage(true);
UpdateActivePage();
-
- if (replacedPage != null)
- {
- replacedPage.Navigation = null;
- replacedPage.SetInNavigationPage(false);
- replacedPage.SendNavigatedFrom(new NavigatedFromEventArgs(page, NavigationType.Replace));
- }
-
- page.SendNavigatedTo(new NavigatedToEventArgs(replacedPage, NavigationType.Replace));
}
private void SwapModalPresenters()
@@ -1797,6 +1971,36 @@ namespace Avalonia.Controls
private void InvalidateNavigationStackCache() => _cachedNavigationStack = null;
+ private void RestoreNavigationState()
+ {
+ foreach (var page in NavigationStack)
+ {
+ page.Navigation = this;
+ page.SetInNavigationPage(true);
+ }
+
+ foreach (var modal in _modalStack)
+ {
+ modal.Navigation = this;
+ modal.SetInNavigationPage(true);
+ }
+ }
+
+ private void ClearNavigationState()
+ {
+ foreach (var modal in _modalStack)
+ {
+ modal.Navigation = null;
+ modal.SetInNavigationPage(false);
+ }
+
+ foreach (var page in NavigationStack)
+ {
+ page.Navigation = null;
+ page.SetInNavigationPage(false);
+ }
+ }
+
internal void UpdateIsBackButtonEffectivelyVisible()
{
var depth = StackDepth;
@@ -1839,31 +2043,34 @@ namespace Avalonia.Controls
{
_drawerPage = drawerPage;
UpdateIsBackButtonEffectivelyVisible();
- UpdateDrawerToggleIcon();
+ UpdateBackButtonContent();
}
-
- private void UpdateDrawerToggleIcon()
+ private void UpdateBackButtonContent()
{
- if (_drawerPage == null || CurrentPage == null)
- return;
+ object? content = CurrentPage != null ? GetBackButtonContent(CurrentPage) : null;
- bool showToggle = _drawerPage.DrawerBehavior != DrawerBehavior.Locked
+ bool showToggle = _drawerPage != null
+ && CurrentPage != null
+ && StackDepth <= 1
+ && _drawerPage.DrawerBehavior != DrawerBehavior.Locked
&& _drawerPage.DrawerBehavior != DrawerBehavior.Disabled;
- if (StackDepth <= 1 && showToggle)
+ if (content is null
+ && showToggle
+ && this.TryFindResource("NavigationPageMenuIcon", out var iconData)
+ && iconData is StreamGeometry geometry)
{
- if (GetBackButtonContent(CurrentPage) is null
- && this.TryFindResource("NavigationPageMenuIcon", out var iconData)
- && iconData is StreamGeometry geometry)
- {
- SetBackButtonContent(CurrentPage, new PathIcon { Data = geometry });
- }
+ content = new PathIcon { Data = geometry };
}
- else
+
+ if (_backButtonDefaultIcon != null)
+ _backButtonDefaultIcon.IsVisible = content is null;
+
+ if (_backButtonContentPresenter != null)
{
- if (GetBackButtonContent(CurrentPage) is PathIcon)
- SetBackButtonContent(CurrentPage, null);
+ _backButtonContentPresenter.Content = content;
+ _backButtonContentPresenter.IsVisible = content is not null;
}
UpdateBackButtonAccessibility();
@@ -1871,25 +2078,38 @@ 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;
- _ = PopAsync();
+ _lastSwipeGestureId = e.Id;
+ _ = PopAsync(); // gesture handler cannot be async; fire-and-forget is intentional
}
}
+ private void OnSwipePointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ _swipeStartPoint = e.GetPosition(this);
+ }
+
protected override void OnKeyDown(KeyEventArgs e)
{
base.OnKeyDown(e);
if (e.Key == Key.Left && e.KeyModifiers == KeyModifiers.Alt && StackDepth > 1)
{
- _ = PopAsync();
+ _ = PopAsync(); // key handler cannot be async; fire-and-forget is intentional
e.Handled = true;
}
}
diff --git a/src/Avalonia.Controls/Page/Page.cs b/src/Avalonia.Controls/Page/Page.cs
index 601af92580..a12ce76ddf 100644
--- a/src/Avalonia.Controls/Page/Page.cs
+++ b/src/Avalonia.Controls/Page/Page.cs
@@ -2,6 +2,7 @@ using System;
using System.Threading.Tasks;
using Avalonia.Automation;
using Avalonia.Controls.Primitives;
+using Avalonia.Controls.Templates;
using Avalonia.Interactivity;
namespace Avalonia.Controls
@@ -17,7 +18,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.
@@ -31,6 +32,12 @@ namespace Avalonia.Controls
public static readonly StyledProperty IconProperty =
AvaloniaProperty.Register(nameof(Icon));
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty IconTemplateProperty =
+ AvaloniaProperty.Register(nameof(IconTemplate));
+
///
/// Defines the property.
///
@@ -94,6 +101,15 @@ namespace Avalonia.Controls
set => SetValue(IconProperty, value);
}
+ ///
+ /// Gets or sets the data template used to display the icon.
+ ///
+ public IDataTemplate? IconTemplate
+ {
+ get => GetValue(IconTemplateProperty);
+ set => SetValue(IconTemplateProperty, value);
+ }
+
///
/// Gets or sets the safe-area padding applied to this page's content.
///
@@ -147,6 +163,14 @@ namespace Avalonia.Controls
///
/// Occurs when the page is about to be navigated from.
///
+ ///
+ /// Each subscriber is awaited in turn. Set to
+ /// to abort the navigation; remaining subscribers are not invoked once
+ /// cancellation is requested. If a subscriber throws an exception, the exception propagates
+ /// to the calling navigation method (such as )
+ /// and the navigation is aborted. Subscribers should use try/catch internally if they need
+ /// guaranteed cancellation semantics regardless of errors.
+ ///
public event Func? Navigating;
///
@@ -162,6 +186,11 @@ namespace Avalonia.Controls
///
/// Called when the page is about to be navigated from.
///
+ ///
+ /// Setting to here
+ /// prevents the async handlers from running and aborts the
+ /// navigation. This method is called before the event.
+ ///
protected virtual void OnNavigatingFrom(NavigatingFromEventArgs args) { }
///
@@ -181,11 +210,18 @@ namespace Avalonia.Controls
{
OnNavigatingFrom(args);
+ if (args.Cancel)
+ return;
+
var navigating = Navigating;
if (navigating != null)
{
foreach (Func handler in navigating.GetInvocationList())
+ {
await handler(args);
+ if (args.Cancel)
+ return;
+ }
}
}
@@ -201,7 +237,7 @@ namespace Avalonia.Controls
UpdateContentSafeAreaPadding();
if (change.Property == HeaderProperty)
- AutomationProperties.SetName(this, change.NewValue as string ?? string.Empty);
+ AutomationProperties.SetName(this, change.GetNewValue() as string ?? string.Empty);
}
protected override void OnLoaded(RoutedEventArgs e)
diff --git a/src/Avalonia.Controls/Page/PageNavigationHost.cs b/src/Avalonia.Controls/Page/PageNavigationHost.cs
index 06ef2d697a..025a8166ab 100644
--- a/src/Avalonia.Controls/Page/PageNavigationHost.cs
+++ b/src/Avalonia.Controls/Page/PageNavigationHost.cs
@@ -129,12 +129,14 @@ namespace Avalonia.Controls
private void ContentPresenter_PropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
{
- if (e.Property == ContentPresenter.ChildProperty
- && e.NewValue is Page page
- && _insetManager != null)
- {
- page.SafeAreaPadding = _insetManager.SafeAreaPadding;
- }
+ if (e.Property != ContentPresenter.ChildProperty)
+ return;
+
+ if (e.GetOldValue() is Page oldPage)
+ oldPage.SafeAreaPadding = default;
+
+ if (e.GetNewValue() is Page newPage && _insetManager != null)
+ newPage.SafeAreaPadding = _insetManager.SafeAreaPadding;
}
private void TopLevel_BackRequested(object? sender, RoutedEventArgs e)
diff --git a/src/Avalonia.Controls/Page/PageSelectionChangedEventArgs.cs b/src/Avalonia.Controls/Page/PageSelectionChangedEventArgs.cs
index e3759b615b..e39596cccc 100644
--- a/src/Avalonia.Controls/Page/PageSelectionChangedEventArgs.cs
+++ b/src/Avalonia.Controls/Page/PageSelectionChangedEventArgs.cs
@@ -1,18 +1,20 @@
-using System;
+using Avalonia.Interactivity;
namespace Avalonia.Controls
{
///
/// Provides data for a page selection-changed event.
///
- public class PageSelectionChangedEventArgs : EventArgs
+ public class PageSelectionChangedEventArgs : RoutedEventArgs
{
///
/// Initializes a new instance of the class.
///
+ /// The routed event associated with this event args instance.
/// The page that was selected before the change, or if no page was selected.
/// The page that is now selected, or if selection was cleared.
- public PageSelectionChangedEventArgs(Page? previousPage, Page? currentPage)
+ public PageSelectionChangedEventArgs(RoutedEvent? routedEvent, Page? previousPage, Page? currentPage)
+ : base(routedEvent)
{
PreviousPage = previousPage;
CurrentPage = currentPage;
diff --git a/src/Avalonia.Controls/Page/SelectingMultiPage.cs b/src/Avalonia.Controls/Page/SelectingMultiPage.cs
index 7f94a86464..9963a0ec2b 100644
--- a/src/Avalonia.Controls/Page/SelectingMultiPage.cs
+++ b/src/Avalonia.Controls/Page/SelectingMultiPage.cs
@@ -1,4 +1,6 @@
using System;
+using System.Collections.Generic;
+using Avalonia.Interactivity;
namespace Avalonia.Controls
{
@@ -24,6 +26,14 @@ namespace Avalonia.Controls
nameof(SelectedPage),
o => o._selectedPage);
+ ///
+ /// Defines the routed event.
+ ///
+ public static readonly RoutedEvent SelectionChangedEvent =
+ RoutedEvent.Register(
+ nameof(SelectionChanged),
+ RoutingStrategies.Bubble);
+
private int _selectedIndex = -1;
private Page? _selectedPage;
@@ -44,11 +54,21 @@ namespace Avalonia.Controls
///
/// Raised when the selected page changes.
///
- public event EventHandler? SelectionChanged;
+ public event EventHandler? SelectionChanged
+ {
+ add => AddHandler(SelectionChangedEvent, value);
+ remove => RemoveHandler(SelectionChangedEvent, value);
+ }
///
/// Commits a selection change and fires lifecycle events on the outgoing and incoming pages.
///
+ ///
+ /// Raises , then on the outgoing
+ /// page and on the incoming page. If the incoming and outgoing
+ /// pages are the same object no events are fired. Subclasses should call this method rather
+ /// than modifying selection state directly.
+ ///
protected void CommitSelection(int newIndex, Page? newPage, NavigationType navigationType = NavigationType.Replace)
{
var previousPage = _selectedPage;
@@ -57,7 +77,7 @@ namespace Avalonia.Controls
SetCurrentValue(CurrentPageProperty, newPage);
if (!ReferenceEquals(previousPage, newPage))
{
- SelectionChanged?.Invoke(this, new PageSelectionChangedEventArgs(previousPage, newPage));
+ RaiseEvent(new PageSelectionChangedEventArgs(SelectionChangedEvent, previousPage, newPage));
if (previousPage != null)
{
@@ -83,5 +103,28 @@ namespace Avalonia.Controls
{
SetAndRaise(SelectedIndexProperty, ref _selectedIndex, index);
}
+
+ ///
+ /// Returns the page at from ,
+ /// or if the index is out of range.
+ ///
+ protected Page? ResolvePageAtIndex(int index)
+ {
+ if (Pages is IList list)
+ return (uint)index < (uint)list.Count ? list[index] : null;
+
+ if (Pages != null)
+ {
+ int i = 0;
+ foreach (var page in Pages)
+ {
+ if (i == index)
+ return page;
+ i++;
+ }
+ }
+
+ return null;
+ }
}
}
diff --git a/src/Avalonia.Controls/Page/TabbedPage.cs b/src/Avalonia.Controls/Page/TabbedPage.cs
index 6a5422b365..69815eb56a 100644
--- a/src/Avalonia.Controls/Page/TabbedPage.cs
+++ b/src/Avalonia.Controls/Page/TabbedPage.cs
@@ -6,13 +6,10 @@ using Avalonia.Automation.Peers;
using Avalonia.Collections;
using Avalonia.Controls.Metadata;
using Avalonia.Controls.Primitives;
-using Avalonia.Controls.Shapes;
using Avalonia.Controls.Templates;
using Avalonia.Input;
using Avalonia.Input.GestureRecognizers;
-using Avalonia.Layout;
using Avalonia.LogicalTree;
-using Avalonia.Media;
using Avalonia.Threading;
namespace Avalonia.Controls
@@ -24,8 +21,11 @@ namespace Avalonia.Controls
public class TabbedPage : SelectingMultiPage
{
private TabControl? _tabControl;
+ private bool _ignoringDisabledSelection;
private readonly Dictionary _containerPageMap = new();
private readonly Dictionary _pageContainerMap = new();
+ private readonly HashSet _templateCreatedPages = new(ReferenceEqualityComparer.Instance);
+ private int _lastSwipeGestureId;
private readonly SwipeGestureRecognizer _swipeRecognizer = new SwipeGestureRecognizer
{
IsEnabled = false
@@ -83,15 +83,21 @@ namespace Avalonia.Controls
public static void SetIsTabEnabled(Page page, bool value) =>
page.SetValue(IsTabEnabledProperty, value);
+ private static readonly bool s_isMobilePlatform = OperatingSystem.IsAndroid() || OperatingSystem.IsIOS();
+
+ static TabbedPage()
+ {
+ FocusableProperty.OverrideDefaultValue(true);
+ }
+
///
/// Initializes a new instance of the class.
///
public TabbedPage()
{
SetCurrentValue(PagesProperty, new AvaloniaList());
- Focusable = true;
GestureRecognizers.Add(_swipeRecognizer);
- AddHandler(InputElement.SwipeGestureEvent, OnSwipeGesture);
+ UpdateSwipeRecognizerAxes();
}
///
@@ -115,6 +121,11 @@ namespace Avalonia.Controls
///
/// Gets or sets whether swipe gestures can be used to navigate between tabs.
///
+ ///
+ /// Defaults to because tab strips do not respond to swipe gestures
+ /// on most platforms (iOS, desktop). Enable this only when the host platform and the
+ /// content inside each tab page do not conflict with horizontal swipe input.
+ ///
public bool IsGestureEnabled
{
get => GetValue(IsGestureEnabledProperty);
@@ -139,6 +150,18 @@ namespace Avalonia.Controls
set => SetValue(IndicatorTemplateProperty, value);
}
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ AddHandler(InputElement.SwipeGestureEvent, OnSwipeGesture);
+ }
+
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnDetachedFromVisualTree(e);
+ RemoveHandler(InputElement.SwipeGestureEvent, OnSwipeGesture);
+ }
+
protected override void ApplySelectedIndex(int index)
{
if (_tabControl != null)
@@ -160,13 +183,10 @@ namespace Avalonia.Controls
_tabControl.SelectionChanged -= TabControl_SelectionChanged;
_tabControl.ContainerPrepared -= OnContainerPrepared;
_tabControl.ContainerClearing -= OnContainerClearing;
-
- foreach (var page in _containerPageMap.Values)
- page.PropertyChanged -= OnPagePropertyChanged;
- _containerPageMap.Clear();
- _pageContainerMap.Clear();
}
+ ClearContainerPages();
+
_tabControl = e.NameScope.Find("PART_TabControl");
if (_tabControl != null)
@@ -174,6 +194,7 @@ namespace Avalonia.Controls
_tabControl.SelectionChanged += TabControl_SelectionChanged;
_tabControl.ContainerPrepared += OnContainerPrepared;
_tabControl.ContainerClearing += OnContainerClearing;
+ _tabControl.ItemsSource = (IEnumerable?)ItemsSource ?? Pages;
if (SelectedIndex >= 0)
_tabControl.SelectedIndex = SelectedIndex;
@@ -185,7 +206,10 @@ namespace Avalonia.Controls
ApplyIndicatorTemplate();
UpdateActivePage();
- Dispatcher.UIThread.Post(SyncAllTabEnabledStates, DispatcherPriority.Loaded);
+ var capturedTabControl = _tabControl;
+ Dispatcher.UIThread.Post(
+ () => SyncAllTabEnabledStates(capturedTabControl),
+ DispatcherPriority.Loaded);
}
}
@@ -194,13 +218,22 @@ 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)
ApplyIndicatorTemplate();
else if (change.Property == IsGestureEnabledProperty)
_swipeRecognizer.IsEnabled = change.GetNewValue();
+ else if (change.Property == ItemsSourceProperty && _tabControl != null)
+ _tabControl.ItemsSource = change.GetNewValue() ?? Pages;
+ else if (change.Property == PagesProperty && ItemsSource == null && _tabControl != null)
+ _tabControl.ItemsSource = change.GetNewValue?>();
+ else if (change.Property == PageTemplateProperty && ItemsSource != null && _tabControl != null)
+ RebuildTemplateCreatedPages();
}
private TabPlacement ResolveTabPlacement()
@@ -208,9 +241,7 @@ namespace Avalonia.Controls
if (TabPlacement != TabPlacement.Auto)
return TabPlacement;
- return OperatingSystem.IsAndroid() || OperatingSystem.IsIOS()
- ? TabPlacement.Bottom
- : TabPlacement.Top;
+ return s_isMobilePlatform ? TabPlacement.Bottom : TabPlacement.Top;
}
private void ApplyTabPlacement()
@@ -227,6 +258,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)
@@ -235,15 +274,13 @@ namespace Avalonia.Controls
_tabControl.IndicatorTemplate = IndicatorTemplate;
}
- private bool _ignoringDisabledSelection;
-
private void TabControl_SelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (_ignoringDisabledSelection)
return;
int newIndex = _tabControl!.SelectedIndex;
- var newPage = _tabControl.SelectedItem as Page ?? ResolvePageAtIndex(newIndex);
+ var newPage = ResolvePageAtIndex(newIndex);
if (newPage != null && !GetIsTabEnabled(newPage))
{
@@ -263,51 +300,60 @@ namespace Avalonia.Controls
if (target == SelectedIndex)
return;
- CommitSelection(target, ResolvePageAtIndex(target), NavigationType.Replace);
+ CommitSelectionIfResolved(target, NavigationType.Replace);
UpdateContentSafeAreaPadding();
return;
}
- CommitSelection(newIndex, newPage, NavigationType.Replace);
+ CommitSelectionIfResolved(newIndex, NavigationType.Replace);
UpdateContentSafeAreaPadding();
}
private void OnContainerPrepared(object? sender, ContainerPreparedEventArgs e)
{
- if (e.Container is not TabItem tabItem) return;
- if (Pages is not IList pages || e.Index >= pages.Count) return;
-
- var item = pages[e.Index];
- Page? page;
+ if (e.Container is not TabItem tabItem)
+ return;
- if (item is Page directPage)
- {
- page = directPage;
- }
- else
- {
- // Data-template mode: build the page and use it directly as the tab's content.
- page = PageTemplate?.Build(item) as Page;
- if (page == null) return;
- tabItem.Content = page;
- }
+ var page = PreparePageForContainer(tabItem, e.Index);
+ if (page == null)
+ return;
tabItem.IsEnabled = GetIsTabEnabled(page);
tabItem.Header = page.Header;
- tabItem.Icon = CreateIconControl(page.Icon);
+ tabItem.Icon = page.Icon;
+ tabItem.IconTemplate = page.IconTemplate;
- _containerPageMap[tabItem] = page;
- _pageContainerMap[page] = tabItem;
- page.PropertyChanged += OnPagePropertyChanged;
+ if (e.Index == (_tabControl?.SelectedIndex ?? -1))
+ UpdateActivePage();
}
private void OnContainerClearing(object? sender, ContainerClearingEventArgs e)
{
- if (e.Container is TabItem tabItem && _containerPageMap.Remove(tabItem, out var page))
+ if (e.Container is TabItem tabItem)
+ ClearContainerPage(tabItem);
+ }
+
+ private void RebuildTemplateCreatedPages()
+ {
+ if (_tabControl == null || ItemsSource == null)
+ return;
+
+ for (int i = 0; i < _tabControl.ItemCount; i++)
{
- _pageContainerMap.Remove(page);
- page.PropertyChanged -= OnPagePropertyChanged;
+ if (_tabControl.ContainerFromIndex(i) is not TabItem tabItem)
+ continue;
+
+ var page = PreparePageForContainer(tabItem, i);
+ if (page == null)
+ continue;
+
+ tabItem.IsEnabled = GetIsTabEnabled(page);
+ tabItem.Header = page.Header;
+ tabItem.Icon = page.Icon;
+ tabItem.IconTemplate = page.IconTemplate;
}
+
+ UpdateActivePage();
}
private void OnPagePropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
@@ -318,7 +364,12 @@ namespace Avalonia.Controls
if (e.Property == Page.IconProperty)
{
if (_pageContainerMap.TryGetValue(page, out var tabItem))
- tabItem.Icon = CreateIconControl(page.Icon);
+ tabItem.Icon = page.Icon;
+ }
+ else if (e.Property == Page.IconTemplateProperty)
+ {
+ if (_pageContainerMap.TryGetValue(page, out var tabItem))
+ tabItem.IconTemplate = page.IconTemplate;
}
else if (e.Property == Page.HeaderProperty)
{
@@ -331,118 +382,103 @@ namespace Avalonia.Controls
}
}
- ///
- /// Creates a visual control from a page icon value.
- ///
- internal static Control? CreateIconControl(object? icon)
- {
- Geometry? geometry = icon switch
- {
- Geometry g => g,
- PathIcon pi => pi.Data,
- DrawingImage { Drawing: GeometryDrawing { Geometry: { } gd } } => gd,
- string s when !string.IsNullOrEmpty(s) => Geometry.Parse(s),
- _ => null
- };
-
- if (geometry != null)
- {
- var path = new Path
- {
- Data = geometry,
- Stretch = Stretch.Uniform,
- HorizontalAlignment = HorizontalAlignment.Center,
- };
-
- path.Bind(
- Path.FillProperty,
- path.GetObservable(Documents.TextElement.ForegroundProperty));
-
- return path;
- }
-
- if (icon is IImage image)
- {
- return new Image
- {
- HorizontalAlignment = HorizontalAlignment.Center,
- Source = image,
- };
- }
-
- return null;
- }
-
private int FindNearestEnabledTab(int disabledIndex)
{
- if (Pages is not IList pages) return -1;
- int count = pages.Count;
+ int count = GetTabCount();
for (int dist = 1; dist < count; dist++)
{
int p = disabledIndex - dist;
- if (p >= 0 && pages[p] is Page pp && GetIsTabEnabled(pp)) return p;
+ if (p >= 0 && IsTabEnabledAtIndex(p))
+ return p;
int n = disabledIndex + dist;
- if (n < count && pages[n] is Page np && GetIsTabEnabled(np)) return n;
+ if (n < count && IsTabEnabledAtIndex(n))
+ return n;
}
return -1;
}
- protected internal int FindNextEnabledTab(int start, int direction)
+ private protected int FindNextEnabledTab(int start, int direction)
{
- if (Pages is not IList pages) return -1;
- int count = pages.Count;
+ int count = GetTabCount();
int i = start;
while (i >= 0 && i < count)
{
- if (pages[i] is Page page && GetIsTabEnabled(page)) return i;
+ if (IsTabEnabledAtIndex(i))
+ return i;
i += direction;
}
return -1;
}
- private Page? ResolvePageAtIndex(int index)
+ internal int GetTabCount()
+ {
+ if (_tabControl != null)
+ return _tabControl.ItemCount;
+
+ var source = (IEnumerable?)ItemsSource ?? Pages;
+ if (source is ICollection collection)
+ return collection.Count;
+
+ if (source == null)
+ return 0;
+
+ int count = 0;
+ foreach (var _ in source)
+ count++;
+
+ return count;
+ }
+
+ private bool IsTabEnabledAtIndex(int index)
{
if (_tabControl?.ContainerFromIndex(index) is TabItem ti && _containerPageMap.TryGetValue(ti, out var p))
- return p;
- if (Pages is IList pages && (uint)index < (uint)pages.Count)
- return pages[index] as Page;
- return null;
+ return GetIsTabEnabled(p);
+ if (ResolvePageAtIndex(index) is Page page)
+ return GetIsTabEnabled(page);
+ return true;
}
- private void SyncTabEnabledState(Page page)
+ private new Page? ResolvePageAtIndex(int index)
{
- if (_tabControl == null || Pages is not IList pages)
- return;
+ if (_tabControl?.ContainerFromIndex(index) is TabItem ti && _containerPageMap.TryGetValue(ti, out var p))
+ return p;
- for (int i = 0; i < pages.Count; i++)
+ if (_tabControl != null)
{
- if (!ReferenceEquals(pages[i], page))
- continue;
+ var itemsView = _tabControl.ItemsView;
+ if ((uint)index < (uint)itemsView.Count)
+ return itemsView[index] as Page;
+ }
- if (_tabControl.ContainerFromIndex(i) is TabItem tabItem)
- tabItem.IsEnabled = GetIsTabEnabled(page);
+ if (_tabControl?.ContainerFromIndex(index) is TabItem { Content: Page page })
+ return page;
- if (!GetIsTabEnabled(page) && i == _tabControl.SelectedIndex)
- {
- int target = FindNearestEnabledTab(i);
- if (target >= 0 && target != i)
- _tabControl.SelectedIndex = target;
- }
+ return base.ResolvePageAtIndex(index);
+ }
+ private void SyncTabEnabledState(Page page)
+ {
+ if (_tabControl == null || !_pageContainerMap.TryGetValue(page, out var tabItem))
return;
+
+ tabItem.IsEnabled = GetIsTabEnabled(page);
+
+ if (!GetIsTabEnabled(page) && ReferenceEquals(tabItem, _tabControl.ContainerFromIndex(_tabControl.SelectedIndex)))
+ {
+ int i = _tabControl.SelectedIndex;
+ int target = FindNearestEnabledTab(i);
+ if (target >= 0 && target != i)
+ _tabControl.SelectedIndex = target;
}
}
- private void SyncAllTabEnabledStates()
+ private void SyncAllTabEnabledStates(TabControl tabControl)
{
- if (_tabControl == null || Pages is not IList pages)
- return;
-
- for (int i = 0; i < pages.Count; i++)
+ for (int i = 0; i < tabControl.ItemCount; i++)
{
- if (_tabControl.ContainerFromIndex(i) is TabItem tabItem &&
+ if (tabControl.ContainerFromIndex(i) is TabItem tabItem &&
_containerPageMap.TryGetValue(tabItem, out var page))
{
tabItem.IsEnabled = GetIsTabEnabled(page);
@@ -455,11 +491,123 @@ namespace Avalonia.Controls
if (_tabControl != null)
{
int index = _tabControl.SelectedIndex;
- CommitSelection(index, _tabControl.SelectedItem as Page ?? ResolvePageAtIndex(index), navigationType);
+ CommitSelectionIfResolved(index, navigationType);
UpdateContentSafeAreaPadding();
}
}
+ private Page? PreparePageForContainer(TabItem tabItem, int index)
+ {
+ ClearContainerPage(tabItem);
+
+ Page? page;
+
+ if (ItemsSource != null)
+ {
+ if (!TryGetItemAtIndex(index, out var item))
+ return null;
+
+ page = PageTemplate?.Build(item) as Page;
+ tabItem.Content = page;
+
+ if (page == null)
+ return null;
+
+ _templateCreatedPages.Add(page);
+ LogicalChildren.Add(page);
+ }
+ else
+ {
+ if (!TryGetItemAtIndex(index, out var item))
+ return null;
+
+ page = item as Page;
+ if (page == null)
+ return null;
+
+ tabItem.Content = page;
+ }
+
+ _containerPageMap[tabItem] = page;
+ _pageContainerMap[page] = tabItem;
+ page.PropertyChanged += OnPagePropertyChanged;
+
+ return page;
+ }
+
+ private void ClearContainerPages()
+ {
+ foreach (var page in _containerPageMap.Values)
+ page.PropertyChanged -= OnPagePropertyChanged;
+
+ foreach (var page in _templateCreatedPages)
+ LogicalChildren.Remove(page);
+
+ _containerPageMap.Clear();
+ _pageContainerMap.Clear();
+ _templateCreatedPages.Clear();
+ }
+
+ private void ClearContainerPage(TabItem tabItem)
+ {
+ if (!_containerPageMap.Remove(tabItem, out var page))
+ return;
+
+ _pageContainerMap.Remove(page);
+ page.PropertyChanged -= OnPagePropertyChanged;
+
+ if (_templateCreatedPages.Remove(page))
+ LogicalChildren.Remove(page);
+ }
+
+ private bool TryGetItemAtIndex(int index, out object? item)
+ {
+ if (_tabControl != null)
+ {
+ var itemsView = _tabControl.ItemsView;
+ if ((uint)index < (uint)itemsView.Count)
+ {
+ item = itemsView[index];
+ return true;
+ }
+ }
+
+ var source = (IEnumerable?)ItemsSource ?? Pages;
+ if (source != null)
+ {
+ int currentIndex = 0;
+ foreach (var candidate in source)
+ {
+ if (currentIndex == index)
+ {
+ item = candidate;
+ return true;
+ }
+
+ currentIndex++;
+ }
+ }
+
+ item = null;
+ return false;
+ }
+
+ private void CommitSelectionIfResolved(int index, NavigationType navigationType)
+ {
+ var page = ResolvePageAtIndex(index);
+
+ if (page == null &&
+ ItemsSource != null &&
+ index >= 0 &&
+ _tabControl?.ContainerFromIndex(index) == null)
+ {
+ StoreSelectedIndex(index);
+ return;
+ }
+
+ CommitSelection(index, page, navigationType);
+ }
+
protected override void UpdateContentSafeAreaPadding()
{
base.UpdateContentSafeAreaPadding();
@@ -500,11 +648,12 @@ 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;
- bool isRtl = FlowDirection == FlowDirection.RightToLeft;
+ bool isRtl = FlowDirection == Media.FlowDirection.RightToLeft;
int delta = (e.SwipeDirection, isHorizontal, isRtl) switch
{
@@ -517,13 +666,15 @@ namespace Avalonia.Controls
_ => 0
};
- if (delta == 0) return;
+ if (delta == 0)
+ return;
int next = FindNextEnabledTab(_tabControl.SelectedIndex + delta, delta);
if (next >= 0)
{
_tabControl.SelectedIndex = next;
e.Handled = true;
+ _lastSwipeGestureId = e.Id;
}
}
@@ -536,7 +687,7 @@ namespace Avalonia.Controls
var resolved = ResolveTabPlacement();
bool isHorizontal = resolved == TabPlacement.Top || resolved == TabPlacement.Bottom;
- bool isRtl = FlowDirection == FlowDirection.RightToLeft;
+ bool isRtl = FlowDirection == Media.FlowDirection.RightToLeft;
bool next = isHorizontal ? (isRtl ? e.Key == Key.Left : e.Key == Key.Right) : e.Key == Key.Down;
bool prev = isHorizontal ? (isRtl ? e.Key == Key.Right : e.Key == Key.Left) : e.Key == Key.Up;
diff --git a/src/Avalonia.Controls/PresentationSource/PresentationSource.cs b/src/Avalonia.Controls/PresentationSource/PresentationSource.cs
index 88fa256aad..cd80dd649c 100644
--- a/src/Avalonia.Controls/PresentationSource/PresentationSource.cs
+++ b/src/Avalonia.Controls/PresentationSource/PresentationSource.cs
@@ -59,7 +59,7 @@ internal partial class PresentationSource : IPresentationSource, IInputRoot, IDi
field?.SetPresentationSourceForRootVisual(this);
Renderer.CompositionTarget.Root = field?.CompositionVisual;
- FocusManager.SetContentRoot(value as IInputElement);
+ FocusManager.ContentRoot = value;
}
}
@@ -150,4 +150,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/ScrollChangedEventArgs.cs b/src/Avalonia.Controls/ScrollChangedEventArgs.cs
index fed23964f5..617d5c3f4d 100644
--- a/src/Avalonia.Controls/ScrollChangedEventArgs.cs
+++ b/src/Avalonia.Controls/ScrollChangedEventArgs.cs
@@ -16,7 +16,7 @@ namespace Avalonia.Controls
}
public ScrollChangedEventArgs(
- RoutedEvent routedEvent,
+ RoutedEvent? routedEvent,
Vector extentDelta,
Vector offsetDelta,
Vector viewportDelta)
diff --git a/src/Avalonia.Controls/SelectionChangedEventArgs.cs b/src/Avalonia.Controls/SelectionChangedEventArgs.cs
index c19d868d57..efbec0c490 100644
--- a/src/Avalonia.Controls/SelectionChangedEventArgs.cs
+++ b/src/Avalonia.Controls/SelectionChangedEventArgs.cs
@@ -15,7 +15,7 @@ namespace Avalonia.Controls
/// The event being raised.
/// The items removed from the selection.
/// The items added to the selection.
- public SelectionChangedEventArgs(RoutedEvent routedEvent, IList removedItems, IList addedItems)
+ public SelectionChangedEventArgs(RoutedEvent? routedEvent, IList removedItems, IList addedItems)
: base(routedEvent)
{
RemovedItems = removedItems;
diff --git a/src/Avalonia.Controls/Spinner.cs b/src/Avalonia.Controls/Spinner.cs
index 64c0db62b1..840113acd4 100644
--- a/src/Avalonia.Controls/Spinner.cs
+++ b/src/Avalonia.Controls/Spinner.cs
@@ -66,7 +66,7 @@ namespace Avalonia.Controls
Direction = direction;
}
- public SpinEventArgs(RoutedEvent routedEvent, SpinDirection direction)
+ public SpinEventArgs(RoutedEvent? routedEvent, SpinDirection direction)
: base(routedEvent)
{
Direction = direction;
@@ -78,7 +78,7 @@ namespace Avalonia.Controls
UsingMouseWheel = usingMouseWheel;
}
- public SpinEventArgs(RoutedEvent routedEvent, SpinDirection direction, bool usingMouseWheel)
+ public SpinEventArgs(RoutedEvent? routedEvent, SpinDirection direction, bool usingMouseWheel)
: base(routedEvent)
{
Direction = direction;
diff --git a/src/Avalonia.Controls/SplitButton/SplitButton.cs b/src/Avalonia.Controls/SplitButton/SplitButton.cs
index 28225a5ad1..390f679191 100644
--- a/src/Avalonia.Controls/SplitButton/SplitButton.cs
+++ b/src/Avalonia.Controls/SplitButton/SplitButton.cs
@@ -331,10 +331,13 @@ namespace Avalonia.Controls
oldFlyout.Hide();
}
+ (oldFlyout as PopupFlyoutBase)?.SetDefaultPlacementTarget(null);
+
// Must unregister events here while a reference to the old flyout still exists
UnregisterFlyoutEvents(oldFlyout);
RegisterFlyoutEvents(newFlyout);
+ (newFlyout as PopupFlyoutBase)?.SetDefaultPlacementTarget(this);
UpdatePseudoClasses();
}
diff --git a/src/Avalonia.Controls/TabItem.cs b/src/Avalonia.Controls/TabItem.cs
index a4829c16ca..6a116b1e28 100644
--- a/src/Avalonia.Controls/TabItem.cs
+++ b/src/Avalonia.Controls/TabItem.cs
@@ -33,8 +33,14 @@ namespace Avalonia.Controls
///
/// Defines the property.
///
- public static readonly StyledProperty IconProperty =
- AvaloniaProperty.Register(nameof(Icon));
+ public static readonly StyledProperty IconProperty =
+ AvaloniaProperty.Register(nameof(Icon));
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty IconTemplateProperty =
+ AvaloniaProperty.Register(nameof(IconTemplate));
///
/// Defines the property.
@@ -77,12 +83,21 @@ namespace Avalonia.Controls
///
/// Gets or sets the icon displayed alongside the tab header.
///
- public Control? Icon
+ public object? Icon
{
get => GetValue(IconProperty);
set => SetValue(IconProperty, value);
}
+ ///
+ /// Gets or sets the data template used to display the icon of the control.
+ ///
+ public IDataTemplate? IconTemplate
+ {
+ get => GetValue(IconTemplateProperty);
+ set => SetValue(IconTemplateProperty, value);
+ }
+
///
/// Gets or sets the data template used to render the selection indicator.
///
diff --git a/src/Avalonia.Controls/TopLevel.cs b/src/Avalonia.Controls/TopLevel.cs
index d39fbb203f..3e1e72210c 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..88a89856bf 100644
--- a/src/Avalonia.Controls/VirtualizingCarouselPanel.cs
+++ b/src/Avalonia.Controls/VirtualizingCarouselPanel.cs
@@ -1,12 +1,18 @@
-using System;
+using System;
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;
@@ -52,15 +111,10 @@ namespace Avalonia.Controls
Size IScrollable.Extent => Extent;
Size IScrollable.Viewport => Viewport;
- Vector IScrollable.Offset
+ Vector IScrollable.Offset
{
get => _offset;
- set
- {
- if ((int)_offset.X != value.X)
- InvalidateMeasure();
- _offset = value;
- }
+ set => SetOffset(value);
}
private Size Extent
@@ -99,37 +153,343 @@ 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 OnItemsControlChanged(ItemsControl? oldValue)
+ {
+ base.OnItemsControlChanged(oldValue);
+ // ItemsPresenter attaches the panel to the visual tree before calling Attach(ItemsControl),
+ // so OnAttachedToVisualTree fires with a null ItemsControl; re-run setup here.
+ RefreshGestureRecognizer();
+ }
+
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ RefreshGestureRecognizer();
+ }
+
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnDetachedFromVisualTree(e);
+ TeardownGestureRecognizer();
+ }
+
protected override Size MeasureOverride(Size availableSize)
+ {
+ if (UsesViewportFractionLayout())
+ return MeasureViewportFractionOverride(availableSize);
+
+ ClearViewportRealized();
+ CancelOffsetAnimation();
+
+ return MeasureSinglePageOverride(availableSize);
+ }
+
+ private Size MeasureSinglePageOverride(Size availableSize)
{
var items = Items;
- var index = (int)_offset.X;
+ var index = (ItemsControl as Carousel)?.SelectedIndex ?? (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;
@@ -138,8 +498,7 @@ namespace Avalonia.Controls
_realized = null;
_realizedIndex = -1;
}
-
- // Get or create an element for the new item.
+
if (index >= 0 && index < items.Count)
{
_realized = GetOrCreateElement(items, index);
@@ -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);
@@ -171,20 +538,109 @@ namespace Avalonia.Controls
_realized is { } to &&
GetTransition() is { } transition)
{
- _transition = new CancellationTokenSource();
+ var transitionCts = new CancellationTokenSource();
+ _transition = transitionCts;
+
+ var forward = _realizedIndex > _transitionFromIndex;
+
+ _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);
+ }
+ }
- var forward = (_realizedIndex > _transitionFromIndex);
- if (Items.Count > 2)
+ foreach (var slot in requiredSlots)
+ {
+ if (!_viewportRealized.ContainsKey(slot.LogicalIndex))
{
- forward = forward || (_transitionFromIndex == Items.Count - 1 && _realizedIndex == 0);
- forward = forward && !(_transitionFromIndex == 0 && _realizedIndex == Items.Count - 1);
+ _viewportRealized[slot.LogicalIndex] = new ViewportRealizedItem(
+ slot.ItemIndex,
+ GetOrCreateElement(Items, slot.ItemIndex));
}
+ }
- transition.Start(_transitionFrom, to, forward, _transition.Token)
- .ContinueWith(TransitionFinished, TaskScheduler.FromCurrentSynchronizationContext());
+ var maxCrossDesiredSize = 0d;
+
+ foreach (var element in _viewportRealized.Values.Select(x => x.Control))
+ {
+ element.Measure(itemSize);
+ maxCrossDesiredSize = Math.Max(maxCrossDesiredSize, GetCrossSize(element.DesiredSize));
}
- return result;
+ 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;
@@ -193,6 +649,9 @@ namespace Avalonia.Controls
{
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 +661,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 +687,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;
}
@@ -251,26 +732,20 @@ namespace Avalonia.Controls
break;
case NotifyCollectionChangedAction.Replace:
if (e.OldStartingIndex < 0)
- {
goto case NotifyCollectionChangedAction.Reset;
- }
Remove(e.OldStartingIndex, e.OldItems!.Count);
Add(e.NewStartingIndex, e.NewItems!.Count);
break;
case NotifyCollectionChangedAction.Move:
if (e.OldStartingIndex < 0)
- {
goto case NotifyCollectionChangedAction.Reset;
- }
Remove(e.OldStartingIndex, e.OldItems!.Count);
var insertIndex = e.NewStartingIndex;
if (e.NewStartingIndex > e.OldStartingIndex)
- {
insertIndex -= e.OldItems.Count - 1;
- }
Add(insertIndex, e.NewItems!.Count);
break;
@@ -281,6 +756,7 @@ namespace Avalonia.Controls
_realized = null;
_realizedIndex = -1;
}
+
break;
}
@@ -291,29 +767,33 @@ namespace Avalonia.Controls
{
Debug.Assert(ItemContainerGenerator is not null);
- var e = GetRealizedElement(index);
+ var element = GetRealizedElement(index);
- if (e is null)
+ if (element is null)
{
var item = items[index];
var generator = ItemContainerGenerator!;
if (generator.NeedsContainer(item, index, out var recycleKey))
{
- e = GetRecycledElement(item, index, recycleKey) ??
+ element = GetRecycledElement(item, index, recycleKey) ??
CreateElement(item, index, recycleKey);
}
else
{
- e = GetItemAsOwnContainer(item, index);
+ element = GetItemAsOwnContainer(item, index);
}
}
- return e;
+ return element;
}
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,38 +859,785 @@ 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
- {
- ItemContainerGenerator.ClearItemContainer(element);
- _recyclePool ??= new();
- if (!_recyclePool.TryGetValue(recycleKey, out var pool))
- {
- pool = new();
- _recyclePool.Add(recycleKey, pool);
- }
+ ItemContainerGenerator.ClearItemContainer(element);
+ _recyclePool ??= new();
- pool.Push(element);
- element.IsVisible = false;
+ if (!_recyclePool.TryGetValue(recycleKey, out var pool))
+ {
+ pool = new();
+ _recyclePool.Add(recycleKey, pool);
}
+
+ pool.Push(element);
}
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,
+ };
+
+ 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/VirtualizingStackPanel.cs b/src/Avalonia.Controls/VirtualizingStackPanel.cs
index bc529b526c..669e75d81a 100644
--- a/src/Avalonia.Controls/VirtualizingStackPanel.cs
+++ b/src/Avalonia.Controls/VirtualizingStackPanel.cs
@@ -84,7 +84,8 @@ namespace Avalonia.Controls
private bool _hasReachedStart = false;
private bool _hasReachedEnd = false;
- private Rect _extendedViewport;
+ private Rect _lastMeasuredExtendedViewport;
+ private Rect _lastKnownExtendedViewport;
static VirtualizingStackPanel()
{
@@ -182,7 +183,7 @@ namespace Avalonia.Controls
///
/// Returns the extended viewport that contains any visible elements and the additional elements for fast scrolling (viewport * CacheLength * 2)
///
- internal Rect ExtendedViewPort => _extendedViewport;
+ internal Rect LastMeasuredExtendedViewPort => _lastMeasuredExtendedViewport;
protected override Size MeasureOverride(Size availableSize)
{
@@ -692,7 +693,7 @@ namespace Avalonia.Controls
Debug.Assert(_realizedElements is not null);
// Use the extended viewport for calculations
- var viewport = _extendedViewport;
+ var viewport = _lastMeasuredExtendedViewport;
// Get the viewport in the orientation direction.
var viewportStart = orientation == Orientation.Horizontal ? viewport.X : viewport.Y;
@@ -1165,40 +1166,25 @@ namespace Avalonia.Controls
ItemContainerGenerator.ItemContainerIndexChanged(element, oldIndex, newIndex);
}
- private void OnEffectiveViewportChanged(object? sender, EffectiveViewportChangedEventArgs e)
+ private Rect CalculateExtendedViewport(bool vertical, double viewportSize, double bufferSize)
{
- var vertical = Orientation == Orientation.Vertical;
- var oldViewportStart = vertical ? _viewport.Top : _viewport.Left;
- var oldViewportEnd = vertical ? _viewport.Bottom : _viewport.Right;
- var oldExtendedViewportStart = vertical ? _extendedViewport.Top : _extendedViewport.Left;
- var oldExtendedViewportEnd = vertical ? _extendedViewport.Bottom : _extendedViewport.Right;
-
- // Update current viewport
- _viewport = e.EffectiveViewport.Intersect(new(Bounds.Size));
- _isWaitingForViewportUpdate = false;
- // Calculate buffer sizes based on viewport dimensions
- var viewportSize = vertical ? _viewport.Height : _viewport.Width;
- var bufferSize = viewportSize * _bufferFactor;
-
- // Calculate extended viewport with relative buffers
- var extendedViewportStart = vertical ?
- Math.Max(0, _viewport.Top - bufferSize) :
+ var extendedViewportStart = vertical ?
+ Math.Max(0, _viewport.Top - bufferSize) :
Math.Max(0, _viewport.Left - bufferSize);
-
- var extendedViewportEnd = vertical ?
- Math.Min(Bounds.Height, _viewport.Bottom + bufferSize) :
+
+ var extendedViewportEnd = vertical ?
+ Math.Min(Bounds.Height, _viewport.Bottom + bufferSize) :
Math.Min(Bounds.Width, _viewport.Right + bufferSize);
- // special case:
// If we are at the start of the list, append 2 * CacheLength additional items
// If we are at the end of the list, prepend 2 * CacheLength additional items
- // - this way we always maintain "2 * CacheLength * element" items.
+ // - this way we always maintain "2 * CacheLength * element" items.
if (vertical)
{
var spaceAbove = _viewport.Top - bufferSize;
var spaceBelow = Bounds.Height - (_viewport.Bottom + bufferSize);
-
+
if (spaceAbove < 0 && spaceBelow >= 0)
extendedViewportEnd = Math.Min(Bounds.Height, extendedViewportEnd + Math.Abs(spaceAbove));
if (spaceAbove >= 0 && spaceBelow < 0)
@@ -1208,30 +1194,48 @@ namespace Avalonia.Controls
{
var spaceLeft = _viewport.Left - bufferSize;
var spaceRight = Bounds.Width - (_viewport.Right + bufferSize);
-
+
if (spaceLeft < 0 && spaceRight >= 0)
extendedViewportEnd = Math.Min(Bounds.Width, extendedViewportEnd + Math.Abs(spaceLeft));
- if(spaceLeft >= 0 && spaceRight < 0)
+ if (spaceLeft >= 0 && spaceRight < 0)
extendedViewportStart = Math.Max(0, extendedViewportStart - Math.Abs(spaceRight));
}
- Rect extendedViewPort;
if (vertical)
{
- extendedViewPort = new Rect(
- _viewport.X,
+ return new Rect(
+ _viewport.X,
extendedViewportStart,
_viewport.Width,
extendedViewportEnd - extendedViewportStart);
}
else
{
- extendedViewPort = new Rect(
+ return new Rect(
extendedViewportStart,
_viewport.Y,
extendedViewportEnd - extendedViewportStart,
_viewport.Height);
}
+ }
+
+ private void OnEffectiveViewportChanged(object? sender, EffectiveViewportChangedEventArgs e)
+ {
+ var vertical = Orientation == Orientation.Vertical;
+ var oldViewportStart = vertical ? _viewport.Top : _viewport.Left;
+ var oldViewportEnd = vertical ? _viewport.Bottom : _viewport.Right;
+ var oldExtendedViewportStart = vertical ? _lastMeasuredExtendedViewport.Top : _lastMeasuredExtendedViewport.Left;
+ var oldExtendedViewportEnd = vertical ? _lastMeasuredExtendedViewport.Bottom : _lastMeasuredExtendedViewport.Right;
+
+ // Update current viewport
+ _viewport = e.EffectiveViewport.Intersect(new(Bounds.Size));
+ _isWaitingForViewportUpdate = false;
+
+ // Calculate buffer sizes based on viewport dimensions
+ var viewportSize = vertical ? _viewport.Height : _viewport.Width;
+ var bufferSize = viewportSize * _bufferFactor;
+
+ var extendedViewPort = CalculateExtendedViewport(vertical, viewportSize, bufferSize);
// Determine if we need a new measure
var newViewportStart = vertical ? _viewport.Top : _viewport.Left;
@@ -1240,14 +1244,13 @@ namespace Avalonia.Controls
var newExtendedViewportEnd = vertical ? extendedViewPort.Bottom : extendedViewPort.Right;
var needsMeasure = false;
-
-
+
// Case 1: Viewport has changed significantly
if (!MathUtilities.AreClose(oldViewportStart, newViewportStart) ||
!MathUtilities.AreClose(oldViewportEnd, newViewportEnd))
{
// Case 1a: The new viewport exceeds the old extended viewport
- if (newViewportStart < oldExtendedViewportStart ||
+ if (newViewportStart < oldExtendedViewportStart ||
newViewportEnd > oldExtendedViewportEnd)
{
needsMeasure = true;
@@ -1259,19 +1262,19 @@ namespace Avalonia.Controls
// Check if we're about to scroll into an area where we don't have realized elements
// This would be the case if we're near the edge of our current extended viewport
var nearingEdge = false;
-
+
if (_realizedElements != null)
{
var firstRealizedElementU = _realizedElements.StartU;
var lastRealizedElementU = _realizedElements.StartU;
-
+
for (var i = 0; i < _realizedElements.Count; i++)
{
lastRealizedElementU += _realizedElements.SizeU[i];
}
-
+
// If scrolling up/left and nearing the top/left edge of realized elements
- if (newViewportStart < oldViewportStart &&
+ if (newViewportStart < oldViewportStart &&
newViewportStart - newExtendedViewportStart < bufferSize)
{
// Edge case: We're at item 0 with excess measurement space.
@@ -1279,9 +1282,9 @@ namespace Avalonia.Controls
// This prevents redundant Measure-Arrange cycles when at list beginning.
nearingEdge = !_hasReachedStart;
}
-
+
// If scrolling down/right and nearing the bottom/right edge of realized elements
- if (newViewportEnd > oldViewportEnd &&
+ if (newViewportEnd > oldViewportEnd &&
newExtendedViewportEnd - newViewportEnd < bufferSize)
{
// Edge case: We're at the last item with excess measurement space.
@@ -1294,16 +1297,34 @@ namespace Avalonia.Controls
{
nearingEdge = true;
}
-
+
needsMeasure = nearingEdge;
}
}
+ // Supplementary check: detect viewport growth after a previous shrink.
+ // The main comparison (Cases 1a/1b) uses _extendedViewport which only updates
+ // on measure. When the viewport shrinks (e.g. ComboBox popup during filtering),
+ // _extendedViewport stays stale-large, masking subsequent growth. Compare against
+ // _lastKnownExtendedViewport (always updated) to catch this case.
+ if (!needsMeasure)
+ {
+ var lastKnownStart = vertical ? _lastKnownExtendedViewport.Top : _lastKnownExtendedViewport.Left;
+ var lastKnownEnd = vertical ? _lastKnownExtendedViewport.Bottom : _lastKnownExtendedViewport.Right;
+ if (newViewportStart < lastKnownStart || newViewportEnd > lastKnownEnd)
+ {
+ needsMeasure = true;
+ }
+ }
+
+ _lastKnownExtendedViewport = extendedViewPort;
+
if (needsMeasure)
{
- // only store the new "old" extended viewport if we _did_ actually measure
- _extendedViewport = extendedViewPort;
-
+ // Only update the measure viewport when triggering a measure. This keeps the
+ // wider realization range available for externally-triggered measures (e.g. from
+ // OnItemsChanged), ensuring enough items are realized.
+ _lastMeasuredExtendedViewport = extendedViewPort;
InvalidateMeasure();
}
}
diff --git a/src/Avalonia.Controls/Window.cs b/src/Avalonia.Controls/Window.cs
index 1a83be2017..47d571e5b7 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;
@@ -265,7 +266,7 @@ namespace Avalonia.Controls
});
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));
@@ -864,6 +865,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)
@@ -970,6 +977,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);
@@ -1500,7 +1509,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)
@@ -1512,6 +1521,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/CarouselPage.xaml b/src/Avalonia.Themes.Fluent/Controls/CarouselPage.xaml
new file mode 100644
index 0000000000..25b5d67cb3
--- /dev/null
+++ b/src/Avalonia.Themes.Fluent/Controls/CarouselPage.xaml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Avalonia.Themes.Fluent/Controls/DrawerPage.xaml b/src/Avalonia.Themes.Fluent/Controls/DrawerPage.xaml
index 4f892153b1..985814c967 100644
--- a/src/Avalonia.Themes.Fluent/Controls/DrawerPage.xaml
+++ b/src/Avalonia.Themes.Fluent/Controls/DrawerPage.xaml
@@ -47,6 +47,8 @@
VerticalAlignment="Center"/>
@@ -101,6 +103,8 @@
VerticalAlignment="Center"/>
@@ -146,6 +150,8 @@
VerticalAlignment="Center"/>
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 @@
-
+
+
diff --git a/src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml b/src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml
index 4278b91e36..5b31fdb55c 100644
--- a/src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml
+++ b/src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml
@@ -1,11 +1,13 @@
+
@@ -18,62 +20,60 @@
-
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+ VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
+ UseLayoutRounding="{TemplateBinding UseLayoutRounding}" />
+
+
+
@@ -84,7 +84,7 @@
+
diff --git a/src/Avalonia.Themes.Fluent/Controls/NavigationPage.xaml b/src/Avalonia.Themes.Fluent/Controls/NavigationPage.xaml
index dd12d3ce27..8c2172a787 100644
--- a/src/Avalonia.Themes.Fluent/Controls/NavigationPage.xaml
+++ b/src/Avalonia.Themes.Fluent/Controls/NavigationPage.xaml
@@ -18,12 +18,26 @@
-
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
@@ -107,16 +121,15 @@
ToolTip.Tip="{x:Null}">
+ VerticalAlignment="Center" />
diff --git a/src/Avalonia.Themes.Fluent/Controls/OverlayPopupHost.xaml b/src/Avalonia.Themes.Fluent/Controls/OverlayPopupHost.xaml
index 3b1b5f6c3d..132400d859 100644
--- a/src/Avalonia.Themes.Fluent/Controls/OverlayPopupHost.xaml
+++ b/src/Avalonia.Themes.Fluent/Controls/OverlayPopupHost.xaml
@@ -11,7 +11,7 @@
-
+
-
+
+ VerticalAlignment="Stretch" />
diff --git a/src/Avalonia.Themes.Fluent/Controls/Window.xaml b/src/Avalonia.Themes.Fluent/Controls/Window.xaml
index f56c9fbd10..0f25079004 100644
--- a/src/Avalonia.Themes.Fluent/Controls/Window.xaml
+++ b/src/Avalonia.Themes.Fluent/Controls/Window.xaml
@@ -14,7 +14,7 @@
-
+
-
-
-
-
+
-
+
+
diff --git a/src/Avalonia.Themes.Simple/Controls/NavigationPage.xaml b/src/Avalonia.Themes.Simple/Controls/NavigationPage.xaml
index fee14e61ba..66325a9ba7 100644
--- a/src/Avalonia.Themes.Simple/Controls/NavigationPage.xaml
+++ b/src/Avalonia.Themes.Simple/Controls/NavigationPage.xaml
@@ -17,12 +17,26 @@
-
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
@@ -101,16 +115,15 @@
ToolTip.Tip="{x:Null}">
+ VerticalAlignment="Center" />
diff --git a/src/Avalonia.Themes.Simple/Controls/OverlayPopupHost.xaml b/src/Avalonia.Themes.Simple/Controls/OverlayPopupHost.xaml
index 6e2f41cf2c..515d821169 100644
--- a/src/Avalonia.Themes.Simple/Controls/OverlayPopupHost.xaml
+++ b/src/Avalonia.Themes.Simple/Controls/OverlayPopupHost.xaml
@@ -13,7 +13,7 @@
-
+
-
+
+
diff --git a/src/Avalonia.Themes.Simple/Controls/TabbedPage.xaml b/src/Avalonia.Themes.Simple/Controls/TabbedPage.xaml
index 2ce01f4392..c18e57581c 100644
--- a/src/Avalonia.Themes.Simple/Controls/TabbedPage.xaml
+++ b/src/Avalonia.Themes.Simple/Controls/TabbedPage.xaml
@@ -133,6 +133,7 @@
VerticalAlignment="{TemplateBinding VerticalContentAlignment}">
+ VerticalAlignment="Stretch" />
diff --git a/src/Avalonia.Themes.Simple/Controls/Window.xaml b/src/Avalonia.Themes.Simple/Controls/Window.xaml
index 43eef2e515..e524a8a8a8 100644
--- a/src/Avalonia.Themes.Simple/Controls/Window.xaml
+++ b/src/Avalonia.Themes.Simple/Controls/Window.xaml
@@ -17,7 +17,7 @@
IsHitTestVisible="False" />
-
+
-
-
-
+ """);
+
+ 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/CarouselPageTests.cs b/tests/Avalonia.RenderTests/Controls/CarouselPageTests.cs
new file mode 100644
index 0000000000..00d669bf14
--- /dev/null
+++ b/tests/Avalonia.RenderTests/Controls/CarouselPageTests.cs
@@ -0,0 +1,133 @@
+using System.Collections;
+using System.Threading.Tasks;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Styling;
+using Avalonia.Themes.Simple;
+using Xunit;
+
+#if AVALONIA_SKIA
+namespace Avalonia.Skia.RenderTests
+#else
+namespace Avalonia.Direct2D1.RenderTests.Controls
+#endif
+{
+ public class CarouselPageRenderTests : TestBase
+ {
+ public CarouselPageRenderTests()
+ : base(@"Controls\CarouselPage")
+ {
+ }
+
+ private static Style FontStyle => new Style(x => x.OfType())
+ {
+ Setters = { new Setter(TextBlock.FontFamilyProperty, TestFontFamily) }
+ };
+
+ private static ContentPage MakePage(string label, string bgHex, string fgHex) =>
+ new ContentPage
+ {
+ Header = label,
+ Background = new SolidColorBrush(Color.Parse(bgHex)),
+ Content = new TextBlock
+ {
+ Text = label,
+ Foreground = new SolidColorBrush(Color.Parse(fgHex)),
+ FontSize = 28,
+ FontWeight = FontWeight.Bold,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center
+ },
+ HorizontalContentAlignment = HorizontalAlignment.Center,
+ VerticalContentAlignment = VerticalAlignment.Center
+ };
+
+ [Fact]
+ public async Task CarouselPage_Blue_Page()
+ {
+ var cp = new CarouselPage { Background = Brushes.White, PageTransition = null };
+ ((IList)cp.Pages!).Add(MakePage("Page 1", "#1565C0", "#FFFFFF"));
+
+ var target = new Decorator { Width = 400, Height = 300, Child = cp };
+ target.Styles.Add(new SimpleTheme());
+ target.Styles.Add(FontStyle);
+ await RenderToFile(target);
+ CompareImages(skipImmediate: true);
+ }
+
+ [Fact]
+ public async Task CarouselPage_Green_Page()
+ {
+ var cp = new CarouselPage { Background = Brushes.White, PageTransition = null };
+ ((IList)cp.Pages!).Add(MakePage("Page 2", "#2E7D32", "#FFFFFF"));
+
+ var target = new Decorator { Width = 400, Height = 300, Child = cp };
+ target.Styles.Add(new SimpleTheme());
+ target.Styles.Add(FontStyle);
+ await RenderToFile(target);
+ CompareImages(skipImmediate: true);
+ }
+
+ [Fact]
+ public async Task CarouselPage_Red_Page()
+ {
+ var cp = new CarouselPage { Background = Brushes.White, PageTransition = null };
+ ((IList)cp.Pages!).Add(MakePage("Page 3", "#C62828", "#FFFFFF"));
+
+ var target = new Decorator { Width = 400, Height = 300, Child = cp };
+ target.Styles.Add(new SimpleTheme());
+ target.Styles.Add(FontStyle);
+ await RenderToFile(target);
+ CompareImages(skipImmediate: true);
+ }
+
+ [Fact]
+ public async Task CarouselPage_ThreePages_FirstSelected()
+ {
+ var cp = new CarouselPage { Background = Brushes.White, PageTransition = null };
+ ((IList)cp.Pages!).Add(MakePage("Page 1", "#1565C0", "#FFFFFF"));
+ ((IList)cp.Pages!).Add(MakePage("Page 2", "#2E7D32", "#FFFFFF"));
+ ((IList)cp.Pages!).Add(MakePage("Page 3", "#C62828", "#FFFFFF"));
+
+ var target = new Decorator { Width = 400, Height = 300, Child = cp };
+ target.Styles.Add(new SimpleTheme());
+ target.Styles.Add(FontStyle);
+ await RenderToFile(target);
+ CompareImages(skipImmediate: true);
+ }
+
+ [Fact]
+ public async Task CarouselPage_ThreePages_SecondSelected()
+ {
+ var cp = new CarouselPage { Background = Brushes.White, PageTransition = null };
+ ((IList)cp.Pages!).Add(MakePage("Page 1", "#1565C0", "#FFFFFF"));
+ ((IList)cp.Pages!).Add(MakePage("Page 2", "#2E7D32", "#FFFFFF"));
+ ((IList)cp.Pages!).Add(MakePage("Page 3", "#C62828", "#FFFFFF"));
+ cp.SelectedIndex = 1;
+
+ var target = new Decorator { Width = 400, Height = 300, Child = cp };
+ target.Styles.Add(new SimpleTheme());
+ target.Styles.Add(FontStyle);
+ await RenderToFile(target);
+ CompareImages(skipImmediate: true);
+ }
+
+ [Fact]
+ public async Task CarouselPage_CustomBackground()
+ {
+ var cp = new CarouselPage
+ {
+ Background = new SolidColorBrush(Color.Parse("#212121")),
+ PageTransition = null
+ };
+ ((IList)cp.Pages!).Add(MakePage("Dark Theme", "#F57F17", "#212121"));
+
+ var target = new Decorator { Width = 400, Height = 300, Child = cp };
+ target.Styles.Add(new SimpleTheme());
+ target.Styles.Add(FontStyle);
+ await RenderToFile(target);
+ CompareImages(skipImmediate: true);
+ }
+ }
+}
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.RenderTests/TestRenderHelper.cs b/tests/Avalonia.RenderTests/TestRenderHelper.cs
index 87132881f7..2bd12cfa81 100644
--- a/tests/Avalonia.RenderTests/TestRenderHelper.cs
+++ b/tests/Avalonia.RenderTests/TestRenderHelper.cs
@@ -5,6 +5,7 @@ using Avalonia.Media.Imaging;
using Avalonia.Rendering;
using SixLabors.ImageSharp;
using Xunit;
+using Avalonia.Input;
using Avalonia.Platform;
using System.Threading.Tasks;
using System;
@@ -33,6 +34,18 @@ static class TestRenderHelper
SkiaPlatform.Initialize();
AvaloniaLocator.CurrentMutable.Bind().ToConstant(new StandardAssetLoader());
AvaloniaLocator.CurrentMutable.Bind().ToConstant(new HarfBuzzTextShaper());
+ AvaloniaLocator.CurrentMutable.Bind().ToConstant(new NullCursorFactory());
+ }
+
+ private sealed class NullCursorFactory : ICursorFactory
+ {
+ public ICursorImpl GetCursor(StandardCursorType cursorType) => new NullCursor();
+ public ICursorImpl CreateCursor(Bitmap cursor, PixelPoint hotSpot) => new NullCursor();
+
+ private sealed class NullCursor : ICursorImpl
+ {
+ public void Dispose() { }
+ }
}
diff --git a/tests/Avalonia.UnitTests/MouseTestHelper.cs b/tests/Avalonia.UnitTests/MouseTestHelper.cs
index 235841fd04..082ae718f3 100644
--- a/tests/Avalonia.UnitTests/MouseTestHelper.cs
+++ b/tests/Avalonia.UnitTests/MouseTestHelper.cs
@@ -68,8 +68,13 @@ namespace Avalonia.UnitTests
public void Move(Interactive target, Interactive source, in Point position, KeyModifiers modifiers = default)
{
- target.RaiseEvent(new PointerEventArgs(InputElement.PointerMovedEvent, source, _pointer, GetRoot(target), position,
- Timestamp(), new PointerPointProperties((RawInputModifiers)_pressedButtons, PointerUpdateKind.Other), modifiers));
+ var e = new PointerEventArgs(InputElement.PointerMovedEvent, source, _pointer, GetRoot(target), position,
+ Timestamp(), new PointerPointProperties((RawInputModifiers)_pressedButtons, PointerUpdateKind.Other), modifiers);
+
+ if (_pointer.CapturedGestureRecognizer != null)
+ _pointer.CapturedGestureRecognizer.PointerMovedInternal(e);
+ else
+ target.RaiseEvent(e);
}
public void Up(Interactive target, MouseButton mouseButton = MouseButton.Left, Point? position = null,
@@ -88,9 +93,17 @@ namespace Avalonia.UnitTests
);
if (ButtonCount(props) == 0)
{
- target.RaiseEvent(new PointerReleasedEventArgs(source, _pointer, GetRoot(target), position ?? MidpointRelativeToRoot(target),
- Timestamp(), props, modifiers, _pressedButton));
+ var e = new PointerReleasedEventArgs(source, _pointer, GetRoot(target), position ?? MidpointRelativeToRoot(target),
+ Timestamp(), props, modifiers, _pressedButton);
+
+ if (_pointer.CapturedGestureRecognizer != null)
+ _pointer.CapturedGestureRecognizer.PointerReleasedInternal(e);
+ else
+ target.RaiseEvent(e);
+
_pointer.Capture(null);
+ _pointer.CaptureGestureRecognizer(null);
+ _pointer.IsGestureRecognitionSkipped = false;
}
else
Move(target, source, position ?? default);
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
diff --git a/tests/TestFiles/Skia/Controls/CarouselPage/CarouselPage_Blue_Page.expected.png b/tests/TestFiles/Skia/Controls/CarouselPage/CarouselPage_Blue_Page.expected.png
new file mode 100644
index 0000000000..4bc663b85b
Binary files /dev/null and b/tests/TestFiles/Skia/Controls/CarouselPage/CarouselPage_Blue_Page.expected.png differ
diff --git a/tests/TestFiles/Skia/Controls/CarouselPage/CarouselPage_CustomBackground.expected.png b/tests/TestFiles/Skia/Controls/CarouselPage/CarouselPage_CustomBackground.expected.png
new file mode 100644
index 0000000000..3f3373bc75
Binary files /dev/null and b/tests/TestFiles/Skia/Controls/CarouselPage/CarouselPage_CustomBackground.expected.png differ
diff --git a/tests/TestFiles/Skia/Controls/CarouselPage/CarouselPage_Green_Page.expected.png b/tests/TestFiles/Skia/Controls/CarouselPage/CarouselPage_Green_Page.expected.png
new file mode 100644
index 0000000000..651b32b47f
Binary files /dev/null and b/tests/TestFiles/Skia/Controls/CarouselPage/CarouselPage_Green_Page.expected.png differ
diff --git a/tests/TestFiles/Skia/Controls/CarouselPage/CarouselPage_Red_Page.expected.png b/tests/TestFiles/Skia/Controls/CarouselPage/CarouselPage_Red_Page.expected.png
new file mode 100644
index 0000000000..f8ffcb901d
Binary files /dev/null and b/tests/TestFiles/Skia/Controls/CarouselPage/CarouselPage_Red_Page.expected.png differ
diff --git a/tests/TestFiles/Skia/Controls/CarouselPage/CarouselPage_ThreePages_FirstSelected.expected.png b/tests/TestFiles/Skia/Controls/CarouselPage/CarouselPage_ThreePages_FirstSelected.expected.png
new file mode 100644
index 0000000000..4bc663b85b
Binary files /dev/null and b/tests/TestFiles/Skia/Controls/CarouselPage/CarouselPage_ThreePages_FirstSelected.expected.png differ
diff --git a/tests/TestFiles/Skia/Controls/CarouselPage/CarouselPage_ThreePages_SecondSelected.expected.png b/tests/TestFiles/Skia/Controls/CarouselPage/CarouselPage_ThreePages_SecondSelected.expected.png
new file mode 100644
index 0000000000..651b32b47f
Binary files /dev/null and b/tests/TestFiles/Skia/Controls/CarouselPage/CarouselPage_ThreePages_SecondSelected.expected.png differ