diff --git a/api/Avalonia.nupkg.xml b/api/Avalonia.nupkg.xml
index c03d1fe6cc..b2a81dd55d 100644
--- a/api/Avalonia.nupkg.xml
+++ b/api/Avalonia.nupkg.xml
@@ -991,6 +991,18 @@
baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+ CP0002
+ F:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.CrossAxisCancelThresholdProperty
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+
+ CP0002
+ F:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.EdgeSizeProperty
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
CP0002
F:Avalonia.Input.HoldingState.Cancelled
@@ -1147,6 +1159,30 @@
baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+ CP0002
+ M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.get_CrossAxisCancelThreshold
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+
+ CP0002
+ M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.get_EdgeSize
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+
+ CP0002
+ M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.set_CrossAxisCancelThreshold(System.Double)
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+
+ CP0002
+ M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.set_EdgeSize(System.Double)
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
CP0002
M:Avalonia.Input.HoldingRoutedEventArgs.#ctor(Avalonia.Input.HoldingState,Avalonia.Point,Avalonia.Input.PointerType)
@@ -1411,6 +1447,18 @@
baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+ CP0002
+ M:Avalonia.Input.SwipeGestureEventArgs.#ctor(System.Int32,Avalonia.Input.SwipeDirection,Avalonia.Vector,Avalonia.Point)
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+
+ CP0002
+ M:Avalonia.Input.SwipeGestureEventArgs.get_StartPoint
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
CP0002
M:Avalonia.Input.TextInput.TextInputMethodClient.ShowInputPanel
@@ -1801,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
@@ -1813,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
@@ -1843,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
@@ -1981,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
@@ -2017,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)
@@ -2221,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
@@ -2239,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)
@@ -2545,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
@@ -2701,6 +2821,30 @@
baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
+ CP0002
+ M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.get_CrossAxisCancelThreshold
+ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
+ current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
+
+ CP0002
+ M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.get_EdgeSize
+ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
+ current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
+
+ CP0002
+ M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.set_CrossAxisCancelThreshold(System.Double)
+ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
+ current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
+
+ CP0002
+ M:Avalonia.Input.GestureRecognizers.SwipeGestureRecognizer.set_EdgeSize(System.Double)
+ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
+ current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
CP0002
M:Avalonia.Input.HoldingRoutedEventArgs.#ctor(Avalonia.Input.HoldingState,Avalonia.Point,Avalonia.Input.PointerType)
@@ -2965,6 +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
@@ -3355,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
@@ -3367,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
@@ -3397,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
@@ -3535,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
@@ -3571,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)
@@ -3775,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
@@ -3793,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)
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/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..8609235b2a 100644
--- a/src/Avalonia.Base/Input/DragEventArgs.cs
+++ b/src/Avalonia.Base/Input/DragEventArgs.cs
@@ -27,7 +27,7 @@ namespace Avalonia.Input
[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 72d83ec419..ee9c2e6fef 100644
--- a/src/Avalonia.Base/Input/FindNextElementOptions.cs
+++ b/src/Avalonia.Base/Input/FindNextElementOptions.cs
@@ -1,10 +1,4 @@
-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
@@ -12,14 +6,28 @@ namespace Avalonia.Input
///
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; }
@@ -27,6 +35,11 @@ namespace Avalonia.Input
/// 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; }
///
@@ -35,12 +48,22 @@ namespace Avalonia.Input
/// 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; }
///
@@ -49,6 +72,11 @@ namespace Avalonia.Input
/// 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 dc62171f48..a273ff6d89 100644
--- a/src/Avalonia.Base/Input/FocusManager.cs
+++ b/src/Avalonia.Base/Input/FocusManager.cs
@@ -172,7 +172,7 @@ namespace Avalonia.Input
ValidateDirection(direction);
var focusOptions = ToFocusOptions(options, true);
- var result = FindAndSetNextFocus(direction, focusOptions);
+ var result = FindAndSetNextFocus(options?.FocusedElement ?? Current, direction, focusOptions);
_reusableFocusOptions = focusOptions;
return result;
}
@@ -319,7 +319,7 @@ namespace Avalonia.Input
ValidateDirection(direction);
var focusOptions = ToFocusOptions(options, false);
- var result = FindNextFocus(direction, focusOptions);
+ var result = FindNextFocus(options?.FocusedElement ?? Current, direction, focusOptions);
_reusableFocusOptions = focusOptions;
return result;
}
@@ -368,27 +368,29 @@ namespace Avalonia.Input
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);
@@ -509,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,
@@ -535,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)
{
@@ -567,7 +572,7 @@ namespace Avalonia.Input
}
else if (!isReverse)
{
- newTabStop = GetNextTabStop();
+ newTabStop = GetNextTabStop(focusedElement);
if (newTabStop == null && (internalCycleWorkaround || queryOnly))
{
@@ -578,7 +583,7 @@ namespace Avalonia.Input
}
else
{
- newTabStop = GetPreviousTabStop();
+ newTabStop = GetPreviousTabStop(focusedElement);
if (newTabStop == null && (internalCycleWorkaround || queryOnly))
{
@@ -590,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;
@@ -698,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;
@@ -930,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)
@@ -961,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;
}
@@ -975,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)
@@ -1041,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;
@@ -1051,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;
@@ -1064,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)
@@ -1091,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)
{
@@ -1099,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)
@@ -1107,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 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 48b7bd1b0c..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
@@ -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/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/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 8556d03d91..ceb9590564 100644
--- a/src/Avalonia.Controls/TopLevel.cs
+++ b/src/Avalonia.Controls/TopLevel.cs
@@ -38,6 +38,7 @@ namespace Avalonia.Controls
/// tracking the widget's .
///
[TemplatePart("PART_TransparencyFallback", typeof(Border))]
+ [TemplatePart("PART_VisualLayerManager", typeof(VisualLayerManager))]
public abstract class TopLevel : ContentControl,
ICloseable,
IStyleHost,
@@ -125,6 +126,7 @@ namespace Avalonia.Controls
private Size? _frameSize;
private WindowTransparencyLevel _actualTransparencyLevel;
private Border? _transparencyFallbackBorder;
+ private VisualLayerManager? _visualLayerManager;
private TargetWeakEventSubscriber? _resourcesChangesSubscriber;
private IStorageProvider? _storageProvider;
private Screens? _screens;
@@ -133,6 +135,18 @@ namespace Avalonia.Controls
internal TopLevelHost TopLevelHost => _topLevelHost;
internal new PresentationSource PresentationSource => _source;
internal IInputRoot InputRoot => _source;
+
+ private protected VisualLayerManager? VisualLayerManager => _visualLayerManager;
+
+ private protected void EnableVisualLayerManagerLayers()
+ {
+ if (_visualLayerManager is { } vlm)
+ {
+ vlm.EnableOverlayLayer = true;
+ vlm.EnablePopupOverlayLayer = true;
+ vlm.EnableTextSelectorLayer = true;
+ }
+ }
///
/// Initializes static members of the class.
@@ -723,6 +737,8 @@ namespace Avalonia.Controls
{
base.OnApplyTemplate(e);
+ _visualLayerManager = e.NameScope.Find("PART_VisualLayerManager");
+
if (PlatformImpl is null)
return;
diff --git a/src/Avalonia.Controls/VirtualizingCarouselPanel.cs b/src/Avalonia.Controls/VirtualizingCarouselPanel.cs
index 454069b4b2..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 db3ec6a077..e7a4ce953e 100644
--- a/src/Avalonia.Controls/Window.cs
+++ b/src/Avalonia.Controls/Window.cs
@@ -6,6 +6,7 @@ using System.Threading.Tasks;
using Avalonia.Automation.Peers;
using Avalonia.Controls.Chrome;
using Avalonia.Controls.Platform;
+using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
@@ -795,6 +796,12 @@ namespace Avalonia.Controls
ShowCore(null, false);
}
+ protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
+ {
+ base.OnApplyTemplate(e);
+ EnableVisualLayerManagerLayers();
+ }
+
protected override void IsVisibleChanged(AvaloniaPropertyChangedEventArgs e)
{
if (!IgnoreVisibilityChanges)
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" />
-
+
-
-
-