diff --git a/api/Avalonia.nupkg.xml b/api/Avalonia.nupkg.xml
index d215b13118..f50d0d1d77 100644
--- a/api/Avalonia.nupkg.xml
+++ b/api/Avalonia.nupkg.xml
@@ -1849,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
@@ -2029,6 +2035,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
@@ -2065,6 +2083,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)
@@ -2305,6 +2329,12 @@
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.SubscribeToOwnerProperties(Avalonia.AvaloniaObject)
@@ -3463,6 +3493,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
@@ -3643,6 +3679,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
@@ -3679,6 +3727,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)
@@ -3919,6 +3973,12 @@
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.SubscribeToOwnerProperties(Avalonia.AvaloniaObject)
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 2a0de7a114..adea1b90fc 100644
--- a/samples/ControlCatalog/MainView.xaml
+++ b/samples/ControlCatalog/MainView.xaml
@@ -59,6 +59,11 @@
ScrollViewer.VerticalScrollBarVisibility="Disabled">
+
+
+
diff --git a/samples/ControlCatalog/Pages/CarouselDemoPage.xaml.cs b/samples/ControlCatalog/Pages/CarouselDemoPage.xaml.cs
index 64753b9fc4..36c9961658 100644
--- a/samples/ControlCatalog/Pages/CarouselDemoPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/CarouselDemoPage.xaml.cs
@@ -9,32 +9,70 @@ namespace ControlCatalog.Pages
private static readonly (string Group, string Title, string Description, Func Factory)[] Demos =
{
// Overview
- ("Overview", "Getting Started",
- "Basic Carousel with image items and previous/next navigation buttons.",
- () => new CarouselGettingStartedPage()),
+ ("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", "Transitions",
+ ("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()),
- ("Features", "Customization",
+ ("Carousel", "Customization",
"Adjust orientation and transition type to tailor the carousel layout.",
() => new CarouselCustomizationPage()),
- ("Features", "Gestures & Keyboard",
+ ("Carousel", "Gestures & Keyboard",
"Navigate items via swipe gesture and arrow keys. Toggle each input mode on and off.",
() => new CarouselGesturesPage()),
- ("Features", "Vertical Orientation",
+ ("Carousel", "Vertical Orientation",
"Carousel with Orientation set to Vertical, navigated with Up/Down keys, swipe, or buttons.",
() => new CarouselVerticalPage()),
- ("Features", "Multi-Item Peek",
+ ("Carousel", "Multi-Item Peek",
"Adjust ViewportFraction to show multiple items simultaneously with adjacent cards peeking.",
() => new CarouselMultiItemPage()),
- ("Features", "Data Binding",
+ ("Carousel", "Data Binding",
"Bind Carousel to an ObservableCollection and add, remove, or shuffle items at runtime.",
() => new CarouselDataBindingPage()),
-
- // Showcases
- ("Showcases", "Curated Gallery",
+ ("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()),
};
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
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/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 c18cfebc7e..e185208119 100644
--- a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageGesturePage.xaml.cs
+++ b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageGesturePage.xaml.cs
@@ -7,6 +7,8 @@ namespace ControlCatalog.Pages
{
public partial class NavigationPageGesturePage : UserControl
{
+ private bool _initialized;
+
public NavigationPageGesturePage()
{
InitializeComponent();
@@ -16,6 +18,10 @@ namespace ControlCatalog.Pages
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);
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/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/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/src/Avalonia.Base/Animation/PageSlide.cs b/src/Avalonia.Base/Animation/PageSlide.cs
index d75f391c79..001c64f648 100644
--- a/src/Avalonia.Base/Animation/PageSlide.cs
+++ b/src/Avalonia.Base/Animation/PageSlide.cs
@@ -210,6 +210,7 @@ namespace Avalonia.Animation
visual.RenderTransform = null;
}
+
///
/// Gets the common visual parent of the two control.
///
diff --git a/src/Avalonia.Base/Animation/Transitions/Rotate3DTransition.cs b/src/Avalonia.Base/Animation/Transitions/Rotate3DTransition.cs
index 1075198881..41aa205547 100644
--- a/src/Avalonia.Base/Animation/Transitions/Rotate3DTransition.cs
+++ b/src/Avalonia.Base/Animation/Transitions/Rotate3DTransition.cs
@@ -7,7 +7,7 @@ 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;
diff --git a/src/Avalonia.Controls/Automation/Peers/CarouselPageAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/CarouselPageAutomationPeer.cs
new file mode 100644
index 0000000000..0744e43125
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/Peers/CarouselPageAutomationPeer.cs
@@ -0,0 +1,26 @@
+using Avalonia.Controls;
+
+namespace Avalonia.Automation.Peers;
+
+public class CarouselPageAutomationPeer : ControlAutomationPeer
+{
+ public CarouselPageAutomationPeer(CarouselPage owner)
+ : base(owner)
+ {
+ }
+
+ public new CarouselPage Owner => (CarouselPage)base.Owner;
+
+ protected override AutomationControlType GetAutomationControlTypeCore()
+ => AutomationControlType.Pane;
+
+ protected override string? GetNameCore()
+ {
+ var result = base.GetNameCore();
+
+ if (string.IsNullOrEmpty(result))
+ result = Owner.Header?.ToString();
+
+ return result;
+ }
+}
diff --git a/src/Avalonia.Controls/Automation/Peers/DrawerPageAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/DrawerPageAutomationPeer.cs
index 35477ae7d4..39a009967e 100644
--- a/src/Avalonia.Controls/Automation/Peers/DrawerPageAutomationPeer.cs
+++ b/src/Avalonia.Controls/Automation/Peers/DrawerPageAutomationPeer.cs
@@ -1,16 +1,24 @@
+using Avalonia.Automation.Provider;
using Avalonia.Controls;
namespace Avalonia.Automation.Peers;
-public class DrawerPageAutomationPeer : ControlAutomationPeer
+public class DrawerPageAutomationPeer : ControlAutomationPeer,
+ IExpandCollapseProvider
{
public DrawerPageAutomationPeer(DrawerPage owner)
: base(owner)
{
+ owner.PropertyChanged += OwnerPropertyChanged;
}
public new DrawerPage Owner => (DrawerPage)base.Owner;
+ public ExpandCollapseState ExpandCollapseState => ToState(Owner.IsOpen);
+ public bool ShowsMenu => false;
+ public void Collapse() => Owner.IsOpen = false;
+ public void Expand() => Owner.IsOpen = true;
+
protected override AutomationControlType GetAutomationControlTypeCore()
=> AutomationControlType.Pane;
@@ -23,4 +31,20 @@ public class DrawerPageAutomationPeer : ControlAutomationPeer
return result;
}
+
+ private void OwnerPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
+ {
+ if (e.Property == DrawerPage.IsOpenProperty)
+ {
+ RaisePropertyChangedEvent(
+ ExpandCollapsePatternIdentifiers.ExpandCollapseStateProperty,
+ ToState(e.GetOldValue()),
+ ToState(e.GetNewValue()));
+ }
+ }
+
+ private static ExpandCollapseState ToState(bool value)
+ {
+ return value ? ExpandCollapseState.Expanded : ExpandCollapseState.Collapsed;
+ }
}
diff --git a/src/Avalonia.Controls/Automation/Peers/TabbedPageAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/TabbedPageAutomationPeer.cs
index 44c0b35af6..e679d7109b 100644
--- a/src/Avalonia.Controls/Automation/Peers/TabbedPageAutomationPeer.cs
+++ b/src/Avalonia.Controls/Automation/Peers/TabbedPageAutomationPeer.cs
@@ -21,6 +21,22 @@ public class TabbedPageAutomationPeer : ControlAutomationPeer
if (string.IsNullOrEmpty(result))
result = Owner.Header?.ToString();
+ var index = Owner.SelectedIndex;
+ var tabCount = GetTabCount();
+
+ if (index >= 0 && tabCount > 0)
+ {
+ var header = Owner.SelectedPage?.Header?.ToString();
+ var position = $"Tab {index + 1} of {tabCount}";
+ var tabName = string.IsNullOrEmpty(header) ? position : $"{position}: {header}";
+ return string.IsNullOrEmpty(result) ? tabName : $"{result} {tabName}";
+ }
+
return result;
}
+
+ private int GetTabCount()
+ {
+ return Owner.GetTabCount();
+ }
}
diff --git a/src/Avalonia.Controls/Carousel.cs b/src/Avalonia.Controls/Carousel.cs
index bf22671462..6e8ce8a287 100644
--- a/src/Avalonia.Controls/Carousel.cs
+++ b/src/Avalonia.Controls/Carousel.cs
@@ -199,6 +199,9 @@ namespace Avalonia.Controls
{
base.OnApplyTemplate(e);
_scroller = e.NameScope.Find("PART_ScrollViewer");
+
+ if (ItemsPanelRoot is VirtualizingCarouselPanel panel)
+ panel.RefreshGestureRecognizer();
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
diff --git a/src/Avalonia.Controls/CommandBar/CommandBar.cs b/src/Avalonia.Controls/CommandBar/CommandBar.cs
index 2f068cbb9f..392ffb7ddc 100644
--- a/src/Avalonia.Controls/CommandBar/CommandBar.cs
+++ b/src/Avalonia.Controls/CommandBar/CommandBar.cs
@@ -142,11 +142,9 @@ namespace Avalonia.Controls
OverflowItems = new ReadOnlyObservableCollection(_overflowItems);
var primaryCommands = new ObservableCollection();
- primaryCommands.CollectionChanged += OnPrimaryCommandsChanged;
SetCurrentValue(PrimaryCommandsProperty, (IList)primaryCommands);
var secondaryCommands = new ObservableCollection();
- secondaryCommands.CollectionChanged += OnSecondaryCommandsChanged;
SetCurrentValue(SecondaryCommandsProperty, (IList)secondaryCommands);
SizeChanged += CommandBar_SizeChanged;
diff --git a/src/Avalonia.Controls/Page/CarouselPage.cs b/src/Avalonia.Controls/Page/CarouselPage.cs
new file mode 100644
index 0000000000..1b640fd528
--- /dev/null
+++ b/src/Avalonia.Controls/Page/CarouselPage.cs
@@ -0,0 +1,418 @@
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+using Avalonia.Animation;
+using Avalonia.Automation;
+using Avalonia.Automation.Peers;
+using Avalonia.Collections;
+using Avalonia.Controls.Metadata;
+using Avalonia.Controls.Presenters;
+using Avalonia.Controls.Primitives;
+using Avalonia.Controls.Templates;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Media;
+using Avalonia.Threading;
+
+namespace Avalonia.Controls
+{
+ ///
+ /// A page that displays its child pages in a horizontally scrollable carousel,
+ /// with optional animated page transitions.
+ ///
+ [TemplatePart("PART_Carousel", typeof(Carousel))]
+ public class CarouselPage : SelectingMultiPage
+ {
+ private Carousel? _carousel;
+
+ private static readonly FuncTemplate DefaultPanel =
+ new FuncTemplate(() => new VirtualizingCarouselPanel());
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty> ItemsPanelProperty =
+ ItemsControl.ItemsPanelProperty.AddOwner();
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty PageTransitionProperty =
+ AvaloniaProperty.Register(nameof(PageTransition));
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty IsGestureEnabledProperty =
+ AvaloniaProperty.Register(nameof(IsGestureEnabled), true);
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty IsKeyboardNavigationEnabledProperty =
+ AvaloniaProperty.Register(nameof(IsKeyboardNavigationEnabled), true);
+
+ static CarouselPage()
+ {
+ ItemsPanelProperty.OverrideDefaultValue(DefaultPanel);
+ FocusableProperty.OverrideDefaultValue(true);
+ }
+
+ public CarouselPage()
+ {
+ SetCurrentValue(PagesProperty, new AvaloniaList());
+ }
+
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ AddHandler(PointerWheelChangedEvent, OnPointerWheelChanged, RoutingStrategies.Bubble);
+ }
+
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnDetachedFromVisualTree(e);
+ RemoveHandler(PointerWheelChangedEvent, OnPointerWheelChanged);
+ }
+
+ ///
+ /// Gets or sets the items panel template used to arrange page items.
+ ///
+ public ITemplate ItemsPanel
+ {
+ get => GetValue(ItemsPanelProperty);
+ set => SetValue(ItemsPanelProperty, value);
+ }
+
+ ///
+ /// Gets or sets the animated page transition used when the selected page changes.
+ ///
+ public IPageTransition? PageTransition
+ {
+ get => GetValue(PageTransitionProperty);
+ set => SetValue(PageTransitionProperty, value);
+ }
+
+ ///
+ /// Gets or sets whether swipe and scroll gestures can be used to navigate between pages.
+ ///
+ public bool IsGestureEnabled
+ {
+ get => GetValue(IsGestureEnabledProperty);
+ set => SetValue(IsGestureEnabledProperty, value);
+ }
+
+ ///
+ /// Gets or sets whether keyboard shortcuts (arrow keys, Home/End) can be used to navigate between pages.
+ ///
+ public bool IsKeyboardNavigationEnabled
+ {
+ get => GetValue(IsKeyboardNavigationEnabledProperty);
+ set => SetValue(IsKeyboardNavigationEnabledProperty, value);
+ }
+
+ protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
+ {
+ base.OnApplyTemplate(e);
+
+ if (_carousel != null)
+ {
+ _carousel.SelectionChanged -= OnCarouselSelectionChanged;
+ _carousel.ContainerPrepared -= OnCarouselContainerPrepared;
+ }
+
+ _carousel = e.NameScope.Find("PART_Carousel");
+
+ if (_carousel != null)
+ {
+ _carousel.SelectionChanged += OnCarouselSelectionChanged;
+ _carousel.ContainerPrepared += OnCarouselContainerPrepared;
+ _carousel.PageTransition = PageTransition;
+ _carousel.ItemsPanel = ItemsPanel;
+ _carousel.ItemTemplate = PageTemplate;
+ _carousel.IsSwipeEnabled = IsGestureEnabled;
+ _carousel.ItemsSource = (IEnumerable?)ItemsSource ?? Pages;
+
+ if (SelectedIndex >= 0)
+ {
+ _carousel.SelectedIndex = SelectedIndex;
+ }
+
+ UpdateActivePage();
+ }
+ }
+
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+
+ if (change.Property == PageTransitionProperty && _carousel != null)
+ _carousel.PageTransition = change.GetNewValue();
+ else if (change.Property == ItemsPanelProperty && _carousel != null)
+ _carousel.ItemsPanel = change.GetNewValue>();
+ else if (change.Property == PageTemplateProperty && _carousel != null)
+ {
+ _carousel.ItemTemplate = change.GetNewValue();
+ if (ItemsSource != null)
+ UpdateActivePage();
+ }
+ else if (change.Property == IsGestureEnabledProperty && _carousel != null)
+ _carousel.IsSwipeEnabled = change.GetNewValue();
+ else if (change.Property == ItemsSourceProperty && _carousel != null)
+ {
+ _carousel.ItemsSource = change.GetNewValue() ?? Pages;
+ UpdateActivePage();
+ }
+ else if (change.Property == PagesProperty && ItemsSource == null && _carousel != null)
+ _carousel.ItemsSource = change.GetNewValue?>();
+ }
+
+ protected override void UpdateActivePage(NavigationType navigationType)
+ {
+ if (_carousel != null)
+ {
+ var index = _carousel.SelectedIndex;
+ if (index >= 0)
+ {
+ UpdateSelection(index, navigationType);
+ }
+ else if (GetPageCount() > 0)
+ {
+ ApplySelectedIndex(0);
+ }
+ }
+ else if (GetPageCount() > 0)
+ {
+ var index = CoercePreTemplateSelectedIndex(SelectedIndex);
+ if (ItemsSource != null)
+ {
+ StoreSelectedIndex(index);
+ }
+ else
+ {
+ var page = ResolvePageAtIndex(index);
+
+ if (index != SelectedIndex || !ReferenceEquals(SelectedPage, page))
+ CommitSelection(index, page, navigationType);
+ }
+ }
+ }
+
+ protected override void ApplySelectedIndex(int index)
+ {
+ if (_carousel != null)
+ {
+ _carousel.SelectedIndex = index;
+ }
+ else
+ {
+ var pageCount = GetPageCount();
+
+ if (pageCount > 0)
+ {
+ var coercedIndex = CoercePreTemplateSelectedIndex(index);
+ if (ItemsSource != null)
+ {
+ StoreSelectedIndex(coercedIndex);
+ }
+ else
+ {
+ var newPage = ResolvePageAtIndex(coercedIndex);
+ CommitSelection(coercedIndex, newPage);
+ }
+ }
+ else
+ {
+ // Preserve preselection until pages exist.
+ StoreSelectedIndex(index);
+ }
+ }
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+ if (e.Handled || !IsKeyboardNavigationEnabled)
+ return;
+
+ bool isRtl = IsRightToLeft;
+ bool next = isRtl ? e.Key == Key.Left : e.Key == Key.Right;
+ bool prev = isRtl ? e.Key == Key.Right : e.Key == Key.Left;
+
+ var pageCount = GetPageCount();
+ if (next || e.Key == Key.Down)
+ {
+ if (SelectedIndex < pageCount - 1)
+ {
+ ApplySelectedIndex(SelectedIndex + 1);
+ e.Handled = true;
+ }
+ }
+ else if (prev || e.Key == Key.Up)
+ {
+ if (SelectedIndex > 0)
+ {
+ ApplySelectedIndex(SelectedIndex - 1);
+ e.Handled = true;
+ }
+ }
+ else if (e.Key == Key.Home)
+ {
+ if (pageCount > 0 && SelectedIndex != 0)
+ {
+ ApplySelectedIndex(0);
+ e.Handled = true;
+ }
+ }
+ else if (e.Key == Key.End)
+ {
+ if (pageCount > 0 && SelectedIndex != pageCount - 1)
+ {
+ ApplySelectedIndex(pageCount - 1);
+ e.Handled = true;
+ }
+ }
+ }
+
+ private void OnCarouselSelectionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ if (_carousel == null)
+ return;
+
+ var newIndex = _carousel.SelectedIndex;
+ var newPage = ResolveDisplayedPageAtIndex(newIndex);
+ if (newIndex == SelectedIndex && ReferenceEquals(newPage, SelectedPage))
+ return;
+
+ UpdateSelection(newIndex, NavigationType.Replace);
+ }
+
+ private void OnCarouselContainerPrepared(object? sender, ContainerPreparedEventArgs e)
+ {
+ if (_carousel == null || e.Index != _carousel.SelectedIndex)
+ return;
+
+ Dispatcher.UIThread.Post(
+ () =>
+ {
+ if (_carousel != null && _carousel.SelectedIndex == e.Index)
+ UpdateSelection(e.Index, NavigationType.Replace);
+ },
+ DispatcherPriority.Loaded);
+ }
+
+ private void OnPointerWheelChanged(object? sender, PointerWheelEventArgs e)
+ {
+ if (!IsGestureEnabled)
+ return;
+
+ bool isRtl = IsRightToLeft;
+ var pageCount = GetPageCount();
+ bool goNext = e.Delta.Y < 0 || (isRtl ? e.Delta.X < 0 : e.Delta.X > 0);
+ bool goPrev = e.Delta.Y > 0 || (isRtl ? e.Delta.X > 0 : e.Delta.X < 0);
+
+ if (goNext && SelectedIndex < pageCount - 1)
+ {
+ ApplySelectedIndex(SelectedIndex + 1);
+ e.Handled = true;
+ }
+ else if (goPrev && SelectedIndex > 0)
+ {
+ ApplySelectedIndex(SelectedIndex - 1);
+ e.Handled = true;
+ }
+ }
+
+ protected override AutomationPeer OnCreateAutomationPeer() => new CarouselPageAutomationPeer(this);
+
+ private void UpdateAccessibilityName(int index, int pageCount, Page? page)
+ {
+ var header = page?.Header?.ToString();
+ var position = pageCount > 0 ? $"Page {index + 1} of {pageCount}" : string.Empty;
+ var name = string.IsNullOrEmpty(header) ? position : string.IsNullOrEmpty(position) ? header : $"{position}: {header}";
+ // CarouselPageAutomationPeer.GetNameCore reads this via base.GetNameCore(), which returns
+ // AutomationProperties.Name when set. Position and header are encoded here rather than in the
+ // peer so that the name stays current without requiring the peer to re-query the carousel state.
+ AutomationProperties.SetName(this, name);
+ }
+
+ private bool IsRightToLeft => FlowDirection == Media.FlowDirection.RightToLeft;
+
+ private int GetPageCount()
+ {
+ if (_carousel != null)
+ return _carousel.ItemCount;
+
+ var source = (IEnumerable?)ItemsSource ?? Pages;
+
+ if (source is ICollection nonGenericCol)
+ return nonGenericCol.Count;
+ if (source is ICollection col)
+ return col.Count;
+ if (source != null)
+ {
+ int count = 0;
+ foreach (var _ in source)
+ count++;
+ return count;
+ }
+ return 0;
+ }
+
+ private int CoercePreTemplateSelectedIndex(int index)
+ {
+ var pageCount = GetPageCount();
+ if (pageCount <= 0)
+ return index;
+
+ return (uint)index < (uint)pageCount ? index : 0;
+ }
+
+ private void UpdateSelection(int index, NavigationType navigationType)
+ {
+ var page = ResolveDisplayedPageAtIndex(index);
+ var pageCount = GetPageCount();
+
+ if (page == null && ItemsSource != null)
+ {
+ StoreSelectedIndex(index);
+ UpdateAccessibilityName(index, pageCount, null);
+ return;
+ }
+
+ CommitSelection(index, page, navigationType);
+ UpdateAccessibilityName(index, pageCount, page);
+ }
+
+ private Page? ResolveDisplayedPageAtIndex(int index)
+ {
+ if (index < 0)
+ return null;
+
+ if (_carousel?.ContainerFromIndex(index) is { } container)
+ return TryGetPageFromContainer(container);
+
+ return ItemsSource == null ? ResolvePageAtIndex(index) : null;
+ }
+
+ private static Page? TryGetPageFromContainer(Control container)
+ {
+ if (container is Page page)
+ return page;
+
+ if (container is ContentPresenter presenter)
+ {
+ if (presenter.Child == null)
+ presenter.UpdateChild();
+
+ return presenter.Child as Page;
+ }
+
+ if (container is ContentControl contentControl)
+ return contentControl.Content as Page;
+
+ return null;
+ }
+
+ }
+}
diff --git a/src/Avalonia.Controls/Page/ContentPage.cs b/src/Avalonia.Controls/Page/ContentPage.cs
index 62f6c82dad..b7523b4688 100644
--- a/src/Avalonia.Controls/Page/ContentPage.cs
+++ b/src/Avalonia.Controls/Page/ContentPage.cs
@@ -1,3 +1,4 @@
+using System;
using Avalonia.Automation.Peers;
using Avalonia.Controls.Metadata;
using Avalonia.Controls.Presenters;
@@ -25,7 +26,14 @@ namespace Avalonia.Controls
/// Defines the property.
///
public static readonly StyledProperty
diff --git a/src/Avalonia.Themes.Simple/Controls/CarouselPage.xaml b/src/Avalonia.Themes.Simple/Controls/CarouselPage.xaml
new file mode 100644
index 0000000000..3e228c34c9
--- /dev/null
+++ b/src/Avalonia.Themes.Simple/Controls/CarouselPage.xaml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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/SimpleControls.xaml b/src/Avalonia.Themes.Simple/Controls/SimpleControls.xaml
index e5ba9163b5..ef6de7ca78 100644
--- a/src/Avalonia.Themes.Simple/Controls/SimpleControls.xaml
+++ b/src/Avalonia.Themes.Simple/Controls/SimpleControls.xaml
@@ -75,6 +75,7 @@
+
diff --git a/src/Avalonia.Themes.Simple/Controls/TabbedPage.xaml b/src/Avalonia.Themes.Simple/Controls/TabbedPage.xaml
index 2ce01f4392..07a1c5b257 100644
--- a/src/Avalonia.Themes.Simple/Controls/TabbedPage.xaml
+++ b/src/Avalonia.Themes.Simple/Controls/TabbedPage.xaml
@@ -250,8 +250,7 @@
Theme="{StaticResource TabbedPageTabControlTheme}"
ItemContainerTheme="{StaticResource TabbedPageTabItemTheme}"
HorizontalAlignment="Stretch"
- VerticalAlignment="Stretch"
- ItemsSource="{TemplateBinding Pages}" />
+ VerticalAlignment="Stretch" />
diff --git a/tests/Avalonia.Benchmarks/Navigation/CarouselPageBenchmark.cs b/tests/Avalonia.Benchmarks/Navigation/CarouselPageBenchmark.cs
new file mode 100644
index 0000000000..8f45b99038
--- /dev/null
+++ b/tests/Avalonia.Benchmarks/Navigation/CarouselPageBenchmark.cs
@@ -0,0 +1,96 @@
+using System;
+using System.Collections;
+using System.Runtime.CompilerServices;
+using Avalonia.Controls;
+using Avalonia.Platform;
+using Avalonia.Styling;
+using Avalonia.Themes.Fluent;
+using Avalonia.Threading;
+using Avalonia.UnitTests;
+using BenchmarkDotNet.Attributes;
+
+namespace Avalonia.Benchmarks.Navigation
+{
+ ///
+ /// Measures the cost of creating a CarouselPage and applying its template
+ /// as the number of pages grows.
+ ///
+ [MemoryDiagnoser]
+ public class CarouselPageCreationBenchmark : IDisposable
+ {
+ private readonly IDisposable _app;
+ private readonly TestRoot _root;
+
+ [Params(2, 5, 10)]
+ public int PageCount { get; set; }
+
+ public CarouselPageCreationBenchmark()
+ {
+ AssetLoader.RegisterResUriParsers();
+ _app = UnitTestApplication.Start(TestServices.StyledWindow.With(
+ theme: () => new Styles { new FluentTheme() }));
+ _root = new TestRoot(true, null) { Renderer = new NullRenderer() };
+ _root.LayoutManager.ExecuteInitialLayoutPass();
+ }
+
+ [Benchmark]
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ public void Create()
+ {
+ var cp = new CarouselPage { PageTransition = null };
+ for (var i = 0; i < PageCount; i++)
+ ((IList)cp.Pages!).Add(new ContentPage { Header = $"Page {i}" });
+ _root.Child = cp;
+ _root.LayoutManager.ExecuteLayoutPass();
+ Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded);
+ }
+
+ public void Dispose() => _app.Dispose();
+ }
+
+ ///
+ /// Measures the cost of navigating between pages on a CarouselPage with five pages (no transition).
+ ///
+ [MemoryDiagnoser]
+ public class CarouselPageNavigationBenchmark : IDisposable
+ {
+ private readonly IDisposable _app;
+ private readonly TestRoot _root;
+ private CarouselPage _carouselPage = null!;
+
+ public CarouselPageNavigationBenchmark()
+ {
+ AssetLoader.RegisterResUriParsers();
+ _app = UnitTestApplication.Start(TestServices.StyledWindow.With(
+ theme: () => new Styles { new FluentTheme() }));
+ _root = new TestRoot(true, null) { Renderer = new NullRenderer() };
+ _root.LayoutManager.ExecuteInitialLayoutPass();
+ }
+
+ [IterationSetup]
+ public void SetupIteration()
+ {
+ _carouselPage = new CarouselPage { PageTransition = null };
+ for (var i = 0; i < 5; i++)
+ ((IList)_carouselPage.Pages!).Add(new ContentPage { Header = $"Page {i}" });
+ _root.Child = _carouselPage;
+ _root.LayoutManager.ExecuteLayoutPass();
+ _carouselPage.SelectedIndex = 0;
+ _root.LayoutManager.ExecuteLayoutPass();
+ Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded);
+ }
+
+ ///
+ /// Navigate forward from page 0 to page 1 (no transition).
+ ///
+ [Benchmark]
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ public void NavigateNext()
+ {
+ _carouselPage.SelectedIndex = 1;
+ _root.LayoutManager.ExecuteLayoutPass();
+ }
+
+ public void Dispose() => _app.Dispose();
+ }
+}
diff --git a/tests/Avalonia.Controls.UnitTests/Automation/PageAutomationPeerTests.cs b/tests/Avalonia.Controls.UnitTests/Automation/PageAutomationPeerTests.cs
index be2ec9f6cd..0276b66086 100644
--- a/tests/Avalonia.Controls.UnitTests/Automation/PageAutomationPeerTests.cs
+++ b/tests/Avalonia.Controls.UnitTests/Automation/PageAutomationPeerTests.cs
@@ -1,4 +1,6 @@
+using Avalonia.Automation;
using Avalonia.Automation.Peers;
+using Avalonia.Automation.Provider;
using Avalonia.UnitTests;
using Xunit;
@@ -222,5 +224,102 @@ public class PageAutomationPeerTests
Assert.True(string.IsNullOrEmpty(peer.GetName()));
}
+
+ [Fact]
+ public void Implements_IExpandCollapseProvider()
+ {
+ var page = new DrawerPage();
+ var peer = (DrawerPageAutomationPeer)ControlAutomationPeer.CreatePeerForElement(page);
+
+ Assert.IsAssignableFrom(peer);
+ }
+
+ [Fact]
+ public void ExpandCollapseState_Collapsed_When_Closed()
+ {
+ var page = new DrawerPage { IsOpen = false };
+ var peer = (DrawerPageAutomationPeer)ControlAutomationPeer.CreatePeerForElement(page);
+
+ Assert.Equal(ExpandCollapseState.Collapsed, peer.ExpandCollapseState);
+ }
+
+ [Fact]
+ public void ExpandCollapseState_Expanded_When_Open()
+ {
+ var page = new DrawerPage { IsOpen = true };
+ var peer = (DrawerPageAutomationPeer)ControlAutomationPeer.CreatePeerForElement(page);
+
+ Assert.Equal(ExpandCollapseState.Expanded, peer.ExpandCollapseState);
+ }
+
+ [Fact]
+ public void Expand_Sets_IsOpen_True()
+ {
+ var page = new DrawerPage { IsOpen = false };
+ var peer = (DrawerPageAutomationPeer)ControlAutomationPeer.CreatePeerForElement(page);
+
+ peer.Expand();
+
+ Assert.True(page.IsOpen);
+ }
+
+ [Fact]
+ public void Collapse_Sets_IsOpen_False()
+ {
+ var page = new DrawerPage { IsOpen = true };
+ var peer = (DrawerPageAutomationPeer)ControlAutomationPeer.CreatePeerForElement(page);
+
+ peer.Collapse();
+
+ Assert.False(page.IsOpen);
+ }
+ }
+
+ public class CarouselPagePeer : ScopedTestBase
+ {
+ [Fact]
+ public void Creates_CarouselPageAutomationPeer()
+ {
+ var page = new CarouselPage();
+ var peer = ControlAutomationPeer.CreatePeerForElement(page);
+
+ Assert.IsType(peer);
+ }
+
+ [Fact]
+ public void ControlType_Is_Pane()
+ {
+ var page = new CarouselPage();
+ var peer = (CarouselPageAutomationPeer)ControlAutomationPeer.CreatePeerForElement(page);
+
+ Assert.Equal(AutomationControlType.Pane, peer.GetAutomationControlType());
+ }
+
+ [Fact]
+ public void Name_Returns_String_Header()
+ {
+ var page = new CarouselPage { Header = "Photos" };
+ var peer = (CarouselPageAutomationPeer)ControlAutomationPeer.CreatePeerForElement(page);
+
+ Assert.Equal("Photos", peer.GetName());
+ }
+
+ [Fact]
+ public void Name_Returns_ToString_For_Non_String_Header()
+ {
+ var page = new CarouselPage { Header = 7 };
+ var peer = (CarouselPageAutomationPeer)ControlAutomationPeer.CreatePeerForElement(page);
+
+ Assert.Equal("7", peer.GetName());
+ }
+
+ [Fact]
+ public void Name_Is_Empty_When_No_Header()
+ {
+ var page = new CarouselPage();
+ var peer = (CarouselPageAutomationPeer)ControlAutomationPeer.CreatePeerForElement(page);
+
+ Assert.True(string.IsNullOrEmpty(peer.GetName()));
+ }
}
}
diff --git a/tests/Avalonia.Controls.UnitTests/CarouselPageTests.cs b/tests/Avalonia.Controls.UnitTests/CarouselPageTests.cs
new file mode 100644
index 0000000000..d4ba06d6e2
--- /dev/null
+++ b/tests/Avalonia.Controls.UnitTests/CarouselPageTests.cs
@@ -0,0 +1,1389 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Avalonia.Animation;
+using Avalonia.Collections;
+using Avalonia.Controls.Presenters;
+using Avalonia.Controls.Primitives;
+using Avalonia.Controls.Templates;
+using Avalonia.Input;
+using Avalonia.Input.GestureRecognizers;
+using Avalonia.Threading;
+using Avalonia.Layout;
+using Avalonia.LogicalTree;
+using Avalonia.Media;
+using Avalonia.UnitTests;
+using Avalonia.VisualTree;
+using Xunit;
+
+namespace Avalonia.Controls.UnitTests;
+
+public class CarouselPageTests
+{
+ public class PropertyDefaults : ScopedTestBase
+ {
+ [Fact]
+ public void SelectedIndex_DefaultIsMinusOne()
+ {
+ var cp = new CarouselPage();
+ Assert.Equal(-1, cp.SelectedIndex);
+ }
+
+ [Fact]
+ public void SelectedPage_DefaultIsNull()
+ {
+ var cp = new CarouselPage();
+ Assert.Null(cp.SelectedPage);
+ }
+
+ [Fact]
+ public void CurrentPage_DefaultIsNull()
+ {
+ var cp = new CarouselPage();
+ Assert.Null(cp.CurrentPage);
+ }
+
+ [Fact]
+ public void PageTransition_DefaultIsNull()
+ {
+ var cp = new CarouselPage();
+ Assert.Null(cp.PageTransition);
+ }
+
+ [Fact]
+ public void IsGestureEnabled_DefaultIsTrue()
+ {
+ var cp = new CarouselPage();
+ Assert.True(cp.IsGestureEnabled);
+ }
+
+ [Fact]
+ public void IsKeyboardNavigationEnabled_DefaultIsTrue()
+ {
+ var cp = new CarouselPage();
+ Assert.True(cp.IsKeyboardNavigationEnabled);
+ }
+
+ }
+
+ public class PropertyRoundTrips : ScopedTestBase
+ {
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void IsGestureEnabled_RoundTrips(bool value)
+ {
+ var cp = new CarouselPage { IsGestureEnabled = value };
+ Assert.Equal(value, cp.IsGestureEnabled);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void IsKeyboardNavigationEnabled_RoundTrips(bool value)
+ {
+ var cp = new CarouselPage { IsKeyboardNavigationEnabled = value };
+ Assert.Equal(value, cp.IsKeyboardNavigationEnabled);
+ }
+
+ [Fact]
+ public void PageTransition_RoundTrip()
+ {
+ var transition = new TestPageTransition();
+ var cp = new CarouselPage { PageTransition = transition };
+ Assert.Same(transition, cp.PageTransition);
+ }
+
+ [Fact]
+ public void PageTransition_CanBeSetToNull()
+ {
+ var cp = new CarouselPage { PageTransition = new TestPageTransition() };
+ cp.PageTransition = null;
+ Assert.Null(cp.PageTransition);
+ }
+
+ [Fact]
+ public void ItemsPanel_RoundTrip()
+ {
+ var template = new FuncTemplate(() => new StackPanel());
+ var cp = new CarouselPage { ItemsPanel = template };
+ Assert.Same(template, cp.ItemsPanel);
+ }
+
+ [Fact]
+ public void PageTemplate_RoundTrip()
+ {
+ var template = new FuncDataTemplate((_, _) => new ContentControl());
+ var cp = new CarouselPage { PageTemplate = template };
+ Assert.Same(template, cp.PageTemplate);
+ }
+
+ [Fact]
+ public void PageTemplate_CanBeSetToNull()
+ {
+ var cp = new CarouselPage { PageTemplate = null };
+ Assert.Null(cp.PageTemplate);
+ }
+ }
+
+ public class SelectionBehavior : ScopedTestBase
+ {
+ [Fact]
+ public void SelectedIndex_SetUpdatesSelectedPageAndCurrentPage()
+ {
+ var cp = new CarouselPage();
+ var page1 = new ContentPage { Header = "A" };
+ var page2 = new ContentPage { Header = "B" };
+ ((AvaloniaList)cp.Pages!).AddRange(new[] { page1, page2 });
+ cp.SelectedIndex = 0;
+
+ cp.SelectedIndex = 1;
+
+ Assert.Same(page2, cp.SelectedPage);
+ Assert.Same(page2, cp.CurrentPage);
+ }
+
+ [Fact]
+ public void SelectedIndex_AutoSelectsFirst_WhenPagesSet()
+ {
+ var cp = new CarouselPage();
+ var page1 = new ContentPage { Header = "A" };
+ var page2 = new ContentPage { Header = "B" };
+ ((AvaloniaList)cp.Pages!).AddRange(new[] { page1, page2 });
+
+ Assert.Equal(0, cp.SelectedIndex);
+ Assert.Same(page1, cp.SelectedPage);
+ }
+
+ [Fact]
+ public void UpdateActivePage_SelectsFirst_WhenFirstPageAddedToEmptyCollection()
+ {
+ var cp = new CarouselPage();
+ Assert.Equal(-1, cp.SelectedIndex);
+
+ var page1 = new ContentPage { Header = "A" };
+ ((AvaloniaList)cp.Pages!).Add(page1);
+
+ Assert.Equal(0, cp.SelectedIndex);
+ Assert.Same(page1, cp.SelectedPage);
+ }
+
+ [Fact]
+ public void UpdateActivePage_DoesNotOverrideExistingSelection()
+ {
+ var cp = new CarouselPage();
+ var page1 = new ContentPage { Header = "A" };
+ var page2 = new ContentPage { Header = "B" };
+ ((AvaloniaList)cp.Pages!).AddRange(new[] { page1, page2 });
+ cp.SelectedIndex = 1;
+
+ ((AvaloniaList)cp.Pages!).Add(new ContentPage { Header = "C" });
+
+ Assert.Equal(1, cp.SelectedIndex);
+ }
+
+ [Fact]
+ public void SelectedPage_SameReferenceAsCurrentPage()
+ {
+ var cp = new CarouselPage();
+ var page1 = new ContentPage { Header = "A" };
+ var page2 = new ContentPage { Header = "B" };
+ ((AvaloniaList)cp.Pages!).AddRange(new[] { page1, page2 });
+ cp.SelectedIndex = 1;
+
+ Assert.Same(cp.SelectedPage, cp.CurrentPage);
+ }
+
+ [Fact]
+ public void SelectedIndex_InvalidWithPages_CoercesToFirstPage()
+ {
+ var cp = new CarouselPage();
+ var page1 = new ContentPage { Header = "A" };
+ var page2 = new ContentPage { Header = "B" };
+ ((AvaloniaList)cp.Pages!).AddRange(new[] { page1, page2 });
+
+ cp.SelectedIndex = 999;
+
+ Assert.Equal(0, cp.SelectedIndex);
+ Assert.Same(page1, cp.SelectedPage);
+ Assert.Same(page1, cp.CurrentPage);
+ }
+
+ [Fact]
+ public void SelectedIndex_SetBeforePages_IsStored()
+ {
+ var cp = new CarouselPage();
+
+ cp.SelectedIndex = 2;
+
+ Assert.Equal(2, cp.SelectedIndex);
+ Assert.Null(cp.SelectedPage);
+ Assert.Null(cp.CurrentPage);
+ }
+
+ [Fact]
+ public void SelectedIndex_SetBeforePages_IsAppliedWhenPagesSet()
+ {
+ var cp = new CarouselPage
+ {
+ SelectedIndex = 2
+ };
+
+ var page1 = new ContentPage { Header = "A" };
+ var page2 = new ContentPage { Header = "B" };
+ var page3 = new ContentPage { Header = "C" };
+
+ cp.Pages = new AvaloniaList { page1, page2, page3 };
+
+ Assert.Equal(2, cp.SelectedIndex);
+ Assert.Same(page3, cp.SelectedPage);
+ Assert.Same(page3, cp.CurrentPage);
+ }
+
+ [Fact]
+ public void Pages_SetNull_SelectedIndex_RetainsLastValue()
+ {
+ var cp = new CarouselPage();
+ ((AvaloniaList)cp.Pages!).Add(new ContentPage { Header = "A" });
+ Assert.Equal(0, cp.SelectedIndex);
+
+ cp.Pages = null;
+
+ Assert.Equal(0, cp.SelectedIndex);
+ }
+ }
+
+ public class SelectionChangedEvent : ScopedTestBase
+ {
+ [Fact]
+ public void SelectionChanged_FiresWhenSelectionChanges()
+ {
+ var cp = new CarouselPage();
+ var page1 = new ContentPage { Header = "A" };
+ var page2 = new ContentPage { Header = "B" };
+ ((AvaloniaList)cp.Pages!).AddRange(new[] { page1, page2 });
+ cp.SelectedIndex = 0;
+
+ PageSelectionChangedEventArgs? received = null;
+ cp.SelectionChanged += (_, e) => received = e;
+
+ cp.SelectedIndex = 1;
+
+ Assert.NotNull(received);
+ Assert.Same(page1, received!.PreviousPage);
+ Assert.Same(page2, received.CurrentPage);
+ }
+
+ [Fact]
+ public void SelectionChanged_NotFiredWhenSamePageSelected()
+ {
+ var cp = new CarouselPage();
+ var page = new ContentPage { Header = "A" };
+ ((AvaloniaList)cp.Pages!).Add(page);
+ cp.SelectedIndex = 0;
+
+ int count = 0;
+ cp.SelectionChanged += (_, _) => count++;
+
+ cp.SelectedIndex = 0;
+
+ Assert.Equal(0, count);
+ }
+
+ [Fact]
+ public void SelectionChanged_TracksSequentialSelections()
+ {
+ var cp = new CarouselPage();
+ var pages = new[]
+ {
+ new ContentPage { Header = "A" },
+ new ContentPage { Header = "B" },
+ new ContentPage { Header = "C" },
+ };
+ ((AvaloniaList)cp.Pages!).AddRange(pages);
+ cp.SelectedIndex = 0;
+
+ var events = new List<(Page? prev, Page? curr)>();
+ cp.SelectionChanged += (_, e) => events.Add((e.PreviousPage, e.CurrentPage));
+
+ cp.SelectedIndex = 1;
+ cp.SelectedIndex = 2;
+ cp.SelectedIndex = 0;
+
+ Assert.Equal(3, events.Count);
+ Assert.Same(pages[0], events[0].prev);
+ Assert.Same(pages[1], events[0].curr);
+ Assert.Same(pages[1], events[1].prev);
+ Assert.Same(pages[2], events[1].curr);
+ Assert.Same(pages[2], events[2].prev);
+ Assert.Same(pages[0], events[2].curr);
+ }
+
+ [Fact]
+ public void SelectionChanged_PreviousPage_IsNull_OnFirstAutoSelection()
+ {
+ var cp = new CarouselPage();
+ var page1 = new ContentPage { Header = "A" };
+
+ PageSelectionChangedEventArgs? received = null;
+ cp.SelectionChanged += (_, e) => received = e;
+
+ ((AvaloniaList)cp.Pages!).Add(page1);
+
+ Assert.NotNull(received);
+ Assert.Null(received!.PreviousPage);
+ Assert.Same(page1, received.CurrentPage);
+ }
+ }
+
+ public class CurrentPageChangedEvent : ScopedTestBase
+ {
+ [Fact]
+ public void CurrentPageChanged_FiresOnSelectionChange()
+ {
+ var cp = new CarouselPage();
+ var page1 = new ContentPage { Header = "A" };
+ var page2 = new ContentPage { Header = "B" };
+ ((AvaloniaList)cp.Pages!).AddRange(new[] { page1, page2 });
+ cp.SelectedIndex = 0;
+
+ int count = 0;
+ cp.CurrentPageChanged += (_, _) => count++;
+
+ cp.SelectedIndex = 1;
+
+ Assert.Equal(1, count);
+ }
+
+ [Fact]
+ public void CurrentPageChanged_NotFiredWhenSamePageSelected()
+ {
+ var cp = new CarouselPage();
+ var page = new ContentPage { Header = "A" };
+ ((AvaloniaList)cp.Pages!).Add(page);
+ cp.SelectedIndex = 0;
+
+ int count = 0;
+ cp.CurrentPageChanged += (_, _) => count++;
+
+ cp.SelectedIndex = 0;
+
+ Assert.Equal(0, count);
+ }
+ }
+
+ public class PagesChangedEvent : ScopedTestBase
+ {
+ [Fact]
+ public void PagesChanged_FiresOnAdd()
+ {
+ var cp = new CarouselPage();
+ NotifyCollectionChangedEventArgs? received = null;
+ cp.PagesChanged += (_, e) => received = e;
+
+ ((AvaloniaList)cp.Pages!).Add(new ContentPage());
+
+ Assert.NotNull(received);
+ Assert.Equal(NotifyCollectionChangedAction.Add, received!.Action);
+ }
+
+ [Fact]
+ public void PagesChanged_FiresOnRemove()
+ {
+ var cp = new CarouselPage();
+ var page = new ContentPage();
+ ((AvaloniaList)cp.Pages!).Add(page);
+
+ NotifyCollectionChangedEventArgs? received = null;
+ cp.PagesChanged += (_, e) => received = e;
+
+ ((AvaloniaList)cp.Pages!).Remove(page);
+
+ Assert.NotNull(received);
+ Assert.Equal(NotifyCollectionChangedAction.Remove, received!.Action);
+ }
+
+ [Fact]
+ public void PagesChanged_NotFiredAfterPagesCollectionReplaced()
+ {
+ var cp = new CarouselPage();
+ var oldPages = (AvaloniaList)cp.Pages!;
+ cp.Pages = new AvaloniaList();
+
+ cp.PagesChanged += (_, _) => throw new InvalidOperationException("Should not fire for old collection");
+
+ oldPages.Add(new ContentPage());
+ }
+ }
+
+ public class PageLifecycleEvents : ScopedTestBase
+ {
+ [Fact]
+ public void SelectionChange_FiresNavigatedFrom_OnPreviousPage()
+ {
+ var cp = new CarouselPage();
+ var page1 = new ContentPage { Header = "A" };
+ var page2 = new ContentPage { Header = "B" };
+ ((AvaloniaList)cp.Pages!).AddRange(new[] { page1, page2 });
+ cp.SelectedIndex = 0;
+
+ NavigatedFromEventArgs? args = null;
+ page1.NavigatedFrom += (_, e) => args = e;
+
+ cp.SelectedIndex = 1;
+
+ Assert.NotNull(args);
+ Assert.Same(page2, args!.DestinationPage);
+ }
+
+ [Fact]
+ public void SelectionChange_FiresNavigatedTo_OnNewPage()
+ {
+ var cp = new CarouselPage();
+ var page1 = new ContentPage { Header = "A" };
+ var page2 = new ContentPage { Header = "B" };
+ ((AvaloniaList)cp.Pages!).AddRange(new[] { page1, page2 });
+ cp.SelectedIndex = 0;
+
+ NavigatedToEventArgs? args = null;
+ page2.NavigatedTo += (_, e) => args = e;
+
+ cp.SelectedIndex = 1;
+
+ Assert.NotNull(args);
+ Assert.Same(page1, args!.PreviousPage);
+ }
+
+ [Fact]
+ public void SelectionChange_LifecycleOrder_NavigatedFromThenNavigatedTo()
+ {
+ var cp = new CarouselPage();
+ var page1 = new ContentPage { Header = "A" };
+ var page2 = new ContentPage { Header = "B" };
+ ((AvaloniaList)cp.Pages!).AddRange(new[] { page1, page2 });
+ cp.SelectedIndex = 0;
+
+ var order = new List();
+ page1.NavigatedFrom += (_, _) => order.Add("NavigatedFrom");
+ page2.NavigatedTo += (_, _) => order.Add("NavigatedTo");
+
+ cp.SelectedIndex = 1;
+
+ Assert.Equal(new[] { "NavigatedFrom", "NavigatedTo" }, order);
+ }
+
+ [Fact]
+ public void SelectionChange_SamePage_NoLifecycleEvents()
+ {
+ var cp = new CarouselPage();
+ var page = new ContentPage { Header = "A" };
+ ((AvaloniaList)cp.Pages!).Add(page);
+ cp.SelectedIndex = 0;
+
+ var events = new List();
+ page.NavigatedTo += (_, _) => events.Add("NavigatedTo");
+ page.NavigatedFrom += (_, _) => events.Add("NavigatedFrom");
+
+ cp.SelectedIndex = 0;
+
+ Assert.Empty(events);
+ }
+
+ [Fact]
+ public void NavigatedFrom_NavigationType_IsReplace()
+ {
+ var cp = new CarouselPage();
+ var page1 = new ContentPage { Header = "A" };
+ var page2 = new ContentPage { Header = "B" };
+ ((AvaloniaList)cp.Pages!).AddRange(new[] { page1, page2 });
+
+ NavigatedFromEventArgs? args = null;
+ page1.NavigatedFrom += (_, e) => args = e;
+
+ cp.SelectedIndex = 1;
+
+ Assert.NotNull(args);
+ Assert.Equal(NavigationType.Replace, args!.NavigationType);
+ }
+
+ [Fact]
+ public void NavigatedTo_NavigationType_IsReplace()
+ {
+ var cp = new CarouselPage();
+ var page1 = new ContentPage { Header = "A" };
+ var page2 = new ContentPage { Header = "B" };
+ ((AvaloniaList)cp.Pages!).AddRange(new[] { page1, page2 });
+
+ NavigatedToEventArgs? args = null;
+ page2.NavigatedTo += (_, e) => args = e;
+
+ cp.SelectedIndex = 1;
+
+ Assert.NotNull(args);
+ Assert.Equal(NavigationType.Replace, args!.NavigationType);
+ }
+
+ [Fact]
+ public void NavigatedTo_FirstAutoSelection_NavigationType_IsReplace()
+ {
+ var cp = new CarouselPage();
+ var page = new ContentPage { Header = "A" };
+
+ NavigatedToEventArgs? args = null;
+ page.NavigatedTo += (_, e) => args = e;
+
+ ((AvaloniaList)cp.Pages!).Add(page);
+
+ Assert.NotNull(args);
+ Assert.Equal(NavigationType.Replace, args!.NavigationType);
+ }
+ }
+
+ public class LogicalChildrenTests : ScopedTestBase
+ {
+ [Fact]
+ public void Pages_Added_BecomeLogicalChildren()
+ {
+ var cp = new CarouselPage();
+ var page = new ContentPage { Header = "A" };
+
+ ((AvaloniaList)cp.Pages!).Add(page);
+
+ Assert.Contains(page, cp.GetLogicalChildren());
+ }
+
+ [Fact]
+ public void Pages_Removed_RemovedFromLogicalChildren()
+ {
+ var cp = new CarouselPage();
+ var page = new ContentPage { Header = "A" };
+ ((AvaloniaList)cp.Pages!).Add(page);
+
+ ((AvaloniaList)cp.Pages!).Remove(page);
+
+ Assert.DoesNotContain(page, cp.GetLogicalChildren());
+ }
+
+ [Fact]
+ public void Pages_Clear_RemovesAllLogicalChildren()
+ {
+ var cp = new CarouselPage();
+ var page1 = new ContentPage { Header = "A" };
+ var page2 = new ContentPage { Header = "B" };
+ ((AvaloniaList)cp.Pages!).AddRange(new[] { page1, page2 });
+
+ ((AvaloniaList)cp.Pages!).Clear();
+
+ Assert.DoesNotContain(page1, cp.GetLogicalChildren());
+ Assert.DoesNotContain(page2, cp.GetLogicalChildren());
+ }
+
+ [Fact]
+ public void Pages_Replaced_OldPagesRemovedNewPagesAdded()
+ {
+ var cp = new CarouselPage();
+ var old1 = new ContentPage { Header = "Old1" };
+ var old2 = new ContentPage { Header = "Old2" };
+ ((AvaloniaList)cp.Pages!).AddRange(new[] { old1, old2 });
+
+ var newPages = new AvaloniaList { new ContentPage { Header = "New1" } };
+ cp.Pages = newPages;
+
+ Assert.DoesNotContain(old1, cp.GetLogicalChildren());
+ Assert.DoesNotContain(old2, cp.GetLogicalChildren());
+ Assert.Contains(newPages[0], cp.GetLogicalChildren());
+ }
+
+ [Fact]
+ public void Pages_SetNull_ClearsLogicalChildren()
+ {
+ var cp = new CarouselPage();
+ var page = new ContentPage { Header = "A" };
+ ((AvaloniaList)cp.Pages!).Add(page);
+
+ cp.Pages = null;
+
+ Assert.DoesNotContain(page, cp.GetLogicalChildren());
+ }
+ }
+
+ public class KeyboardNavigationTests : ScopedTestBase
+ {
+ [Fact]
+ public void RightKey_NavigatesToNextPage()
+ {
+ var cp = MakeCarousel(3, selectedIndex: 0);
+ cp.SimulateKeyDown(Key.Right);
+ Assert.Equal(1, cp.SelectedIndex);
+ }
+
+ [Fact]
+ public void DownKey_NavigatesToNextPage()
+ {
+ var cp = MakeCarousel(3, selectedIndex: 0);
+ cp.SimulateKeyDown(Key.Down);
+ Assert.Equal(1, cp.SelectedIndex);
+ }
+
+ [Fact]
+ public void LeftKey_NavigatesToPreviousPage()
+ {
+ var cp = MakeCarousel(3, selectedIndex: 2);
+ cp.SimulateKeyDown(Key.Left);
+ Assert.Equal(1, cp.SelectedIndex);
+ }
+
+ [Fact]
+ public void UpKey_NavigatesToPreviousPage()
+ {
+ var cp = MakeCarousel(3, selectedIndex: 2);
+ cp.SimulateKeyDown(Key.Up);
+ Assert.Equal(1, cp.SelectedIndex);
+ }
+
+ [Fact]
+ public void HomeKey_NavigatesToFirstPage()
+ {
+ var cp = MakeCarousel(3, selectedIndex: 2);
+ cp.SimulateKeyDown(Key.Home);
+ Assert.Equal(0, cp.SelectedIndex);
+ }
+
+ [Fact]
+ public void EndKey_NavigatesToLastPage()
+ {
+ var cp = MakeCarousel(3, selectedIndex: 0);
+ cp.SimulateKeyDown(Key.End);
+ Assert.Equal(2, cp.SelectedIndex);
+ }
+
+ [Fact]
+ public void LeftKey_AtFirstPage_DoesNotNavigate()
+ {
+ var cp = MakeCarousel(3, selectedIndex: 0);
+ cp.SimulateKeyDown(Key.Left);
+ Assert.Equal(0, cp.SelectedIndex);
+ }
+
+ [Fact]
+ public void RightKey_AtLastPage_DoesNotNavigate()
+ {
+ var cp = MakeCarousel(3, selectedIndex: 2);
+ cp.SimulateKeyDown(Key.Right);
+ Assert.Equal(2, cp.SelectedIndex);
+ }
+
+ [Fact]
+ public void KeyNavigation_Disabled_IgnoresArrowKey()
+ {
+ var cp = MakeCarousel(3, selectedIndex: 0);
+ cp.IsKeyboardNavigationEnabled = false;
+ cp.SimulateKeyDown(Key.Right);
+ Assert.Equal(0, cp.SelectedIndex);
+ }
+
+ [Fact]
+ public void RightKey_MarksEventHandled()
+ {
+ var cp = MakeCarousel(3, selectedIndex: 0);
+ var handled = cp.SimulateKeyDownReturnsHandled(Key.Right);
+ Assert.True(handled);
+ }
+
+ [Fact]
+ public void RightKey_AtLastPage_DoesNotMarkEventHandled()
+ {
+ var cp = MakeCarousel(3, selectedIndex: 2);
+ var handled = cp.SimulateKeyDownReturnsHandled(Key.Right);
+ Assert.False(handled);
+ }
+
+ [Fact]
+ public void RtlFlowDirection_LeftKey_NavigatesToNextPage()
+ {
+ var cp = MakeCarousel(3, selectedIndex: 0);
+ cp.FlowDirection = FlowDirection.RightToLeft;
+ cp.SimulateKeyDown(Key.Left);
+ Assert.Equal(1, cp.SelectedIndex);
+ }
+
+ [Fact]
+ public void RtlFlowDirection_RightKey_NavigatesToPreviousPage()
+ {
+ var cp = MakeCarousel(3, selectedIndex: 2);
+ cp.FlowDirection = FlowDirection.RightToLeft;
+ cp.SimulateKeyDown(Key.Right);
+ Assert.Equal(1, cp.SelectedIndex);
+ }
+
+ [Fact]
+ public void RtlFlowDirection_LeftKey_AtLastPage_DoesNotNavigate()
+ {
+ var cp = MakeCarousel(3, selectedIndex: 2);
+ cp.FlowDirection = FlowDirection.RightToLeft;
+ cp.SimulateKeyDown(Key.Left);
+ Assert.Equal(2, cp.SelectedIndex);
+ }
+
+ [Fact]
+ public void RtlFlowDirection_RightKey_AtFirstPage_DoesNotNavigate()
+ {
+ var cp = MakeCarousel(3, selectedIndex: 0);
+ cp.FlowDirection = FlowDirection.RightToLeft;
+ cp.SimulateKeyDown(Key.Right);
+ Assert.Equal(0, cp.SelectedIndex);
+ }
+
+ private static TestableCarouselPage MakeCarousel(int count, int selectedIndex)
+ {
+ var cp = new TestableCarouselPage();
+ for (var i = 0; i < count; i++)
+ ((AvaloniaList)cp.Pages!).Add(new ContentPage { Header = $"P{i}" });
+ cp.SelectedIndex = selectedIndex;
+ return cp;
+ }
+ }
+
+ public class WheelBehavior : ScopedTestBase
+ {
+ [Fact]
+ public void WheelDown_NavigatesForward()
+ {
+ var cp = MakeCarousel(3, selectedIndex: 0);
+ cp.SimulateWheel(new Vector(0, -1));
+ Assert.Equal(1, cp.SelectedIndex);
+ }
+
+ [Fact]
+ public void WheelUp_NavigatesBackward()
+ {
+ var cp = MakeCarousel(3, selectedIndex: 2);
+ cp.SimulateWheel(new Vector(0, 1));
+ Assert.Equal(1, cp.SelectedIndex);
+ }
+
+ [Fact]
+ public void WheelDown_AtLastPage_DoesNotHandleEvent()
+ {
+ var cp = MakeCarousel(3, selectedIndex: 2);
+ var handled = cp.SimulateWheelReturnsHandled(new Vector(0, -1));
+ Assert.Equal(2, cp.SelectedIndex);
+ Assert.False(handled);
+ }
+
+ [Fact]
+ public void WheelUp_AtFirstPage_DoesNotHandleEvent()
+ {
+ var cp = MakeCarousel(3, selectedIndex: 0);
+ var handled = cp.SimulateWheelReturnsHandled(new Vector(0, 1));
+ Assert.Equal(0, cp.SelectedIndex);
+ Assert.False(handled);
+ }
+
+ [Fact]
+ public void Wheel_WhenGestureDisabled_DoesNotHandleEvent()
+ {
+ var cp = MakeCarousel(3, selectedIndex: 0);
+ cp.IsGestureEnabled = false;
+ var handled = cp.SimulateWheelReturnsHandled(new Vector(0, -1));
+ Assert.Equal(0, cp.SelectedIndex);
+ Assert.False(handled);
+ }
+
+ [Fact]
+ public void Wheel_HandledByChild_DoesNotNavigate()
+ {
+ var cp = new CarouselPage
+ {
+ Width = 400,
+ Height = 300,
+ IsGestureEnabled = true,
+ Template = CreateCarouselPageTemplate(),
+ };
+
+ var child = new Border();
+ child.AddHandler(InputElement.PointerWheelChangedEvent, (_, e) => e.Handled = true);
+
+ var page0 = new ContentPage { Header = "P0", Content = child };
+ var page1 = new ContentPage { Header = "P1" };
+ var page2 = new ContentPage { Header = "P2" };
+ ((AvaloniaList)cp.Pages!).Add(page0);
+ ((AvaloniaList)cp.Pages!).Add(page1);
+ ((AvaloniaList)cp.Pages!).Add(page2);
+
+ var root = new TestRoot(cp) { ClientSize = new Size(400, 300) };
+ root.LayoutManager.ExecuteInitialLayoutPass();
+
+ var pointer = new Pointer(Pointer.GetNextFreeId(), PointerType.Mouse, true);
+ var wheelArgs = new PointerWheelEventArgs(
+ child,
+ pointer,
+ root,
+ default,
+ 0,
+ new PointerPointProperties(RawInputModifiers.None, PointerUpdateKind.Other),
+ KeyModifiers.None,
+ new Vector(0, -1))
+ {
+ RoutedEvent = InputElement.PointerWheelChangedEvent
+ };
+ child.RaiseEvent(wheelArgs);
+
+ Assert.True(wheelArgs.Handled);
+ Assert.Equal(0, cp.SelectedIndex);
+ }
+
+ private static FuncControlTemplate CreateCarouselPageTemplate()
+ {
+ return new FuncControlTemplate((_, scope) =>
+ new Carousel
+ {
+ Name = "PART_Carousel",
+ Template = new FuncControlTemplate((c, ns) =>
+ new ScrollViewer
+ {
+ Name = "PART_ScrollViewer",
+ HorizontalScrollBarVisibility = ScrollBarVisibility.Hidden,
+ VerticalScrollBarVisibility = ScrollBarVisibility.Hidden,
+ Content = new ItemsPresenter
+ {
+ Name = "PART_ItemsPresenter",
+ [~ItemsPresenter.ItemsPanelProperty] = c[~ItemsControl.ItemsPanelProperty],
+ }.RegisterInNameScope(ns)
+ }.RegisterInNameScope(ns)),
+ HorizontalAlignment = HorizontalAlignment.Stretch,
+ VerticalAlignment = VerticalAlignment.Stretch,
+ }.RegisterInNameScope(scope));
+ }
+
+ private static TestableCarouselPage MakeCarousel(int count, int selectedIndex)
+ {
+ var cp = new TestableCarouselPage();
+ for (var i = 0; i < count; i++)
+ ((AvaloniaList)cp.Pages!).Add(new ContentPage { Header = $"P{i}" });
+ cp.SelectedIndex = selectedIndex;
+ _ = new TestRoot { Child = cp };
+ return cp;
+ }
+ }
+
+ public class DataTemplateTests : ScopedTestBase
+ {
+ private record DataItem(string Name);
+
+ [Fact]
+ public void ItemsSource_SelectedPage_IsResolvedAfterLayout()
+ {
+ var cp = CreateTemplatedCarouselPage(
+ new[] { new DataItem("First"), new DataItem("Second") });
+
+ var root = new TestRoot { ClientSize = new Size(400, 300), Child = cp };
+ root.ExecuteInitialLayoutPass();
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ Assert.NotNull(cp.SelectedPage);
+ Assert.Equal("First", cp.SelectedPage!.Header?.ToString());
+ Assert.Equal("Page 1 of 2: First", Avalonia.Automation.AutomationProperties.GetName(cp));
+ }
+
+ [Fact]
+ public void KeyboardNavigation_UsesItemsSourceCount()
+ {
+ var cp = CreateTemplatedCarouselPage(
+ new[] { new DataItem("First"), new DataItem("Second") });
+
+ var root = new TestRoot { ClientSize = new Size(400, 300), Child = cp };
+ root.ExecuteInitialLayoutPass();
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ cp.SimulateKeyDown(Key.Right);
+ root.LayoutManager.ExecuteLayoutPass();
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ Assert.Equal(1, cp.SelectedIndex);
+ Assert.NotNull(cp.SelectedPage);
+ Assert.Equal("Second", cp.SelectedPage!.Header?.ToString());
+ Assert.Equal("Page 2 of 2: Second", Avalonia.Automation.AutomationProperties.GetName(cp));
+ }
+
+ [Fact]
+ public void WheelNavigation_UsesItemsSourceCount()
+ {
+ var cp = CreateTemplatedCarouselPage(
+ new[] { new DataItem("First"), new DataItem("Second") });
+
+ var root = new TestRoot { ClientSize = new Size(400, 300), Child = cp };
+ root.ExecuteInitialLayoutPass();
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ var handled = cp.SimulateWheelReturnsHandled(new Vector(0, -1));
+ root.LayoutManager.ExecuteLayoutPass();
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ Assert.True(handled);
+ Assert.Equal(1, cp.SelectedIndex);
+ Assert.NotNull(cp.SelectedPage);
+ Assert.Equal("Second", cp.SelectedPage!.Header?.ToString());
+ }
+
+ [Fact]
+ public void ItemsSource_SelectionChanges_FireLifecycleOnGeneratedPages()
+ {
+ var cp = CreateTemplatedCarouselPage(
+ new[] { new DataItem("First"), new DataItem("Second") },
+ item => new TrackingPage
+ {
+ Header = item?.Name,
+ Content = new Border { Width = 400, Height = 300 }
+ });
+
+ var root = new TestRoot { ClientSize = new Size(400, 300), Child = cp };
+ root.ExecuteInitialLayoutPass();
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ var firstPage = Assert.IsType(cp.SelectedPage);
+ Assert.Equal(1, firstPage.NavigatedToCount);
+ Assert.Equal(0, firstPage.NavigatedFromCount);
+
+ cp.SimulateKeyDown(Key.Right);
+ root.LayoutManager.ExecuteLayoutPass();
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ var secondPage = Assert.IsType(cp.SelectedPage);
+ Assert.Equal(1, firstPage.NavigatedFromCount);
+ Assert.Equal(1, secondPage.NavigatedToCount);
+ Assert.Same(secondPage, cp.CurrentPage);
+ }
+
+ [Fact]
+ public void PageTemplate_ChangedAfterContainersRealized_UpdatesSelectedPage()
+ {
+ var cp = CreateTemplatedCarouselPage(
+ new[] { new DataItem("First"), new DataItem("Second") },
+ item => new ContentPage
+ {
+ Header = $"Detail {item?.Name}",
+ Content = new Border { Width = 400, Height = 300 }
+ });
+
+ var root = new TestRoot { ClientSize = new Size(400, 300), Child = cp };
+ root.ExecuteInitialLayoutPass();
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ var originalPage = Assert.IsType(cp.SelectedPage);
+ Assert.Equal("Detail First", originalPage.Header);
+
+ cp.PageTemplate = new FuncDataTemplate(
+ (item, _) => new ContentPage
+ {
+ Header = $"Showcase {item?.Name}",
+ Content = new Border { Width = 400, Height = 300 }
+ },
+ supportsRecycling: false);
+
+ root.LayoutManager.ExecuteLayoutPass();
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ var updatedPage = Assert.IsType(cp.SelectedPage);
+ Assert.NotSame(originalPage, updatedPage);
+ Assert.Equal("Showcase First", updatedPage.Header);
+ }
+
+ private static T CreateTemplatedCarouselPage(IEnumerable items)
+ where T : CarouselPage, new()
+ {
+ return CreateTemplatedCarouselPage(
+ items,
+ item => new ContentPage
+ {
+ Header = item?.Name,
+ Content = new Border { Width = 400, Height = 300 }
+ });
+ }
+
+ private static T CreateTemplatedCarouselPage(IEnumerable items, Func pageFactory)
+ where T : CarouselPage, new()
+ {
+ return new T
+ {
+ Width = 400,
+ Height = 300,
+ ItemsSource = items,
+ PageTemplate = new FuncDataTemplate(
+ (item, _) => pageFactory(item),
+ supportsRecycling: false),
+ Template = CreateCarouselPageTemplate(),
+ };
+ }
+
+ private static FuncControlTemplate CreateCarouselPageTemplate()
+ {
+ return new FuncControlTemplate((_, scope) =>
+ new Carousel
+ {
+ Name = "PART_Carousel",
+ Template = CarouselTemplate(),
+ HorizontalAlignment = HorizontalAlignment.Stretch,
+ VerticalAlignment = VerticalAlignment.Stretch,
+ }.RegisterInNameScope(scope));
+ }
+
+ private static IControlTemplate CarouselTemplate()
+ {
+ return new FuncControlTemplate((c, ns) =>
+ new ScrollViewer
+ {
+ Name = "PART_ScrollViewer",
+ Template = ScrollViewerTemplate(),
+ HorizontalScrollBarVisibility = ScrollBarVisibility.Hidden,
+ VerticalScrollBarVisibility = ScrollBarVisibility.Hidden,
+ Content = new ItemsPresenter
+ {
+ Name = "PART_ItemsPresenter",
+ [~ItemsPresenter.ItemsPanelProperty] = c[~ItemsControl.ItemsPanelProperty],
+ }.RegisterInNameScope(ns)
+ }.RegisterInNameScope(ns));
+ }
+
+ private static FuncControlTemplate ScrollViewerTemplate()
+ {
+ return new FuncControlTemplate((_, ns) =>
+ new ScrollContentPresenter
+ {
+ Name = "PART_ContentPresenter",
+ }.RegisterInNameScope(ns));
+ }
+
+ private sealed class TrackingPage : ContentPage
+ {
+ public int NavigatedToCount { get; private set; }
+ public int NavigatedFromCount { get; private set; }
+
+ protected override void OnNavigatedTo(NavigatedToEventArgs args)
+ {
+ NavigatedToCount++;
+ base.OnNavigatedTo(args);
+ }
+
+ protected override void OnNavigatedFrom(NavigatedFromEventArgs args)
+ {
+ NavigatedFromCount++;
+ base.OnNavigatedFrom(args);
+ }
+ }
+ }
+
+ private sealed class TestableCarouselPage : CarouselPage
+ {
+ public void SimulateKeyDown(Key key)
+ {
+ var e = new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = key };
+ OnKeyDown(e);
+ }
+
+ public bool SimulateKeyDownReturnsHandled(Key key)
+ {
+ var e = new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = key };
+ OnKeyDown(e);
+ return e.Handled;
+ }
+
+ public void SimulateWheel(Vector delta)
+ {
+ SimulateWheelReturnsHandled(delta);
+ }
+
+ public bool SimulateWheelReturnsHandled(Vector delta)
+ {
+ var pointer = new FakePointer();
+ var e = new PointerWheelEventArgs(this, pointer, this, default, 0,
+ new PointerPointProperties(RawInputModifiers.None, PointerUpdateKind.Other),
+ KeyModifiers.None, delta)
+ {
+ RoutedEvent = PointerWheelChangedEvent
+ };
+ RaiseEvent(e);
+ return e.Handled;
+ }
+ }
+
+ public class SwipeGestureTests : ScopedTestBase
+ {
+ [Fact]
+ public void MouseSwipe_Advances_Page()
+ {
+ var clock = new MockGlobalClock();
+ using var app = UnitTestApplication.Start(
+ TestServices.MockPlatformRenderInterface.With(globalClock: clock));
+ using var sync = UnitTestSynchronizationContext.Begin();
+
+ var (cp, carousel, panel) = CreateSwipeReadyCarouselPage();
+ var mouse = new MouseTestHelper();
+
+ mouse.Down(panel, position: new Point(200, 100));
+ mouse.Move(panel, new Point(40, 100));
+ mouse.Up(panel, position: new Point(40, 100));
+ clock.Pulse(TimeSpan.Zero);
+ clock.Pulse(TimeSpan.FromSeconds(1));
+ sync.ExecutePostedCallbacks();
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ Assert.Equal(1, carousel.SelectedIndex);
+ Assert.Equal(1, cp.SelectedIndex);
+ Assert.Same(((AvaloniaList)cp.Pages!)[1], cp.CurrentPage);
+ }
+
+ [Fact]
+ public void TouchSwipe_Advances_Page()
+ {
+ var clock = new MockGlobalClock();
+ using var app = UnitTestApplication.Start(
+ TestServices.MockPlatformRenderInterface.With(globalClock: clock));
+ using var sync = UnitTestSynchronizationContext.Begin();
+
+ var (cp, carousel, panel) = CreateSwipeReadyCarouselPage();
+ var touch = new TouchTestHelper();
+
+ touch.Down(panel, new Point(200, 100));
+ touch.Move(panel, new Point(40, 100));
+ touch.Up(panel, new Point(40, 100));
+ clock.Pulse(TimeSpan.Zero);
+ clock.Pulse(TimeSpan.FromSeconds(1));
+ sync.ExecutePostedCallbacks();
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ Assert.Equal(1, carousel.SelectedIndex);
+ Assert.Equal(1, cp.SelectedIndex);
+ Assert.Same(((AvaloniaList)cp.Pages!)[1], cp.CurrentPage);
+ }
+
+ private static (CarouselPage Page, Carousel Carousel, VirtualizingCarouselPanel Panel) CreateSwipeReadyCarouselPage()
+ {
+ var cp = new CarouselPage
+ {
+ Width = 400,
+ Height = 300,
+ IsGestureEnabled = true,
+ PageTransition = new PageSlide(TimeSpan.FromMilliseconds(1)),
+ Pages = new AvaloniaList
+ {
+ new ContentPage { Header = "A", Content = new Border { Width = 400, Height = 300 } },
+ new ContentPage { Header = "B", Content = new Border { Width = 400, Height = 300 } },
+ new ContentPage { Header = "C", Content = new Border { Width = 400, Height = 300 } }
+ },
+ Template = new FuncControlTemplate((parent, scope) =>
+ new Carousel
+ {
+ Name = "PART_Carousel",
+ Template = CarouselTemplate(),
+ HorizontalAlignment = HorizontalAlignment.Stretch,
+ VerticalAlignment = VerticalAlignment.Stretch,
+ [~ItemsControl.ItemsSourceProperty] = parent[~CarouselPage.PagesProperty],
+ [~ItemsControl.ItemTemplateProperty] = parent[~CarouselPage.PageTemplateProperty],
+ [~ItemsControl.ItemsPanelProperty] = parent[~CarouselPage.ItemsPanelProperty],
+ [~Carousel.PageTransitionProperty] = parent[~CarouselPage.PageTransitionProperty],
+ }.RegisterInNameScope(scope))
+ };
+
+ var root = new TestRoot
+ {
+ ClientSize = new Size(400, 300),
+ Child = cp
+ };
+ root.ExecuteInitialLayoutPass();
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ var carousel = cp.GetVisualDescendants().OfType().Single();
+ var panel = Assert.IsType(carousel.Presenter!.Panel!);
+ Assert.True(carousel.IsSwipeEnabled);
+ var recognizer = Assert.Single(panel.GestureRecognizers.OfType());
+ Assert.True(recognizer.IsEnabled);
+ Assert.True(recognizer.CanHorizontallySwipe);
+ recognizer.IsMouseEnabled = true;
+ return (cp, carousel, panel);
+ }
+
+ private static IControlTemplate CarouselTemplate()
+ {
+ return new FuncControlTemplate((c, ns) =>
+ new ScrollViewer
+ {
+ Name = "PART_ScrollViewer",
+ Template = ScrollViewerTemplate(),
+ HorizontalScrollBarVisibility = ScrollBarVisibility.Hidden,
+ VerticalScrollBarVisibility = ScrollBarVisibility.Hidden,
+ Content = new ItemsPresenter
+ {
+ Name = "PART_ItemsPresenter",
+ [~ItemsPresenter.ItemsPanelProperty] = c[~ItemsControl.ItemsPanelProperty],
+ }.RegisterInNameScope(ns)
+ }.RegisterInNameScope(ns));
+ }
+
+ private static FuncControlTemplate ScrollViewerTemplate()
+ {
+ return new FuncControlTemplate((parent, scope) =>
+ new Panel
+ {
+ Children =
+ {
+ new ScrollContentPresenter
+ {
+ Name = "PART_ContentPresenter",
+ }.RegisterInNameScope(scope),
+ }
+ });
+ }
+ }
+
+ public class InteractiveTransitionTests : ScopedTestBase
+ {
+ [Fact]
+ public void PageSlide_Update_AppliesTranslateTransformToFrom()
+ {
+ var parent = new Canvas { Width = 400, Height = 300 };
+ var from = new Border();
+ var to = new Border();
+ parent.Children.Add(from);
+ parent.Children.Add(to);
+ parent.Measure(new Size(400, 300));
+ parent.Arrange(new Rect(0, 0, 400, 300));
+
+ var slide = new PageSlide(TimeSpan.FromMilliseconds(300));
+ slide.Update(0.5, from, to, true, 400, Array.Empty());
+
+ Assert.IsType(from.RenderTransform);
+ var ft = (TranslateTransform)from.RenderTransform!;
+ Assert.Equal(-200, ft.X);
+ }
+
+ [Fact]
+ public void PageSlide_Update_AppliesTranslateTransformToTo()
+ {
+ var parent = new Canvas { Width = 400, Height = 300 };
+ var from = new Border();
+ var to = new Border();
+ parent.Children.Add(from);
+ parent.Children.Add(to);
+ parent.Measure(new Size(400, 300));
+ parent.Arrange(new Rect(0, 0, 400, 300));
+
+ var slide = new PageSlide(TimeSpan.FromMilliseconds(300));
+ slide.Update(0.5, from, to, true, 400, Array.Empty());
+
+ Assert.IsType(to.RenderTransform);
+ var tt = (TranslateTransform)to.RenderTransform!;
+ Assert.Equal(200, tt.X);
+ }
+
+ [Fact]
+ public void CrossFade_Update_SetsOpacity()
+ {
+ var from = new Border();
+ var to = new Border();
+
+ var crossFade = new CrossFade(TimeSpan.FromMilliseconds(300));
+ crossFade.Update(0.5, from, to, true, 0, Array.Empty());
+
+ Assert.Equal(0.5, from.Opacity, 2);
+ Assert.Equal(0.5, to.Opacity, 2);
+ Assert.True(to.IsVisible);
+ }
+
+ }
+
+ public class CarouselIsSwipeEnabledTests : ScopedTestBase
+ {
+ [Fact]
+ public void IsSwipeEnabled_DefaultIsFalse()
+ {
+ var carousel = new Carousel();
+ Assert.False(carousel.IsSwipeEnabled);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void IsSwipeEnabled_RoundTrips(bool value)
+ {
+ var carousel = new Carousel { IsSwipeEnabled = value };
+ Assert.Equal(value, carousel.IsSwipeEnabled);
+ }
+
+ [Fact]
+ public void GetTransitionAxis_ReturnsNull_WhenNoTransition()
+ {
+ var carousel = new Carousel();
+ Assert.Null(carousel.GetTransitionAxis());
+ }
+
+ [Fact]
+ public void GetTransitionAxis_ReturnsNull_WhenNonPageSlideTransition()
+ {
+ var carousel = new Carousel { PageTransition = new CrossFade(TimeSpan.FromMilliseconds(200)) };
+ Assert.Null(carousel.GetTransitionAxis());
+ }
+
+ [Fact]
+ public void GetTransitionAxis_ReturnsVertical_WhenPageSlideVertical()
+ {
+ var carousel = new Carousel
+ {
+ PageTransition = new PageSlide(TimeSpan.FromMilliseconds(200), PageSlide.SlideAxis.Vertical)
+ };
+ Assert.Equal(PageSlide.SlideAxis.Vertical, carousel.GetTransitionAxis());
+ }
+
+ [Fact]
+ public void GetTransitionAxis_ReturnsVertical_WhenRotate3DVertical()
+ {
+ var carousel = new Carousel
+ {
+ PageTransition = new Rotate3DTransition(TimeSpan.FromMilliseconds(200), PageSlide.SlideAxis.Vertical)
+ };
+ Assert.Equal(PageSlide.SlideAxis.Vertical, carousel.GetTransitionAxis());
+ }
+ }
+
+ private sealed class TestPageTransition : IPageTransition
+ {
+ public Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken)
+ => Task.CompletedTask;
+ }
+
+ private sealed class FakePointer : IPointer
+ {
+ public int Id { get; } = Pointer.GetNextFreeId();
+ public void Capture(IInputElement? control) => Captured = control;
+ public IInputElement? Captured { get; set; }
+ public PointerType Type => PointerType.Mouse;
+ public bool IsPrimary => true;
+ }
+
+ public class VisualTreeLifecycleTests : ScopedTestBase
+ {
+ [Fact]
+ public void Detach_And_Reattach_CollectionChangedStillUpdatesSelection()
+ {
+ var pages = new AvaloniaList();
+ var cp = new CarouselPage { Pages = pages };
+ var root = new TestRoot { Child = cp };
+
+ var page1 = new ContentPage { Header = "A" };
+ pages.Add(page1);
+ Assert.Same(page1, cp.SelectedPage);
+
+ root.Child = null;
+ root.Child = cp;
+
+ var page2 = new ContentPage { Header = "B" };
+ pages.Add(page2);
+
+ pages.Remove(page1);
+ Assert.Same(page2, cp.SelectedPage);
+ }
+ }
+}
diff --git a/tests/Avalonia.Controls.UnitTests/CommandBarTests.cs b/tests/Avalonia.Controls.UnitTests/CommandBarTests.cs
index 0e291071d8..8bd4401d10 100644
--- a/tests/Avalonia.Controls.UnitTests/CommandBarTests.cs
+++ b/tests/Avalonia.Controls.UnitTests/CommandBarTests.cs
@@ -1,4 +1,5 @@
using System.Collections.Generic;
+using System.Collections.Specialized;
using Avalonia.Controls;
using Avalonia.UnitTests;
using Xunit;
@@ -521,6 +522,19 @@ public class CommandBarCollectionTests : ScopedTestBase
Assert.Contains(btn, cb.VisiblePrimaryCommands);
}
+ [Fact]
+ public void PrimaryCommands_DefaultCollection_DoesNotDuplicateVisiblePrimaryNotifications()
+ {
+ var cb = new CommandBar();
+ var notifications = 0;
+
+ ((INotifyCollectionChanged)cb.VisiblePrimaryCommands).CollectionChanged += (_, _) => notifications++;
+
+ cb.PrimaryCommands!.Add(new AppBarButton { Label = "Save" });
+
+ Assert.Equal(2, notifications);
+ }
+
[Fact]
public void PrimaryCommands_Removed_DisappearsFromVisiblePrimary()
{
@@ -540,6 +554,19 @@ public class CommandBarCollectionTests : ScopedTestBase
Assert.Contains(btn, cb.OverflowItems);
}
+ [Fact]
+ public void SecondaryCommands_DefaultCollection_DoesNotDuplicateOverflowNotifications()
+ {
+ var cb = new CommandBar();
+ var notifications = 0;
+
+ ((INotifyCollectionChanged)cb.OverflowItems).CollectionChanged += (_, _) => notifications++;
+
+ cb.SecondaryCommands!.Add(new AppBarButton { Label = "Settings" });
+
+ Assert.Equal(2, notifications);
+ }
+
[Fact]
public void SecondaryCommands_Removed_DisappearsFromOverflowItems()
{
diff --git a/tests/Avalonia.Controls.UnitTests/ContentPageTests.cs b/tests/Avalonia.Controls.UnitTests/ContentPageTests.cs
index 19a9becc4f..385163b103 100644
--- a/tests/Avalonia.Controls.UnitTests/ContentPageTests.cs
+++ b/tests/Avalonia.Controls.UnitTests/ContentPageTests.cs
@@ -1,4 +1,6 @@
+using System;
using Avalonia.Controls.Templates;
+using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.LogicalTree;
using Avalonia.UnitTests;
@@ -112,6 +114,17 @@ public class ContentPageTests
Assert.Same(ctrl, page.Content);
}
+ [Theory]
+ [InlineData(typeof(ContentPage))]
+ [InlineData(typeof(NavigationPage))]
+ [InlineData(typeof(TabbedPage))]
+ public void Content_SetAnyPageSubtype_ThrowsInvalidOperationException(Type pageType)
+ {
+ var host = new ContentPage();
+ var child = (Page)Activator.CreateInstance(pageType)!;
+ Assert.Throws(() => host.Content = child);
+ }
+
[Fact]
public void AutomaticallyApplySafeAreaPadding_DefaultIsTrue()
{
@@ -222,18 +235,6 @@ public class ContentPageTests
Assert.Empty(((ILogical)page).LogicalChildren);
}
- [Fact]
- public void Content_ReplacedPage_ResetsSafeAreaPaddingOnOldPage()
- {
- var host = new ContentPage();
- var oldChild = new ContentPage();
- oldChild.SafeAreaPadding = new Thickness(10, 20, 10, 30);
- host.Content = oldChild;
-
- host.Content = new ContentPage();
-
- Assert.Equal(default(Thickness), oldChild.SafeAreaPadding);
- }
}
public class CommandBarPropertyTests : ScopedTestBase
diff --git a/tests/Avalonia.Controls.UnitTests/DrawerPageTests.cs b/tests/Avalonia.Controls.UnitTests/DrawerPageTests.cs
index d8f50b81de..f4afe81e19 100644
--- a/tests/Avalonia.Controls.UnitTests/DrawerPageTests.cs
+++ b/tests/Avalonia.Controls.UnitTests/DrawerPageTests.cs
@@ -8,8 +8,10 @@ using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.LogicalTree;
using Avalonia.Media;
+using Avalonia.Controls.Templates;
using Avalonia.Threading;
using Avalonia.UnitTests;
+using Avalonia.VisualTree;
using Xunit;
namespace Avalonia.Controls.UnitTests;
@@ -53,10 +55,10 @@ public class DrawerPageTests
[InlineData(600.0)]
[InlineData(800.0)]
[InlineData(1200.0)]
- public void DrawerBreakpointWidth_RoundTrips(double width)
+ public void DrawerBreakpointLength_RoundTrips(double width)
{
- var dp = new DrawerPage { DrawerBreakpointWidth = width };
- Assert.Equal(width, dp.DrawerBreakpointWidth);
+ var dp = new DrawerPage { DrawerBreakpointLength = width };
+ Assert.Equal(width, dp.DrawerBreakpointLength);
}
[Theory]
@@ -559,6 +561,57 @@ public class DrawerPageTests
Assert.True(dp.IsOpen);
}
+
+ [Fact]
+ public void IsOpen_RapidToggle_EventsFiredExactlyOncePerChange()
+ {
+ var dp = new DrawerPage();
+ int openedCount = 0;
+ int closedCount = 0;
+ dp.Opened += (_, _) => openedCount++;
+ dp.Closed += (_, _) => closedCount++;
+
+ for (int i = 0; i < 5; i++)
+ {
+ dp.IsOpen = true;
+ dp.IsOpen = false;
+ }
+
+ Assert.Equal(5, openedCount);
+ Assert.Equal(5, closedCount);
+ Assert.False(dp.IsOpen);
+ }
+
+ [Fact]
+ public void BackdropPress_WithCanceledClose_FiresClosingOnce_WhenTemplateWasAppliedBeforeAttach()
+ {
+ var dp = new DrawerPage
+ {
+ Template = BackdropOnlyTemplate(),
+ IsOpen = true,
+ BackdropBrush = Brushes.Black,
+ DisplayMode = SplitViewDisplayMode.Overlay
+ };
+
+ dp.ApplyTemplate();
+
+ int closingCount = 0;
+ dp.Closing += (_, e) =>
+ {
+ closingCount++;
+ e.Cancel = true;
+ };
+
+ var root = new TestRoot { Child = dp };
+ root.ExecuteInitialLayoutPass();
+
+ var backdrop = Assert.Single(dp.GetVisualDescendants().OfType(), x => x.Name == "PART_Backdrop");
+
+ RaisePointerPressed(backdrop);
+
+ Assert.Equal(1, closingCount);
+ Assert.True(dp.IsOpen);
+ }
}
public class LifecycleEventTests : ScopedTestBase
@@ -832,25 +885,95 @@ public class DrawerPageTests
}
[Fact]
- public void DrawerBreakpointWidth_BeforeLayout_DoesNotOverrideLayoutBehavior()
+ public void DrawerBreakpointLength_BeforeLayout_DoesNotOverrideLayoutBehavior()
{
var dp = new DrawerPage
{
DrawerLayoutBehavior = DrawerLayoutBehavior.Split,
- DrawerBreakpointWidth = 1200
+ DrawerBreakpointLength = 1200
};
Assert.Equal(SplitViewDisplayMode.Inline, dp.DisplayMode);
}
[Fact]
- public void DrawerBreakpointWidth_Zero_DoesNotOverrideLayoutBehavior()
+ public void DrawerBreakpointLength_Zero_DoesNotOverrideLayoutBehavior()
{
// Breakpoint == 0 means the feature is disabled; DrawerLayoutBehavior drives DisplayMode.
var dp = new DrawerPage
{
DrawerLayoutBehavior = DrawerLayoutBehavior.Split,
- DrawerBreakpointWidth = 0
+ DrawerBreakpointLength = 0
+ };
+ Assert.Equal(SplitViewDisplayMode.Inline, dp.DisplayMode);
+ }
+
+ [Theory]
+ [InlineData(DrawerPlacement.Left)]
+ [InlineData(DrawerPlacement.Right)]
+ public void DrawerBreakpointLength_Horizontal_BelowBreakpoint_ForcesOverlay(DrawerPlacement placement)
+ {
+ var dp = new DrawerPage
+ {
+ DrawerLayoutBehavior = DrawerLayoutBehavior.Split,
+ DrawerBreakpointLength = 1200,
+ DrawerPlacement = placement
+ };
+ dp.Measure(new Size(800, 600));
+ dp.Arrange(new Rect(0, 0, 800, 600));
+
+ Assert.Equal(SplitViewDisplayMode.Overlay, dp.DisplayMode);
+ }
+
+ [Theory]
+ [InlineData(DrawerPlacement.Left)]
+ [InlineData(DrawerPlacement.Right)]
+ public void DrawerBreakpointLength_Horizontal_AboveBreakpoint_UsesConfiguredLayout(DrawerPlacement placement)
+ {
+ var dp = new DrawerPage
+ {
+ DrawerLayoutBehavior = DrawerLayoutBehavior.Split,
+ DrawerBreakpointLength = 600,
+ DrawerPlacement = placement
+ };
+ dp.Measure(new Size(800, 600));
+ dp.Arrange(new Rect(0, 0, 800, 600));
+
+ Assert.Equal(SplitViewDisplayMode.Inline, dp.DisplayMode);
+ }
+
+ [Theory]
+ [InlineData(DrawerPlacement.Top)]
+ [InlineData(DrawerPlacement.Bottom)]
+ public void DrawerBreakpointLength_Vertical_BelowBreakpoint_ForcesOverlay(DrawerPlacement placement)
+ {
+ var dp = new DrawerPage
+ {
+ DrawerLayoutBehavior = DrawerLayoutBehavior.Split,
+ DrawerBreakpointLength = 800,
+ DrawerPlacement = placement
+ };
+ dp.Measure(new Size(800, 600));
+ dp.Arrange(new Rect(0, 0, 800, 600));
+
+ // Vertical: breakpoint compares against Bounds.Height (600 < 800 → Overlay)
+ Assert.Equal(SplitViewDisplayMode.Overlay, dp.DisplayMode);
+ }
+
+ [Theory]
+ [InlineData(DrawerPlacement.Top)]
+ [InlineData(DrawerPlacement.Bottom)]
+ public void DrawerBreakpointLength_Vertical_AboveBreakpoint_UsesConfiguredLayout(DrawerPlacement placement)
+ {
+ var dp = new DrawerPage
+ {
+ DrawerLayoutBehavior = DrawerLayoutBehavior.Split,
+ DrawerBreakpointLength = 400,
+ DrawerPlacement = placement
};
+ dp.Measure(new Size(800, 600));
+ dp.Arrange(new Rect(0, 0, 800, 600));
+
+ // Vertical: breakpoint compares against Bounds.Height (600 > 400 → Inline)
Assert.Equal(SplitViewDisplayMode.Inline, dp.DisplayMode);
}
}
@@ -1034,20 +1157,116 @@ public class DrawerPageTests
}
}
- public class DrawerIconTests : ScopedTestBase
+ public class IconTests : ScopedTestBase
{
[Fact]
- public void DrawerIcon_PathIcon_DoesNotCrashWhenTemplateApplied()
+ public void Geometry_ReturnsPathIcon()
{
- var icon = new PathIcon();
- var dp = new DrawerPage { DrawerIcon = icon };
+ var geometry = new EllipseGeometry { Rect = new Rect(0, 0, 10, 10) };
+ var result = DrawerPage.CreateIconContent(geometry);
+ Assert.IsType(result);
+ Assert.Same(geometry, ((PathIcon)result!).Data);
+ }
+
+ [Fact]
+ public void PathIcon_ReturnsPathIcon()
+ {
+ var geometry = new EllipseGeometry { Rect = new Rect(0, 0, 10, 10) };
+ var pathIcon = new PathIcon { Data = geometry };
+ var result = DrawerPage.CreateIconContent(pathIcon);
+ Assert.IsType(result);
+ Assert.Same(geometry, ((PathIcon)result!).Data);
+ }
+
+ [Fact]
+ public void DrawingImage_WithGeometryDrawing_ReturnsPathIcon()
+ {
+ var geometry = new EllipseGeometry { Rect = new Rect(0, 0, 10, 10) };
+ var drawing = new GeometryDrawing { Geometry = geometry };
+ var drawingImage = new DrawingImage(drawing);
+ var result = DrawerPage.CreateIconContent(drawingImage);
+ Assert.IsType(result);
+ Assert.Same(geometry, ((PathIcon)result!).Data);
+ }
+
+ [Fact]
+ public void Image_ReturnsImage()
+ {
+ var image = new TestImage();
+ var result = DrawerPage.CreateIconContent(image);
+ Assert.IsType(result);
+ Assert.Same(image, ((Image)result!).Source);
+ }
+
+ private sealed class TestImage : IImage
+ {
+ public Size Size => new Size(1, 1);
+ public void Draw(DrawingContext context, Rect sourceRect, Rect destRect) { }
+ }
+
+ [Fact]
+ public void EmptyString_ReturnsNull()
+ {
+ var result = DrawerPage.CreateIconContent("");
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void NullString_ReturnsNull()
+ {
+ var result = DrawerPage.CreateIconContent((string?)null);
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void Null_ReturnsNull()
+ {
+ var result = DrawerPage.CreateIconContent(null);
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void Template_BuildsControl()
+ {
+ var template = new FuncTemplate(() => new Border());
+ var result = DrawerPage.CreateIconContent(template);
+ Assert.IsType(result);
+ }
+
+ [Fact]
+ public void Template_BuildsSeparateInstances()
+ {
+ var template = new FuncTemplate(() => new Border());
+ var first = DrawerPage.CreateIconContent(template);
+ var second = DrawerPage.CreateIconContent(template);
+ Assert.NotSame(first, second);
+ }
+
+ [Fact]
+ public void NonEmptyString_ReturnsNull()
+ {
+ var result = DrawerPage.CreateIconContent("M10 20v-6h4v6");
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void UnsupportedType_ReturnsNull()
+ {
+ var result = DrawerPage.CreateIconContent(42);
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void ChangingDrawerIcon_AfterTemplateApplied_UpdatesPresenters()
+ {
+ var geometry = new EllipseGeometry { Rect = new Rect(0, 0, 10, 10) };
+ var dp = new DrawerPage { DrawerIcon = new PathIcon { Data = geometry } };
var root = new TestRoot { Child = dp };
- // Changing DrawerIcon after template is applied must not throw.
- var icon2 = new PathIcon();
- dp.DrawerIcon = icon2;
+ var geometry2 = new EllipseGeometry { Rect = new Rect(0, 0, 20, 20) };
+ dp.DrawerIcon = new PathIcon { Data = geometry2 };
- Assert.Same(icon2, dp.DrawerIcon);
+ Assert.Same(geometry2, dp.DrawerIcon is PathIcon pi ? pi.Data : null);
}
}
@@ -1127,6 +1346,36 @@ public class DrawerPageTests
}
}
+ private static void RaisePointerPressed(Interactive target, Point? position = null)
+ {
+ var pointer = new Pointer(Pointer.GetNextFreeId(), PointerType.Touch, true);
+ var args = new PointerPressedEventArgs(
+ target,
+ pointer,
+ (Visual)target,
+ position ?? default,
+ timestamp: 1,
+ new PointerPointProperties(RawInputModifiers.LeftMouseButton, PointerUpdateKind.LeftButtonPressed),
+ KeyModifiers.None);
+
+ target.RaiseEvent(args);
+ }
+
+ private static IControlTemplate BackdropOnlyTemplate()
+ {
+ return new FuncControlTemplate((_, scope) =>
+ new Canvas
+ {
+ Children =
+ {
+ new Border
+ {
+ Name = "PART_Backdrop"
+ }.RegisterInNameScope(scope)
+ }
+ });
+ }
+
public class DetachmentTests : ScopedTestBase
{
[Fact]
@@ -1146,5 +1395,23 @@ public class DrawerPageTests
await nav.PushAsync(page);
Assert.Null(NavigationPage.GetBackButtonContent(page));
}
+
+ [Fact]
+ public async Task Detach_And_Reattach_RestoresDrawerPageReference()
+ {
+ var nav = new NavigationPage();
+ var dp = new DrawerPage { Content = nav };
+ var root = new TestRoot { Child = dp };
+ var page = new ContentPage();
+ await nav.PushAsync(page);
+
+ Assert.True(nav.IsBackButtonEffectivelyVisible);
+
+ root.Child = null;
+ Assert.False(nav.IsBackButtonEffectivelyVisible ?? false);
+
+ root.Child = dp;
+ Assert.True(nav.IsBackButtonEffectivelyVisible);
+ }
}
}
diff --git a/tests/Avalonia.Controls.UnitTests/NavigationEventArgsTests.cs b/tests/Avalonia.Controls.UnitTests/NavigationEventArgsTests.cs
index 3e57152d08..82d092162e 100644
--- a/tests/Avalonia.Controls.UnitTests/NavigationEventArgsTests.cs
+++ b/tests/Avalonia.Controls.UnitTests/NavigationEventArgsTests.cs
@@ -94,7 +94,7 @@ public class NavigationEventArgsTests
{
var prev = new ContentPage { Header = "Tab 1" };
var current = new ContentPage { Header = "Tab 2" };
- var args = new PageSelectionChangedEventArgs(prev, current);
+ var args = new PageSelectionChangedEventArgs(SelectingMultiPage.SelectionChangedEvent, prev, current);
Assert.Same(prev, args.PreviousPage);
Assert.Same(current, args.CurrentPage);
}
diff --git a/tests/Avalonia.Controls.UnitTests/NavigationPageTests.cs b/tests/Avalonia.Controls.UnitTests/NavigationPageTests.cs
index 2d15825f72..e61b449a91 100644
--- a/tests/Avalonia.Controls.UnitTests/NavigationPageTests.cs
+++ b/tests/Avalonia.Controls.UnitTests/NavigationPageTests.cs
@@ -6,14 +6,16 @@ using System.Threading.Tasks;
using Avalonia.Animation;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives;
+using Avalonia.Controls.Shapes;
using Avalonia.Controls.Templates;
using Avalonia.Input;
using Avalonia.Input.GestureRecognizers;
using Avalonia.Interactivity;
using Avalonia.LogicalTree;
+using Avalonia.Media;
using Avalonia.Threading;
-using Avalonia.VisualTree;
using Avalonia.UnitTests;
+using Avalonia.VisualTree;
using Xunit;
namespace Avalonia.Controls.UnitTests;
@@ -81,6 +83,18 @@ public class NavigationPageTests
await Assert.ThrowsAsync(() => nav.PushAsync(page));
}
+ [Fact]
+ public async Task Push_PageAlreadyPresentedModally_Throws()
+ {
+ var nav = new NavigationPage();
+ await nav.PushAsync(new ContentPage());
+
+ var modal = new ContentPage();
+ await nav.PushModalAsync(modal);
+
+ await Assert.ThrowsAsync(() => nav.PushAsync(modal));
+ }
+
[Fact]
public async Task Push_FiresPushedEvent()
{
@@ -153,8 +167,6 @@ public class NavigationPageTests
[Fact]
public async Task Push_ReentrantFromNavigatedTo_IsIgnoredNotThrown()
{
- // Verifies that a re-entrant Push called from inside a NavigatedTo
- // lifecycle callback is silently ignored rather than throwing.
var nav = new NavigationPage();
var root = new ContentPage();
await nav.PushAsync(root);
@@ -162,13 +174,65 @@ public class NavigationPageTests
var second = new ContentPage();
second.NavigatedTo += async (_, _) => await nav.PushAsync(new ContentPage());
- await nav.PushAsync(second); // must not throw
+ await nav.PushAsync(second);
Assert.Equal(2, nav.StackDepth);
Assert.Same(second, nav.CurrentPage);
}
}
+ public class ReentrantNavigationTests : ScopedTestBase
+ {
+ [Fact]
+ public async Task Pop_ReentrantFromNavigatedTo_IsIgnored()
+ {
+ var nav = new NavigationPage();
+ var root = new ContentPage();
+ var top = new ContentPage();
+ await nav.PushAsync(root);
+ await nav.PushAsync(top);
+
+ root.NavigatedTo += async (_, _) => await nav.PopAsync();
+
+ await nav.PopAsync();
+
+ Assert.Equal(1, nav.StackDepth);
+ Assert.Same(root, nav.CurrentPage);
+ }
+
+ [Fact]
+ public async Task PushModal_ReentrantFromNavigatedTo_IsIgnored()
+ {
+ var nav = new NavigationPage();
+ await nav.PushAsync(new ContentPage());
+
+ var modal = new ContentPage();
+ modal.NavigatedTo += async (_, _) => await nav.PushModalAsync(new ContentPage());
+
+ await nav.PushModalAsync(modal);
+
+ Assert.Equal(1, nav.ModalStack.Count);
+ Assert.Same(modal, nav.ModalStack[0]);
+ }
+
+ [Fact]
+ public async Task PopModal_ReentrantFromNavigatedTo_IsIgnored()
+ {
+ var nav = new NavigationPage();
+ var root = new ContentPage();
+ var modal = new ContentPage();
+ await nav.PushAsync(root);
+ await nav.PushModalAsync(modal);
+
+ root.NavigatedTo += async (_, _) => await nav.PopModalAsync();
+
+ await nav.PopModalAsync();
+
+ Assert.Equal(0, nav.ModalStack.Count);
+ Assert.Same(root, nav.CurrentPage);
+ }
+ }
+
public class PopTests : ScopedTestBase
{
[Fact]
@@ -408,6 +472,135 @@ public class NavigationPageTests
}
}
+ public class BackButtonContentTests : ScopedTestBase
+ {
+ [Fact]
+ public async Task DrawerToggle_UsesMenuIconWithoutMutatingPageBackButtonContent()
+ {
+ var parts = new BackButtonParts();
+ var nav = CreateNavigationPageWithBackButtonParts(parts);
+ nav.Resources.Add("NavigationPageMenuIcon", new StreamGeometry());
+ var drawer = new DrawerPage();
+ var root = new TestRoot { Child = nav };
+ root.LayoutManager.ExecuteInitialLayoutPass();
+ nav.SetDrawerPage(drawer);
+
+ var page = new ContentPage { Header = "Root" };
+ await nav.PushAsync(page);
+
+ Assert.NotNull(parts.DefaultIcon);
+ Assert.NotNull(parts.ContentPresenter);
+ Assert.Null(NavigationPage.GetBackButtonContent(page));
+ Assert.False(parts.DefaultIcon!.IsVisible);
+ Assert.IsType(parts.ContentPresenter!.Content);
+ }
+
+ [Fact]
+ public async Task CurrentPageBackButtonContent_UpdatesRenderedPresenter()
+ {
+ var parts = new BackButtonParts();
+ var nav = CreateNavigationPageWithBackButtonParts(parts);
+ var root = new TestRoot { Child = nav };
+ root.LayoutManager.ExecuteInitialLayoutPass();
+
+ await nav.PushAsync(new ContentPage { Header = "Root" });
+ var detail = new ContentPage { Header = "Detail" };
+ await nav.PushAsync(detail);
+
+ var customContent = "Custom";
+ NavigationPage.SetBackButtonContent(detail, customContent);
+
+ Assert.NotNull(parts.DefaultIcon);
+ Assert.NotNull(parts.ContentPresenter);
+ Assert.False(parts.DefaultIcon!.IsVisible);
+ Assert.Equal(customContent, parts.ContentPresenter!.Content);
+
+ NavigationPage.SetBackButtonContent(detail, null);
+
+ Assert.True(parts.DefaultIcon.IsVisible);
+ Assert.Null(parts.ContentPresenter.Content);
+ }
+
+ [Fact]
+ public async Task DrawerBehaviorChange_DoesNotClearCustomPathIcon()
+ {
+ var parts = new BackButtonParts();
+ var nav = CreateNavigationPageWithBackButtonParts(parts);
+ var drawer = new DrawerPage();
+ var root = new TestRoot { Child = nav };
+ root.LayoutManager.ExecuteInitialLayoutPass();
+ nav.SetDrawerPage(drawer);
+
+ var customIcon = new PathIcon();
+ var page = new ContentPage { Header = "Root" };
+ NavigationPage.SetBackButtonContent(page, customIcon);
+
+ await nav.PushAsync(page);
+ drawer.DrawerBehavior = DrawerBehavior.Locked;
+ nav.SetDrawerPage(drawer);
+
+ Assert.NotNull(parts.DefaultIcon);
+ Assert.NotNull(parts.ContentPresenter);
+ Assert.Same(customIcon, NavigationPage.GetBackButtonContent(page));
+ Assert.Same(customIcon, parts.ContentPresenter!.Content);
+ Assert.False(parts.DefaultIcon!.IsVisible);
+ }
+
+ private static NavigationPage CreateNavigationPageWithBackButtonParts(BackButtonParts parts)
+ {
+ return new NavigationPage
+ {
+ Template = new FuncControlTemplate((parent, ns) =>
+ {
+ parts.DefaultIcon = new Path { Name = "PART_BackButtonDefaultIcon" }.RegisterInNameScope(ns);
+ parts.ContentPresenter = new ContentPresenter { Name = "PART_BackButtonContentPresenter" }.RegisterInNameScope(ns);
+
+ return new Panel
+ {
+ Children =
+ {
+ new Border
+ {
+ Name = "PART_NavigationBar",
+ Child = new Button
+ {
+ Name = "PART_BackButton",
+ Content = new Panel
+ {
+ Children =
+ {
+ parts.DefaultIcon,
+ parts.ContentPresenter
+ }
+ }
+ }.RegisterInNameScope(ns)
+ }.RegisterInNameScope(ns),
+ new Panel
+ {
+ Name = "PART_ContentHost",
+ Children =
+ {
+ new ContentPresenter { Name = "PART_PageBackPresenter" }.RegisterInNameScope(ns),
+ new ContentPresenter { Name = "PART_PagePresenter" }.RegisterInNameScope(ns),
+ }
+ }.RegisterInNameScope(ns),
+ new ContentPresenter { Name = "PART_TopCommandBar" }.RegisterInNameScope(ns),
+ new ContentPresenter { Name = "PART_ModalBackPresenter" }.RegisterInNameScope(ns),
+ new ContentPresenter { Name = "PART_ModalPresenter" }.RegisterInNameScope(ns),
+ }
+ };
+ })
+ };
+ }
+
+ private sealed class BackButtonParts
+ {
+ public Path? DefaultIcon { get; set; }
+
+ public ContentPresenter? ContentPresenter { get; set; }
+ }
+ }
+
public class PopToRootTests : ScopedTestBase
{
[Fact]
@@ -617,20 +810,49 @@ public class NavigationPageTests
[Fact]
public async Task InsertPage_DoesNotFireNavigatedTo_OnInsertedPage()
{
- // InsertPage should not invoke NavigatedTo on a non-current (background) page.
var nav = new NavigationPage();
var root = new ContentPage();
var top = new ContentPage();
await nav.PushAsync(root);
await nav.PushAsync(top);
- bool navigatedToFired = false;
+ NavigatedToEventArgs? args = null;
+ var inserted = new ContentPage();
+ inserted.NavigatedTo += (_, e) => args = e;
+
+ nav.InsertPage(inserted, top);
+
+ Assert.Null(args);
+ }
+
+ [Fact]
+ public async Task InsertPage_FiresNavigatedTo_WhenInsertedPageBecomesCurrent()
+ {
+ var nav = new NavigationPage();
+ var root = new ContentPage();
+ var top = new ContentPage();
+ await nav.PushAsync(root);
+ await nav.PushAsync(top);
+
var inserted = new ContentPage();
- inserted.NavigatedTo += (_, _) => navigatedToFired = true;
+ int navigatedToCount = 0;
+ NavigatedToEventArgs? args = null;
+ inserted.NavigatedTo += (_, e) =>
+ {
+ navigatedToCount++;
+ args = e;
+ };
nav.InsertPage(inserted, top);
+ Assert.Equal(0, navigatedToCount);
+
+ await nav.PopAsync();
- Assert.False(navigatedToFired);
+ Assert.Equal(1, navigatedToCount);
+ Assert.NotNull(args);
+ Assert.Equal(NavigationType.Pop, args!.NavigationType);
+ Assert.Same(top, args.PreviousPage);
+ Assert.Same(inserted, nav.CurrentPage);
}
[Fact]
@@ -645,6 +867,31 @@ public class NavigationPageTests
Assert.Throws(() => nav.InsertPage(root, top));
}
+ [Fact]
+ public async Task InsertPage_PageAlreadyPresentedModally_ThrowsInvalidOperationException()
+ {
+ var nav = new NavigationPage();
+ var root = new ContentPage();
+ var top = new ContentPage();
+ var modal = new ContentPage();
+ await nav.PushAsync(root);
+ await nav.PushAsync(top);
+ await nav.PushModalAsync(modal);
+
+ Assert.Throws(() => nav.InsertPage(modal, top));
+ }
+
+ [Fact]
+ public async Task InsertPage_BeforeNotInStack_ThrowsInvalidOperationException()
+ {
+ var nav = new NavigationPage();
+ var root = new ContentPage();
+ await nav.PushAsync(root);
+
+ var stranger = new ContentPage();
+ Assert.Throws(() => nav.InsertPage(new ContentPage(), stranger));
+ }
+
[Fact]
public async Task RemovePage_PageNotInStack_IsNoOp()
{
@@ -656,6 +903,59 @@ public class NavigationPageTests
nav.RemovePage(stranger);
Assert.Equal(1, nav.StackDepth);
}
+
+ [Fact]
+ public async Task RemovePage_TopPage_FiresNavigatedFrom_WithRemoveType()
+ {
+ var nav = new NavigationPage();
+ var root = new ContentPage { Header = "Root" };
+ var top = new ContentPage { Header = "Top" };
+ await nav.PushAsync(root);
+ await nav.PushAsync(top);
+
+ NavigatedFromEventArgs? fromArgs = null;
+ top.NavigatedFrom += (_, e) => fromArgs = e;
+
+ nav.RemovePage(top);
+
+ Assert.NotNull(fromArgs);
+ Assert.Equal(NavigationType.Remove, fromArgs!.NavigationType);
+ Assert.Same(root, fromArgs.DestinationPage);
+ }
+
+ [Fact]
+ public async Task RemovePage_TopPage_FiresNavigatedTo_OnNewCurrentPage_WithRemoveType()
+ {
+ var nav = new NavigationPage();
+ var root = new ContentPage { Header = "Root" };
+ var top = new ContentPage { Header = "Top" };
+ await nav.PushAsync(root);
+ await nav.PushAsync(top);
+
+ NavigatedToEventArgs? toArgs = null;
+ root.NavigatedTo += (_, e) => toArgs = e;
+
+ nav.RemovePage(top);
+
+ Assert.NotNull(toArgs);
+ Assert.Equal(NavigationType.Remove, toArgs!.NavigationType);
+ Assert.Same(top, toArgs.PreviousPage);
+ }
+
+ [Fact]
+ public async Task RemovePage_TopPage_UpdatesCurrentPage()
+ {
+ var nav = new NavigationPage();
+ var root = new ContentPage { Header = "Root" };
+ var top = new ContentPage { Header = "Top" };
+ await nav.PushAsync(root);
+ await nav.PushAsync(top);
+
+ nav.RemovePage(top);
+
+ Assert.Same(root, nav.CurrentPage);
+ Assert.Equal(1, nav.StackDepth);
+ }
}
public class ModalTests : ScopedTestBase
@@ -672,6 +972,28 @@ public class NavigationPageTests
Assert.Equal(1, nav.ModalStack.Count);
}
+ [Fact]
+ public async Task PushModal_PageAlreadyInNavigationStack_Throws()
+ {
+ var nav = new NavigationPage();
+ var root = new ContentPage { Header = "Root" };
+ await nav.PushAsync(root);
+
+ await Assert.ThrowsAsync(() => nav.PushModalAsync(root));
+ }
+
+ [Fact]
+ public async Task PushModal_DuplicateModal_Throws()
+ {
+ var nav = new NavigationPage();
+ await nav.PushAsync(new ContentPage());
+
+ var modal = new ContentPage { Header = "Modal" };
+ await nav.PushModalAsync(modal);
+
+ await Assert.ThrowsAsync(() => nav.PushModalAsync(modal));
+ }
+
[Fact]
public async Task PopModal_RemovesFromModalStack()
{
@@ -880,52 +1202,217 @@ public class NavigationPageTests
Assert.NotNull(args);
Assert.Same(modal, args!.PreviousPage);
}
- }
- public class AttachedPropertyTests : ScopedTestBase
- {
[Fact]
- public async Task EffectiveBarHeight_UsesPageOverride()
+ public async Task PushModal_WhenCoveredPageCancelsNavigating_DoesNotPushModal()
{
var nav = new NavigationPage();
- var page = new ContentPage();
- NavigationPage.SetBarHeightOverride(page, 60.0);
- await nav.PushAsync(page);
- Assert.Equal(60.0, nav.EffectiveBarHeight);
- }
+ var root = new ContentPage { Header = "Root" };
+ await nav.PushAsync(root);
- [Fact]
- public async Task EffectiveBarHeight_FallsBackToGlobalBarHeight()
- {
- var nav = new NavigationPage { BarHeight = 56.0 };
- await nav.PushAsync(new ContentPage());
- Assert.Equal(56.0, nav.EffectiveBarHeight);
+ bool handlerInvoked = false;
+ root.Navigating += args =>
+ {
+ handlerInvoked = true;
+ args.Cancel = true;
+ return Task.CompletedTask;
+ };
+
+ await nav.PushModalAsync(new ContentPage { Header = "Modal" });
+
+ Assert.True(handlerInvoked);
+ Assert.Empty(nav.ModalStack);
}
[Fact]
- public async Task BarLayoutBehavior_DefaultAppliesNavBarInsetPseudoClass()
+ public async Task PushModal_WhenTopModalCancelsNavigating_DoesNotPushAnotherModal()
{
var nav = new NavigationPage();
- await nav.PushAsync(new ContentPage());
- Assert.True(nav.Classes.Contains(":nav-bar-inset"));
+ await nav.PushAsync(new ContentPage { Header = "Root" });
+
+ var firstModal = new ContentPage { Header = "Modal 1" };
+ await nav.PushModalAsync(firstModal);
+
+ bool handlerInvoked = false;
+ firstModal.Navigating += args =>
+ {
+ handlerInvoked = true;
+ args.Cancel = true;
+ return Task.CompletedTask;
+ };
+
+ await nav.PushModalAsync(new ContentPage { Header = "Modal 2" });
+
+ Assert.True(handlerInvoked);
+ Assert.Single(nav.ModalStack);
+ Assert.Same(firstModal, nav.ModalStack[0]);
}
[Fact]
- public async Task BarLayoutBehavior_OverlayRemovesNavBarInsetPseudoClass()
+ public async Task PopModal_WhenModalCancelsNavigating_DoesNotPopModal()
{
var nav = new NavigationPage();
- var page = new ContentPage();
- NavigationPage.SetBarLayoutBehavior(page, BarLayoutBehavior.Overlay);
- await nav.PushAsync(page);
- Assert.False(nav.Classes.Contains(":nav-bar-inset"));
- }
- }
+ await nav.PushAsync(new ContentPage { Header = "Root" });
- public class InitialContentTests : ScopedTestBase
- {
- [Fact]
- public void Content_SetBeforePush_IsUsedAsInitialPage()
- {
+ var modal = new ContentPage { Header = "Modal" };
+ await nav.PushModalAsync(modal);
+
+ bool handlerInvoked = false;
+ modal.Navigating += args =>
+ {
+ handlerInvoked = true;
+ args.Cancel = true;
+ return Task.CompletedTask;
+ };
+
+ var result = await nav.PopModalAsync();
+
+ Assert.True(handlerInvoked);
+ Assert.Null(result);
+ Assert.Single(nav.ModalStack);
+ Assert.Same(modal, nav.ModalStack[0]);
+ }
+ }
+
+ public class SystemBackButtonTests : ScopedTestBase
+ {
+ private static RoutedEventArgs RaiseBackButton(NavigationPage nav)
+ {
+ var args = new RoutedEventArgs(Page.PageNavigationSystemBackButtonPressedEvent);
+ nav.RaiseEvent(args);
+ return args;
+ }
+
+ [Fact]
+ public async Task BackButton_WithModalOnRoot_PopsModal()
+ {
+ var nav = new NavigationPage();
+ var root = new ContentPage { Header = "Root" };
+ var modal = new ContentPage { Header = "Modal" };
+ await nav.PushAsync(root);
+ await nav.PushModalAsync(modal);
+
+ var args = RaiseBackButton(nav);
+
+ Assert.True(args.Handled);
+ Assert.Empty(nav.ModalStack);
+ Assert.Equal(1, nav.StackDepth);
+ Assert.Same(root, nav.CurrentPage);
+ }
+
+ [Fact]
+ public async Task BackButton_WithModalAndDeepStack_PopsModalBeforeStack()
+ {
+ var nav = new NavigationPage();
+ var root = new ContentPage { Header = "Root" };
+ var detail = new ContentPage { Header = "Detail" };
+ var modal = new ContentPage { Header = "Modal" };
+ await nav.PushAsync(root);
+ await nav.PushAsync(detail);
+ await nav.PushModalAsync(modal);
+
+ var args = RaiseBackButton(nav);
+
+ Assert.True(args.Handled);
+ Assert.Empty(nav.ModalStack);
+ Assert.Equal(2, nav.StackDepth);
+ Assert.Same(detail, nav.CurrentPage);
+ }
+
+ [Fact]
+ public async Task BackButton_ForwardsToTopModalBeforeAutoPop()
+ {
+ var nav = new NavigationPage();
+ await nav.PushAsync(new ContentPage { Header = "Root" });
+
+ var modal = new BackHandlingPage { HandleBack = true };
+ await nav.PushModalAsync(modal);
+
+ var args = RaiseBackButton(nav);
+
+ Assert.True(args.Handled);
+ Assert.Equal(1, modal.BackButtonPressCount);
+ Assert.Single(nav.ModalStack);
+ Assert.Same(modal, nav.ModalStack[0]);
+ }
+
+ private sealed class BackHandlingPage : ContentPage
+ {
+ public int BackButtonPressCount { get; private set; }
+
+ public bool HandleBack { get; set; }
+
+ protected override bool OnSystemBackButtonPressed()
+ {
+ BackButtonPressCount++;
+ return HandleBack;
+ }
+ }
+ }
+
+ public class PropertyTests : ScopedTestBase
+ {
+ [Fact]
+ public void IsGestureEnabled_Default_IsTrue()
+ {
+ var nav = new NavigationPage();
+ Assert.True(nav.IsGestureEnabled);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void IsGestureEnabled_RoundTrips(bool value)
+ {
+ var nav = new NavigationPage { IsGestureEnabled = value };
+ Assert.Equal(value, nav.IsGestureEnabled);
+ }
+ }
+
+ public class AttachedPropertyTests : ScopedTestBase
+ {
+ [Fact]
+ public async Task EffectiveBarHeight_UsesPageOverride()
+ {
+ var nav = new NavigationPage();
+ var page = new ContentPage();
+ NavigationPage.SetBarHeightOverride(page, 60.0);
+ await nav.PushAsync(page);
+ Assert.Equal(60.0, nav.EffectiveBarHeight);
+ }
+
+ [Fact]
+ public async Task EffectiveBarHeight_FallsBackToGlobalBarHeight()
+ {
+ var nav = new NavigationPage { BarHeight = 56.0 };
+ await nav.PushAsync(new ContentPage());
+ Assert.Equal(56.0, nav.EffectiveBarHeight);
+ }
+
+ [Fact]
+ public async Task BarLayoutBehavior_DefaultAppliesNavBarInsetPseudoClass()
+ {
+ var nav = new NavigationPage();
+ await nav.PushAsync(new ContentPage());
+ Assert.True(nav.Classes.Contains(":nav-bar-inset"));
+ }
+
+ [Fact]
+ public async Task BarLayoutBehavior_OverlayRemovesNavBarInsetPseudoClass()
+ {
+ var nav = new NavigationPage();
+ var page = new ContentPage();
+ NavigationPage.SetBarLayoutBehavior(page, BarLayoutBehavior.Overlay);
+ await nav.PushAsync(page);
+ Assert.False(nav.Classes.Contains(":nav-bar-inset"));
+ }
+ }
+
+ public class InitialContentTests : ScopedTestBase
+ {
+ [Fact]
+ public void Content_SetBeforePush_IsUsedAsInitialPage()
+ {
var page = new ContentPage { Header = "Initial" };
var nav = new NavigationPage { Content = page };
Assert.Equal(1, nav.StackDepth);
@@ -1094,6 +1581,49 @@ public class NavigationPageTests
Assert.True(handlerInvoked);
Assert.Equal(2, nav.StackDepth);
}
+
+ [Fact]
+ public async Task Navigating_CancelInFirstHandler_SkipsSubsequentHandlers()
+ {
+ var nav = new NavigationPage();
+ var root = new ContentPage();
+ await nav.PushAsync(root);
+ var top = new ContentPage();
+ await nav.PushAsync(top);
+
+ bool secondHandlerInvoked = false;
+ top.Navigating += args => { args.Cancel = true; return Task.CompletedTask; };
+ top.Navigating += args => { secondHandlerInvoked = true; return Task.CompletedTask; };
+
+ await nav.PopAsync();
+
+ Assert.False(secondHandlerInvoked);
+ Assert.Equal(2, nav.StackDepth);
+ }
+
+ [Fact]
+ public async Task Navigating_CancelInOnNavigatingFrom_SkipsNavigatingEvent()
+ {
+ var nav = new NavigationPage();
+ var root = new ContentPage();
+ await nav.PushAsync(root);
+
+ var top = new CancellingPage();
+ await nav.PushAsync(top);
+
+ bool asyncHandlerInvoked = false;
+ top.Navigating += args => { asyncHandlerInvoked = true; return Task.CompletedTask; };
+
+ await nav.PopAsync();
+
+ Assert.False(asyncHandlerInvoked);
+ Assert.Equal(2, nav.StackDepth);
+ }
+
+ private sealed class CancellingPage : ContentPage
+ {
+ protected override void OnNavigatingFrom(NavigatingFromEventArgs args) => args.Cancel = true;
+ }
}
public class LogicalChildrenTests : ScopedTestBase
@@ -1159,6 +1689,33 @@ public class NavigationPageTests
Assert.Contains(root, children);
Assert.Contains(second, children);
}
+
+ [Fact]
+ public async Task PushModal_ModalPageIsNotInDirectLogicalChildren()
+ {
+ var nav = new NavigationPage();
+ await nav.PushAsync(new ContentPage());
+ var modal = new ContentPage();
+ await nav.PushModalAsync(modal);
+
+ Assert.DoesNotContain(modal, nav.GetLogicalChildren());
+ Assert.Same(nav, modal.Navigation);
+ Assert.True(modal.IsInNavigationPage);
+ }
+
+ [Fact]
+ public async Task PopModal_ClearsNavigationAndIsInNavigationPage()
+ {
+ var nav = new NavigationPage();
+ await nav.PushAsync(new ContentPage());
+ var modal = new ContentPage();
+ await nav.PushModalAsync(modal);
+
+ await nav.PopModalAsync();
+
+ Assert.Null(modal.Navigation);
+ Assert.False(modal.IsInNavigationPage);
+ }
}
public class PopAllModalsTests : ScopedTestBase
@@ -1169,7 +1726,7 @@ public class NavigationPageTests
var nav = new NavigationPage();
await nav.PushAsync(new ContentPage());
- await nav.PopAllModalsAsync(); // must not throw
+ await nav.PopAllModalsAsync();
Assert.Equal(0, nav.ModalStack.Count);
}
@@ -1314,6 +1871,52 @@ public class NavigationPageTests
Assert.Same(m2, previousPage);
}
+
+ [Fact]
+ public async Task PopAllModals_MultipleModals_NavigatedTo_FiredOnlyOnBaseCurrentPage()
+ {
+ var nav = new NavigationPage();
+ var root = new ContentPage { Header = "Root" };
+ await nav.PushAsync(root);
+ var m1 = new ContentPage { Header = "M1" };
+ var m2 = new ContentPage { Header = "M2" };
+ await nav.PushModalAsync(m1);
+ await nav.PushModalAsync(m2);
+
+ var navigatedToPages = new List();
+ root.NavigatedTo += (_, _) => navigatedToPages.Add("root");
+ m1.NavigatedTo += (_, _) => navigatedToPages.Add("m1");
+ m2.NavigatedTo += (_, _) => navigatedToPages.Add("m2");
+
+ await nav.PopAllModalsAsync();
+
+ Assert.Equal(["root"], navigatedToPages);
+ }
+
+ [Fact]
+ public async Task PopAllModals_WhenTopModalCancelsNavigating_DoesNotClearModalStack()
+ {
+ var nav = new NavigationPage();
+ await nav.PushAsync(new ContentPage { Header = "Root" });
+
+ var m1 = new ContentPage { Header = "M1" };
+ var m2 = new ContentPage { Header = "M2" };
+ await nav.PushModalAsync(m1);
+ await nav.PushModalAsync(m2);
+
+ bool handlerInvoked = false;
+ m2.Navigating += args =>
+ {
+ handlerInvoked = true;
+ args.Cancel = true;
+ return Task.CompletedTask;
+ };
+
+ await nav.PopAllModalsAsync();
+
+ Assert.True(handlerInvoked);
+ Assert.Equal(new[] { m1, m2 }, nav.ModalStack);
+ }
}
public class ReplaceTests : ScopedTestBase
@@ -1353,6 +1956,51 @@ public class NavigationPageTests
Assert.DoesNotContain(root, nav.NavigationStack);
}
+ [Fact]
+ public async Task ReplaceAsync_WhenReplacementAlreadyExistsInNavigationStack_Throws()
+ {
+ var nav = new NavigationPage();
+ var root = new ContentPage { Header = "Root" };
+ var top = new ContentPage { Header = "Top" };
+ await nav.PushAsync(root);
+ await nav.PushAsync(top);
+
+ await Assert.ThrowsAsync(() => nav.ReplaceAsync(root));
+ }
+
+ [Fact]
+ public async Task ReplaceAsync_WhenReplacementAlreadyExistsInModalStack_Throws()
+ {
+ var nav = new NavigationPage();
+ await nav.PushAsync(new ContentPage { Header = "Root" });
+ await nav.PushAsync(new ContentPage { Header = "Top" });
+
+ var modal = new ContentPage { Header = "Modal" };
+ await nav.PushModalAsync(modal);
+
+ await Assert.ThrowsAsync(() => nav.ReplaceAsync(modal));
+ }
+
+ [Fact]
+ public async Task ReplaceAsync_WithCurrentPage_IsNoOp()
+ {
+ var nav = new NavigationPage();
+ var root = new ContentPage { Header = "Root" };
+ var top = new ContentPage { Header = "Top" };
+ await nav.PushAsync(root);
+ await nav.PushAsync(top);
+
+ var lifecycleEvents = 0;
+ top.NavigatedFrom += (_, _) => lifecycleEvents++;
+ top.NavigatedTo += (_, _) => lifecycleEvents++;
+
+ await nav.ReplaceAsync(top);
+
+ Assert.Equal(2, nav.StackDepth);
+ Assert.Same(top, nav.CurrentPage);
+ Assert.Equal(0, lifecycleEvents);
+ }
+
[Fact]
public async Task ReplaceAsync_FiresLifecycleEventsInOrder()
{
@@ -1524,6 +2172,25 @@ public class NavigationPageTests
var stranger = new ContentPage();
await Assert.ThrowsAsync(() => nav.PopToPageAsync(stranger));
}
+
+ [Fact]
+ public async Task PopToPageAsync_TargetIsAlreadyTop_IsNoOp()
+ {
+ var nav = new NavigationPage();
+ var root = new ContentPage();
+ var top = new ContentPage();
+ await nav.PushAsync(root);
+ await nav.PushAsync(top);
+
+ int navigatedToCount = 0;
+ top.NavigatedTo += (_, _) => navigatedToCount++;
+
+ await nav.PopToPageAsync(top);
+
+ Assert.Same(top, nav.CurrentPage);
+ Assert.Equal(2, nav.StackDepth);
+ Assert.Equal(0, navigatedToCount);
+ }
}
public class ModalTransitionCancellationTests : ScopedTestBase
@@ -1583,116 +2250,6 @@ public class NavigationPageTests
}
}
- public class SwipeGestureTests : ScopedTestBase
- {
- [Fact]
- public async Task HandledPointerPressedAtEdge_AllowsSwipePop()
- {
- var nav = new NavigationPage();
- var rootPage = new ContentPage { Header = "Root" };
- var topPage = new ContentPage { Header = "Top" };
-
- await nav.PushAsync(rootPage);
- await nav.PushAsync(topPage);
-
- var root = new TestRoot { Child = nav };
- root.ExecuteInitialLayoutPass();
-
- RaiseHandledPointerPressed(nav, new Point(5, 5));
-
- var swipe = new SwipeGestureEventArgs(1, new Vector(-20, 0), default);
- nav.RaiseEvent(swipe);
- Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
-
- Assert.True(swipe.Handled);
- Assert.Equal(1, nav.StackDepth);
- Assert.Same(rootPage, nav.CurrentPage);
- }
-
- [Fact]
- public async Task MouseEdgeDrag_AllowsSwipePop()
- {
- var nav = new NavigationPage
- {
- Width = 400,
- Height = 300
- };
- nav.GestureRecognizers.OfType().First().IsMouseEnabled = true;
- var rootPage = new ContentPage { Header = "Root" };
- var topPage = new ContentPage { Header = "Top" };
-
- await nav.PushAsync(rootPage);
- await nav.PushAsync(topPage);
-
- var root = new TestRoot
- {
- ClientSize = new Size(400, 300),
- Child = nav
- };
- root.ExecuteInitialLayoutPass();
-
- var mouse = new MouseTestHelper();
- mouse.Down(nav, position: new Point(5, 5));
- mouse.Move(nav, new Point(40, 5));
- mouse.Up(nav, position: new Point(40, 5));
- Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
-
- Assert.Equal(1, nav.StackDepth);
- Assert.Same(rootPage, nav.CurrentPage);
- }
-
- [Fact]
- public async Task SameGestureId_OnlyPops_One_Page()
- {
- var nav = new NavigationPage
- {
- Width = 400,
- Height = 300
- };
- var page1 = new ContentPage { Header = "1" };
- var page2 = new ContentPage { Header = "2" };
- var page3 = new ContentPage { Header = "3" };
-
- await nav.PushAsync(page1);
- await nav.PushAsync(page2);
- await nav.PushAsync(page3);
-
- var root = new TestRoot
- {
- ClientSize = new Size(400, 300),
- Child = nav
- };
- root.ExecuteInitialLayoutPass();
-
- RaiseHandledPointerPressed(nav, new Point(5, 5));
-
- nav.RaiseEvent(new SwipeGestureEventArgs(42, new Vector(-20, 0), default));
- nav.RaiseEvent(new SwipeGestureEventArgs(42, new Vector(-30, 0), default));
- Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
-
- Assert.Equal(2, nav.StackDepth);
- Assert.Same(page2, nav.CurrentPage);
- }
-
- private static void RaiseHandledPointerPressed(Interactive target, Point position)
- {
- var pointer = new Pointer(Pointer.GetNextFreeId(), PointerType.Touch, true);
- var args = new PointerPressedEventArgs(
- target,
- pointer,
- (Visual)target,
- position,
- timestamp: 1,
- new PointerPointProperties(RawInputModifiers.LeftMouseButton, PointerUpdateKind.LeftButtonPressed),
- KeyModifiers.None)
- {
- Handled = true
- };
-
- target.RaiseEvent(args);
- }
- }
-
public class LifecycleAfterTransitionTests : ScopedTestBase
{
[Fact]
@@ -1789,6 +2346,33 @@ public class NavigationPageTests
Assert.False(poppedToRootDuringTransition);
}
+ [Fact]
+ public async Task ReplaceAsync_LifecycleEvents_FireAfterTransition()
+ {
+ var tcs = new TaskCompletionSource();
+ var nav = CreateNavigationPage(null);
+
+ var root = new ContentPage { Header = "Root" };
+ await nav.PushAsync(root);
+
+ nav.PageTransition = new ControllableTransition(tcs.Task);
+
+ bool navigatedFromDuringTransition = false;
+ bool navigatedToDuringTransition = false;
+
+ var replacement = new ContentPage { Header = "Replacement" };
+ root.NavigatedFrom += (_, _) => navigatedFromDuringTransition = !tcs.Task.IsCompleted;
+ replacement.NavigatedTo += (_, _) => navigatedToDuringTransition = !tcs.Task.IsCompleted;
+
+ var replaceTask = nav.ReplaceAsync(replacement);
+
+ tcs.SetResult();
+ await replaceTask;
+
+ Assert.False(navigatedFromDuringTransition);
+ Assert.False(navigatedToDuringTransition);
+ }
+
private static NavigationPage CreateNavigationPage(IPageTransition? transition)
{
var nav = new NavigationPage
@@ -1853,4 +2437,366 @@ public class NavigationPageTests
}
}
+ public class SwipeGestureTests : ScopedTestBase
+ {
+ [Fact]
+ public async Task HandledPointerPressedAtEdge_AllowsSwipePop()
+ {
+ var nav = new NavigationPage();
+ var rootPage = new ContentPage { Header = "Root" };
+ var topPage = new ContentPage { Header = "Top" };
+
+ await nav.PushAsync(rootPage);
+ await nav.PushAsync(topPage);
+
+ var root = new TestRoot { Child = nav };
+ root.ExecuteInitialLayoutPass();
+
+ RaiseHandledPointerPressed(nav, new Point(5, 5));
+
+ var swipe = new SwipeGestureEventArgs(1, new Vector(-20, 0), default);
+ nav.RaiseEvent(swipe);
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ Assert.True(swipe.Handled);
+ Assert.Equal(1, nav.StackDepth);
+ Assert.Same(rootPage, nav.CurrentPage);
+ }
+
+ [Fact]
+ public async Task MouseEdgeDrag_AllowsSwipePop()
+ {
+ var nav = new NavigationPage
+ {
+ Width = 400,
+ Height = 300
+ };
+ nav.GestureRecognizers.OfType().First().IsMouseEnabled = true;
+ var rootPage = new ContentPage { Header = "Root" };
+ var topPage = new ContentPage { Header = "Top" };
+
+ await nav.PushAsync(rootPage);
+ await nav.PushAsync(topPage);
+
+ var root = new TestRoot
+ {
+ ClientSize = new Size(400, 300),
+ Child = nav
+ };
+ root.ExecuteInitialLayoutPass();
+
+ var mouse = new MouseTestHelper();
+ mouse.Down(nav, position: new Point(5, 5));
+ mouse.Move(nav, new Point(40, 5));
+ mouse.Up(nav, position: new Point(40, 5));
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ Assert.Equal(1, nav.StackDepth);
+ Assert.Same(rootPage, nav.CurrentPage);
+ }
+
+ [Fact]
+ public async Task SameGestureId_OnlyPops_One_Page()
+ {
+ var nav = new NavigationPage
+ {
+ Width = 400,
+ Height = 300
+ };
+ var page1 = new ContentPage { Header = "1" };
+ var page2 = new ContentPage { Header = "2" };
+ var page3 = new ContentPage { Header = "3" };
+
+ await nav.PushAsync(page1);
+ await nav.PushAsync(page2);
+ await nav.PushAsync(page3);
+
+ var root = new TestRoot
+ {
+ ClientSize = new Size(400, 300),
+ Child = nav
+ };
+ root.ExecuteInitialLayoutPass();
+
+ RaiseHandledPointerPressed(nav, new Point(5, 5));
+
+ nav.RaiseEvent(new SwipeGestureEventArgs(42, new Vector(-20, 0), default));
+ nav.RaiseEvent(new SwipeGestureEventArgs(42, new Vector(-30, 0), default));
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ Assert.Equal(2, nav.StackDepth);
+ Assert.Same(page2, nav.CurrentPage);
+ }
+
+ private static void RaiseHandledPointerPressed(Interactive target, Point position)
+ {
+ var pointer = new Pointer(Pointer.GetNextFreeId(), PointerType.Touch, true);
+ var args = new PointerPressedEventArgs(
+ target,
+ pointer,
+ (Visual)target,
+ position,
+ timestamp: 1,
+ new PointerPointProperties(RawInputModifiers.LeftMouseButton, PointerUpdateKind.LeftButtonPressed),
+ KeyModifiers.None)
+ {
+ Handled = true
+ };
+
+ target.RaiseEvent(args);
+ }
+ }
+
+ public class IsNavigatingTests : ScopedTestBase
+ {
+ [Fact]
+ public void IsNavigating_FalseByDefault()
+ {
+ var nav = new NavigationPage();
+ Assert.False(nav.IsNavigating);
+ }
+
+ [Fact]
+ public async Task IsNavigating_TrueWhilePushInProgress()
+ {
+ var tcs = new TaskCompletionSource();
+ var nav = CreateNavigationPage(new ControllableTransition(tcs.Task));
+
+ await nav.PushAsync(new ContentPage());
+
+ bool duringPush = false;
+ var pushTask = nav.PushAsync(new ContentPage());
+ duringPush = nav.IsNavigating;
+ tcs.SetResult(true);
+ await pushTask;
+
+ Assert.True(duringPush);
+ }
+
+ [Fact]
+ public async Task IsNavigating_FalseAfterPushCompletes()
+ {
+ var tcs = new TaskCompletionSource();
+ var nav = CreateNavigationPage(new ControllableTransition(tcs.Task));
+
+ await nav.PushAsync(new ContentPage());
+ var pushTask = nav.PushAsync(new ContentPage());
+ tcs.SetResult(true);
+ await pushTask;
+
+ Assert.False(nav.IsNavigating);
+ }
+
+ private static NavigationPage CreateNavigationPage(IPageTransition? transition)
+ {
+ var nav = new NavigationPage
+ {
+ PageTransition = transition,
+ Template = new FuncControlTemplate((parent, ns) =>
+ {
+ return new Panel
+ {
+ Children =
+ {
+ new Panel
+ {
+ Name = "PART_ContentHost",
+ Children =
+ {
+ new ContentPresenter { Name = "PART_PageBackPresenter" }.RegisterInNameScope(ns),
+ new ContentPresenter { Name = "PART_PagePresenter" }.RegisterInNameScope(ns),
+ }
+ }.RegisterInNameScope(ns),
+ new Border { Name = "PART_NavigationBar",
+ Child = new Button { Name = "PART_BackButton" }.RegisterInNameScope(ns) }.RegisterInNameScope(ns),
+ new ContentPresenter { Name = "PART_TopCommandBar" }.RegisterInNameScope(ns),
+ new ContentPresenter { Name = "PART_ModalBackPresenter" }.RegisterInNameScope(ns),
+ new ContentPresenter { Name = "PART_ModalPresenter" }.RegisterInNameScope(ns),
+ }
+ };
+ })
+ };
+ var root = new TestRoot { Child = nav };
+ root.LayoutManager.ExecuteInitialLayoutPass();
+ return nav;
+ }
+
+ private class ControllableTransition : IPageTransition
+ {
+ private readonly Task _gate;
+
+ public ControllableTransition(Task gate)
+ {
+ _gate = gate;
+ }
+
+ public async Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken)
+ {
+ if (to != null)
+ to.IsVisible = true;
+ await _gate;
+ if (from != null)
+ from.IsVisible = false;
+ }
+ }
+ }
+
+ public class VisualTreeLifecycleTests : ScopedTestBase
+ {
+ [Fact]
+ public async Task Detach_And_Reattach_PreservesModalStack()
+ {
+ var nav = new NavigationPage
+ {
+ Template = new FuncControlTemplate((parent, ns) =>
+ {
+ return new Panel
+ {
+ Children =
+ {
+ new Panel
+ {
+ Name = "PART_ContentHost",
+ Children =
+ {
+ new ContentPresenter { Name = "PART_PageBackPresenter" }.RegisterInNameScope(ns),
+ new ContentPresenter { Name = "PART_PagePresenter" }.RegisterInNameScope(ns),
+ }
+ }.RegisterInNameScope(ns),
+ new Border
+ {
+ Name = "PART_NavigationBar",
+ Child = new Button { Name = "PART_BackButton" }.RegisterInNameScope(ns)
+ }.RegisterInNameScope(ns),
+ new ContentPresenter { Name = "PART_TopCommandBar" }.RegisterInNameScope(ns),
+ new ContentPresenter { Name = "PART_ModalBackPresenter" }.RegisterInNameScope(ns),
+ new ContentPresenter { Name = "PART_ModalPresenter" }.RegisterInNameScope(ns),
+ }
+ };
+ })
+ };
+
+ var root = new TestRoot { Child = nav };
+ root.LayoutManager.ExecuteInitialLayoutPass();
+
+ var page = new ContentPage { Header = "Root" };
+ var modal = new ContentPage { Header = "Modal" };
+
+ await nav.PushAsync(page);
+ await nav.PushModalAsync(modal);
+
+ root.Child = null;
+
+ Assert.Single(nav.ModalStack);
+ Assert.Same(modal, nav.ModalStack[0]);
+ Assert.Null(modal.Navigation);
+ Assert.False(modal.IsInNavigationPage);
+
+ root.Child = nav;
+ root.LayoutManager.ExecuteInitialLayoutPass();
+
+ Assert.Single(nav.ModalStack);
+ Assert.Same(modal, nav.ModalStack[0]);
+ Assert.Same(nav, modal.Navigation);
+ Assert.True(modal.IsInNavigationPage);
+ }
+ }
+
+ public class PagesPropertyTests : ScopedTestBase
+ {
+ [Fact]
+ public void Pages_DirectAssignment_ThrowsInvalidOperationException()
+ {
+ var nav = new NavigationPage();
+ var originalPages = nav.Pages;
+
+ Assert.Throws(() => nav.Pages = new List { new ContentPage() });
+
+ Assert.Same(originalPages, nav.Pages);
+ Assert.Empty(nav.NavigationStack);
+ Assert.Null(nav.CurrentPage);
+ }
+
+ [Fact]
+ public void Pages_AssignNull_ThrowsInvalidOperationException()
+ {
+ var nav = new NavigationPage();
+ var originalPages = nav.Pages;
+
+ Assert.Throws(() => nav.Pages = null);
+
+ Assert.Same(originalPages, nav.Pages);
+ Assert.Empty(nav.NavigationStack);
+ Assert.Null(nav.CurrentPage);
+ }
+
+ [Fact]
+ public async Task Pages_DirectAssignment_DoesNotCorruptExistingStack()
+ {
+ var nav = new NavigationPage();
+ var root = new ContentPage();
+ var top = new ContentPage();
+ await nav.PushAsync(root);
+ await nav.PushAsync(top);
+ var originalPages = nav.Pages;
+
+ Assert.Throws(() => nav.Pages = new List { new ContentPage() });
+
+ Assert.Same(originalPages, nav.Pages);
+ Assert.Equal(2, nav.NavigationStack.Count);
+ Assert.Same(root, nav.NavigationStack[0]);
+ Assert.Same(top, nav.NavigationStack[1]);
+ Assert.Same(top, nav.CurrentPage);
+ }
+ }
+
+ public class ContentPageCoerceTests : ScopedTestBase
+ {
+ [Fact]
+ public void ContentPage_ContentSetToPage_ThrowsInvalidOperationException()
+ {
+ var page = new ContentPage();
+ Assert.Throws(() => page.Content = new ContentPage());
+ }
+
+ [Fact]
+ public void ContentPage_ContentSetToNonPage_DoesNotThrow()
+ {
+ var page = new ContentPage();
+ page.Content = "hello";
+ Assert.Equal("hello", page.Content);
+ }
+ }
+
+ public class DrawerPageFirstPageTests : ScopedTestBase
+ {
+ [Fact]
+ public void DrawerPage_ContentReplaced_ResendsNavigatedToOnLoad()
+ {
+ var root = new TestRoot();
+ var nav1 = new NavigationPage();
+ var page1 = new ContentPage();
+ var drawer = new DrawerPage { Content = nav1 };
+ root.Child = drawer;
+ root.LayoutManager.ExecuteInitialLayoutPass();
+
+ NavigatedToEventArgs? firstArgs = null;
+ page1.NavigatedTo += (_, e) => firstArgs = e;
+ nav1.Content = page1;
+
+ Assert.NotNull(firstArgs);
+
+ var nav2 = new NavigationPage();
+ var page2 = new ContentPage();
+
+ NavigatedToEventArgs? secondArgs = null;
+ page2.NavigatedTo += (_, e) => secondArgs = e;
+
+ drawer.Content = nav2;
+ nav2.Content = page2;
+
+ Assert.NotNull(secondArgs);
+ }
+ }
+
}
diff --git a/tests/Avalonia.Controls.UnitTests/TabbedPageTests.cs b/tests/Avalonia.Controls.UnitTests/TabbedPageTests.cs
index 9034161e39..3fa34e0f2b 100644
--- a/tests/Avalonia.Controls.UnitTests/TabbedPageTests.cs
+++ b/tests/Avalonia.Controls.UnitTests/TabbedPageTests.cs
@@ -4,10 +4,12 @@ using System.Collections.Specialized;
using System.Linq;
using Avalonia.Animation;
using Avalonia.Collections;
+using Avalonia.Controls.Presenters;
using Avalonia.Controls.Shapes;
using Avalonia.Controls.Templates;
using Avalonia.Input;
using Avalonia.Input.GestureRecognizers;
+using Avalonia.Interactivity;
using Avalonia.LogicalTree;
using Avalonia.Media;
using Avalonia.Threading;
@@ -185,6 +187,18 @@ public class TabbedPageTests
Assert.DoesNotContain(page, ((ILogical)tp).LogicalChildren);
}
+ [Fact]
+ public void Pages_SetNull_ClearsCurrentPage()
+ {
+ var tp = new TestableTabbedPage();
+ var page = new ContentPage();
+ tp.Pages = new AvaloniaList { page };
+ tp.CallCommitSelection(0, page);
+ Assert.NotNull(tp.CurrentPage);
+ tp.Pages = null;
+ Assert.Null(tp.CurrentPage);
+ }
+
[Fact]
public void Pages_AddMultiple_AllBecomeLogicalChildren()
{
@@ -686,6 +700,194 @@ public class TabbedPageTests
}
}
+ public class KeyboardNavigationTests : ScopedTestBase
+ {
+ [Fact]
+ public void IsKeyboardNavigationEnabled_Default_IsTrue()
+ {
+ var tp = new TabbedPage();
+ Assert.True(tp.IsKeyboardNavigationEnabled);
+ }
+
+ [Fact]
+ public void IsKeyboardNavigationEnabled_False_RightKey_IsNotHandled()
+ {
+ var tp = new TestableTabbedPage { IsKeyboardNavigationEnabled = false };
+ tp.Pages = new AvaloniaList { new ContentPage(), new ContentPage() };
+ tp.SelectedIndex = 0;
+
+ bool handled = tp.SimulateKeyDownReturnsHandled(Key.Right);
+
+ Assert.False(handled);
+ }
+
+ [Fact]
+ public void IsKeyboardNavigationEnabled_False_CtrlTab_IsNotHandled()
+ {
+ var tp = new TestableTabbedPage { IsKeyboardNavigationEnabled = false };
+ tp.Pages = new AvaloniaList { new ContentPage(), new ContentPage() };
+ tp.SelectedIndex = 0;
+
+ bool handled = tp.SimulateKeyDownWithModifiersReturnsHandled(Key.Tab, KeyModifiers.Control);
+
+ Assert.False(handled);
+ }
+
+ [Fact]
+ public void IsKeyboardNavigationEnabled_True_NoTemplate_KeyIsNotHandled()
+ {
+ var tp = new TestableTabbedPage { IsKeyboardNavigationEnabled = true };
+ tp.Pages = new AvaloniaList { new ContentPage(), new ContentPage() };
+
+ bool handled = tp.SimulateKeyDownReturnsHandled(Key.Right);
+
+ Assert.False(handled);
+ }
+ }
+
+ public class KeyboardNavigationWithTemplateTests : ScopedTestBase
+ {
+ // Builds a TabbedPage with a real PART_TabControl wired up so OnKeyDown can navigate.
+ private static TestableTabbedPage MakeTabbed(int pageCount, int selectedIndex = 0,
+ TabPlacement placement = TabPlacement.Top)
+ {
+ var tp = new TestableTabbedPage { TabPlacement = placement };
+ for (int i = 0; i < pageCount; i++)
+ ((AvaloniaList)tp.Pages!).Add(new ContentPage { Header = $"Tab {i}" });
+
+ tp.Template = new FuncControlTemplate((parent, scope) =>
+ new TabControl
+ {
+ Name = "PART_TabControl",
+ ItemsSource = parent.Pages,
+ }.RegisterInNameScope(scope));
+
+ _ = new TestRoot { Child = tp };
+ tp.ApplyTemplate();
+ tp.SelectedIndex = selectedIndex;
+ return tp;
+ }
+
+ [Fact]
+ public void RightKey_NavigatesToNextPage()
+ {
+ var tp = MakeTabbed(3, selectedIndex: 0);
+ tp.SimulateKeyDown(Key.Right);
+ Assert.Equal(1, tp.SelectedIndex);
+ }
+
+ [Fact]
+ public void LeftKey_NavigatesToPreviousPage()
+ {
+ var tp = MakeTabbed(3, selectedIndex: 1);
+ tp.SimulateKeyDown(Key.Left);
+ Assert.Equal(0, tp.SelectedIndex);
+ }
+
+ [Fact]
+ public void DownKey_WithVerticalPlacement_NavigatesToNextPage()
+ {
+ var tp = MakeTabbed(3, selectedIndex: 0, placement: TabPlacement.Left);
+ tp.SimulateKeyDown(Key.Down);
+ Assert.Equal(1, tp.SelectedIndex);
+ }
+
+ [Fact]
+ public void UpKey_WithVerticalPlacement_NavigatesToPreviousPage()
+ {
+ var tp = MakeTabbed(3, selectedIndex: 1, placement: TabPlacement.Left);
+ tp.SimulateKeyDown(Key.Up);
+ Assert.Equal(0, tp.SelectedIndex);
+ }
+
+ [Fact]
+ public void RightKey_AtLastPage_DoesNotNavigate()
+ {
+ var tp = MakeTabbed(3, selectedIndex: 2);
+ tp.SimulateKeyDown(Key.Right);
+ Assert.Equal(2, tp.SelectedIndex);
+ }
+
+ [Fact]
+ public void LeftKey_AtFirstPage_DoesNotNavigate()
+ {
+ var tp = MakeTabbed(3, selectedIndex: 0);
+ tp.SimulateKeyDown(Key.Left);
+ Assert.Equal(0, tp.SelectedIndex);
+ }
+
+ [Fact]
+ public void RightKey_MarksEventHandled()
+ {
+ var tp = MakeTabbed(3, selectedIndex: 0);
+ bool handled = tp.SimulateKeyDownReturnsHandled(Key.Right);
+ Assert.True(handled);
+ }
+
+ [Fact]
+ public void RightKey_AtLastPage_DoesNotMarkEventHandled()
+ {
+ var tp = MakeTabbed(3, selectedIndex: 2);
+ bool handled = tp.SimulateKeyDownReturnsHandled(Key.Right);
+ Assert.False(handled);
+ }
+
+ [Fact]
+ public void CtrlTab_NavigatesToNextPage()
+ {
+ var tp = MakeTabbed(3, selectedIndex: 0);
+ bool handled = tp.SimulateKeyDownWithModifiersReturnsHandled(Key.Tab, KeyModifiers.Control);
+ Assert.Equal(1, tp.SelectedIndex);
+ Assert.True(handled);
+ }
+
+ [Fact]
+ public void CtrlShiftTab_NavigatesToPreviousPage()
+ {
+ var tp = MakeTabbed(3, selectedIndex: 1);
+ bool handled = tp.SimulateKeyDownWithModifiersReturnsHandled(Key.Tab, KeyModifiers.Control | KeyModifiers.Shift);
+ Assert.Equal(0, tp.SelectedIndex);
+ Assert.True(handled);
+ }
+
+ [Fact]
+ public void RtlFlowDirection_LeftKey_NavigatesToNextPage()
+ {
+ var tp = MakeTabbed(3, selectedIndex: 0);
+ tp.FlowDirection = FlowDirection.RightToLeft;
+ tp.SimulateKeyDown(Key.Left);
+ Assert.Equal(1, tp.SelectedIndex);
+ }
+
+ [Fact]
+ public void RtlFlowDirection_RightKey_NavigatesToPreviousPage()
+ {
+ var tp = MakeTabbed(3, selectedIndex: 1);
+ tp.FlowDirection = FlowDirection.RightToLeft;
+ tp.SimulateKeyDown(Key.Right);
+ Assert.Equal(0, tp.SelectedIndex);
+ }
+
+ [Fact]
+ public void RightKey_SkipsDisabledTab()
+ {
+ var tp = MakeTabbed(3, selectedIndex: 0);
+ TabbedPage.SetIsTabEnabled((Page)((System.Collections.IList)tp.Pages!)[1]!, false);
+ tp.SimulateKeyDown(Key.Right);
+ Assert.Equal(2, tp.SelectedIndex);
+ }
+
+ [Fact]
+ public void RightKey_AllTabsAhead_Disabled_DoesNotNavigate()
+ {
+ var tp = MakeTabbed(3, selectedIndex: 0);
+ TabbedPage.SetIsTabEnabled((Page)((System.Collections.IList)tp.Pages!)[1]!, false);
+ TabbedPage.SetIsTabEnabled((Page)((System.Collections.IList)tp.Pages!)[2]!, false);
+ tp.SimulateKeyDown(Key.Right);
+ Assert.Equal(0, tp.SelectedIndex);
+ }
+ }
+
public class SelectingMultiPageTests : ScopedTestBase
{
[Fact]
@@ -711,13 +913,13 @@ public class TabbedPageTests
}
}
- public class CreateIconControlTests : ScopedTestBase
+ public class IconTests : ScopedTestBase
{
[Fact]
public void Geometry_ReturnsPath()
{
var geometry = new EllipseGeometry { Rect = new Rect(0, 0, 10, 10) };
- var result = TabbedPage.CreateIconControl(geometry);
+ var result = TabbedPage.CreateIconContent(geometry);
Assert.IsType(result);
Assert.Same(geometry, ((Path)result!).Data);
}
@@ -727,7 +929,7 @@ public class TabbedPageTests
{
var geometry = new EllipseGeometry { Rect = new Rect(0, 0, 10, 10) };
var pathIcon = new PathIcon { Data = geometry };
- var result = TabbedPage.CreateIconControl(pathIcon);
+ var result = TabbedPage.CreateIconContent(pathIcon);
Assert.IsType(result);
Assert.Same(geometry, ((Path)result!).Data);
}
@@ -735,21 +937,21 @@ public class TabbedPageTests
[Fact]
public void EmptyString_ReturnsNull()
{
- var result = TabbedPage.CreateIconControl("");
+ var result = TabbedPage.CreateIconContent("");
Assert.Null(result);
}
[Fact]
public void NullString_ReturnsNull()
{
- var result = TabbedPage.CreateIconControl((string?)null);
+ var result = TabbedPage.CreateIconContent((string?)null);
Assert.Null(result);
}
[Fact]
public void Null_ReturnsNull()
{
- var result = TabbedPage.CreateIconControl(null);
+ var result = TabbedPage.CreateIconContent(null);
Assert.Null(result);
}
@@ -759,7 +961,7 @@ public class TabbedPageTests
var geometry = new EllipseGeometry { Rect = new Rect(0, 0, 10, 10) };
var drawing = new GeometryDrawing { Geometry = geometry };
var drawingImage = new DrawingImage(drawing);
- var result = TabbedPage.CreateIconControl(drawingImage);
+ var result = TabbedPage.CreateIconContent(drawingImage);
Assert.IsType(result);
Assert.Same(geometry, ((Path)result!).Data);
}
@@ -768,14 +970,53 @@ public class TabbedPageTests
public void Path_HasStretchUniform()
{
var geometry = new EllipseGeometry { Rect = new Rect(0, 0, 10, 10) };
- var result = TabbedPage.CreateIconControl(geometry);
+ var result = TabbedPage.CreateIconContent(geometry);
Assert.Equal(Stretch.Uniform, ((Path)result!).Stretch);
}
+ [Fact]
+ public void Image_ReturnsImage()
+ {
+ var image = new TestImage();
+ var result = TabbedPage.CreateIconContent(image);
+ Assert.IsType(result);
+ Assert.Same(image, ((Image)result!).Source);
+ }
+
+ private sealed class TestImage : IImage
+ {
+ public Size Size => new Size(1, 1);
+ public void Draw(DrawingContext context, Rect sourceRect, Rect destRect) { }
+ }
+
+ [Fact]
+ public void Template_BuildsControl()
+ {
+ var template = new FuncTemplate(() => new Border());
+ var result = TabbedPage.CreateIconContent(template);
+ Assert.IsType(result);
+ }
+
+ [Fact]
+ public void Template_BuildsSeparateInstances()
+ {
+ var template = new FuncTemplate(() => new Border());
+ var first = TabbedPage.CreateIconContent(template);
+ var second = TabbedPage.CreateIconContent(template);
+ Assert.NotSame(first, second);
+ }
+
+ [Fact]
+ public void NonEmptyString_ReturnsNull()
+ {
+ var result = TabbedPage.CreateIconContent("M10 20v-6h4v6");
+ Assert.Null(result);
+ }
+
[Fact]
public void UnsupportedType_ReturnsNull()
{
- var result = TabbedPage.CreateIconControl(42);
+ var result = TabbedPage.CreateIconContent(42);
Assert.Null(result);
}
}
@@ -811,6 +1052,348 @@ public class TabbedPageTests
var built = tp.PageTemplate!.Build(new DataItem("Test"));
Assert.IsType(built);
}
+
+ [Fact]
+ public void ViewModelItems_WithPageTemplate_BuildContainersAsContentPages()
+ {
+ var items = new System.Collections.ObjectModel.ObservableCollection
+ {
+ new("Electronics"),
+ new("Books"),
+ };
+
+ var tp = new TabbedPage
+ {
+ Width = 400, Height = 300,
+ ItemsSource = items,
+ PageTemplate = new FuncDataTemplate(
+ (item, _) => new ContentPage { Header = item!.Name }, supportsRecycling: false),
+ Template = CreateTabbedPageTemplate(),
+ };
+
+ var root = new TestRoot { ClientSize = new Size(400, 300), Child = tp };
+ root.ExecuteInitialLayoutPass();
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ var logicals = ((ILogical)tp).LogicalChildren;
+ Assert.Contains(logicals, l => l is ContentPage cp && cp.Header?.ToString() == "Electronics");
+ Assert.Contains(logicals, l => l is ContentPage cp && cp.Header?.ToString() == "Books");
+ }
+
+ [Fact]
+ public void ItemsSource_SelectedPage_IsNotNullAfterContainersRealized()
+ {
+ var items = new System.Collections.ObjectModel.ObservableCollection
+ {
+ new("First"),
+ new("Second"),
+ };
+
+ var tp = new TabbedPage
+ {
+ Width = 400, Height = 300,
+ ItemsSource = items,
+ PageTemplate = new FuncDataTemplate(
+ (item, _) => new ContentPage { Header = item!.Name }, supportsRecycling: false),
+ Template = CreateTabbedPageTemplate(),
+ };
+
+ var root = new TestRoot { ClientSize = new Size(400, 300), Child = tp };
+ root.ExecuteInitialLayoutPass();
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ Assert.NotNull(tp.SelectedPage);
+ Assert.Equal(0, tp.SelectedIndex);
+ Assert.IsType(tp.SelectedPage);
+ Assert.Equal("First", tp.SelectedPage!.Header?.ToString());
+ }
+
+ [Fact]
+ public void NonListItemsSource_SelectedPage_And_AutomationName_AreResolved()
+ {
+ var tp = new TabbedPage
+ {
+ Width = 400, Height = 300,
+ ItemsSource = EnumerateItems(new("First"), new("Second")),
+ PageTemplate = new FuncDataTemplate(
+ (item, _) => new ContentPage { Header = item!.Name }, supportsRecycling: false),
+ Template = CreateTabbedPageTemplate(),
+ };
+
+ var root = new TestRoot { ClientSize = new Size(400, 300), Child = tp };
+ root.ExecuteInitialLayoutPass();
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ Assert.NotNull(tp.SelectedPage);
+ Assert.Equal("First", tp.SelectedPage!.Header?.ToString());
+ Assert.Equal("Tab 1 of 2: First", new Avalonia.Automation.Peers.TabbedPageAutomationPeer(tp).GetName());
+
+ tp.SelectedIndex = 1;
+ root.LayoutManager.ExecuteLayoutPass();
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ Assert.NotNull(tp.SelectedPage);
+ Assert.Equal("Second", tp.SelectedPage!.Header?.ToString());
+ Assert.Equal("Tab 2 of 2: Second", new Avalonia.Automation.Peers.TabbedPageAutomationPeer(tp).GetName());
+ }
+
+ [Fact]
+ public void ItemsSource_SelectionChanged_ReportsCorrectPage()
+ {
+ var items = new System.Collections.ObjectModel.ObservableCollection
+ {
+ new("Alpha"),
+ new("Beta"),
+ };
+
+ var tp = new TabbedPage
+ {
+ Width = 400, Height = 300,
+ ItemsSource = items,
+ PageTemplate = new FuncDataTemplate(
+ (item, _) => new ContentPage { Header = item!.Name }, supportsRecycling: false),
+ Template = CreateTabbedPageTemplate(),
+ };
+
+ var root = new TestRoot { ClientSize = new Size(400, 300), Child = tp };
+ root.ExecuteInitialLayoutPass();
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ Page? reportedPage = null;
+ tp.SelectionChanged += (_, e) => reportedPage = e.CurrentPage;
+
+ tp.SelectedIndex = 1;
+ root.LayoutManager.ExecuteLayoutPass();
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ Assert.NotNull(tp.SelectedPage);
+ Assert.Equal(1, tp.SelectedIndex);
+ Assert.Equal("Beta", tp.SelectedPage!.Header?.ToString());
+ Assert.NotNull(reportedPage);
+ Assert.Equal("Beta", reportedPage!.Header?.ToString());
+ }
+
+ [Fact]
+ public void ViewModelItems_RemovedFromCollection_TemplateCreatedPageRemovedFromLogicalChildren()
+ {
+ var items = new System.Collections.ObjectModel.ObservableCollection
+ {
+ new("A"),
+ new("B"),
+ };
+
+ var tp = new TabbedPage
+ {
+ Width = 400, Height = 300,
+ ItemsSource = items,
+ PageTemplate = new FuncDataTemplate(
+ (item, _) => new ContentPage { Header = item!.Name }, supportsRecycling: false),
+ Template = CreateTabbedPageTemplate(),
+ };
+
+ var root = new TestRoot { ClientSize = new Size(400, 300), Child = tp };
+ root.ExecuteInitialLayoutPass();
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ items.RemoveAt(1);
+ root.LayoutManager.ExecuteLayoutPass();
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ var logicals = ((ILogical)tp).LogicalChildren;
+ Assert.DoesNotContain(logicals, l => l is ContentPage cp && cp.Header?.ToString() == "B");
+ }
+
+ [Fact]
+ public void ItemsSource_Replaced_NoPhantomLogicalChildren()
+ {
+ var first = new System.Collections.ObjectModel.ObservableCollection
+ {
+ new("One"),
+ new("Two"),
+ };
+ var second = new System.Collections.ObjectModel.ObservableCollection
+ {
+ new("Three"),
+ new("Four"),
+ };
+
+ var tp = new TabbedPage
+ {
+ Width = 400, Height = 300,
+ ItemsSource = first,
+ PageTemplate = new FuncDataTemplate(
+ (item, _) => new ContentPage { Header = item!.Name }, supportsRecycling: false),
+ Template = CreateTabbedPageTemplate(),
+ };
+
+ var root = new TestRoot { ClientSize = new Size(400, 300), Child = tp };
+ root.ExecuteInitialLayoutPass();
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ tp.ItemsSource = second;
+ root.LayoutManager.ExecuteLayoutPass();
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ var logicals = ((ILogical)tp).LogicalChildren;
+ Assert.DoesNotContain(logicals, l => l is ContentPage cp && cp.Header?.ToString() == "One");
+ Assert.DoesNotContain(logicals, l => l is ContentPage cp && cp.Header?.ToString() == "Two");
+ Assert.Contains(logicals, l => l is ContentPage cp && cp.Header?.ToString() == "Three");
+ Assert.Contains(logicals, l => l is ContentPage cp && cp.Header?.ToString() == "Four");
+ }
+
+ [Fact]
+ public void PageTemplate_ChangedAfterContainersRealized_RebuildsExistingContainers()
+ {
+ var items = new System.Collections.ObjectModel.ObservableCollection
+ {
+ new("X"),
+ new("Y"),
+ };
+
+ var tp = new TabbedPage
+ {
+ Width = 400, Height = 300,
+ ItemsSource = items,
+ PageTemplate = new FuncDataTemplate(
+ (item, _) => new ContentPage { Header = "old-" + item!.Name }, supportsRecycling: false),
+ Template = CreateTabbedPageTemplate(),
+ };
+
+ var root = new TestRoot { ClientSize = new Size(400, 300), Child = tp };
+ root.ExecuteInitialLayoutPass();
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ tp.PageTemplate = new FuncDataTemplate(
+ (item, _) => new ContentPage { Header = "new-" + item!.Name }, supportsRecycling: false);
+ root.LayoutManager.ExecuteLayoutPass();
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ var logicals = ((ILogical)tp).LogicalChildren;
+ Assert.DoesNotContain(logicals, l => l is ContentPage cp && cp.Header?.ToString()?.StartsWith("old-") == true);
+ Assert.Contains(logicals, l => l is ContentPage cp && cp.Header?.ToString() == "new-X");
+ Assert.Contains(logicals, l => l is ContentPage cp && cp.Header?.ToString() == "new-Y");
+ }
+
+ [Fact]
+ public void PageTemplate_ChangedAfterContainersRealized_UpdatesSelectedPage()
+ {
+ var items = new System.Collections.ObjectModel.ObservableCollection
+ {
+ new("A"),
+ new("B"),
+ };
+
+ var tp = new TabbedPage
+ {
+ Width = 400, Height = 300,
+ ItemsSource = items,
+ PageTemplate = new FuncDataTemplate(
+ (item, _) => new ContentPage { Header = "old-" + item!.Name }, supportsRecycling: false),
+ Template = CreateTabbedPageTemplate(),
+ };
+
+ var root = new TestRoot { ClientSize = new Size(400, 300), Child = tp };
+ root.ExecuteInitialLayoutPass();
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ tp.PageTemplate = new FuncDataTemplate(
+ (item, _) => new ContentPage { Header = "new-" + item!.Name }, supportsRecycling: false);
+ root.LayoutManager.ExecuteLayoutPass();
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ Assert.NotNull(tp.SelectedPage);
+ Assert.Equal("new-A", tp.SelectedPage!.Header?.ToString());
+ }
+
+ [Fact]
+ public void ReapplyingTemplate_DoesNotLeavePhantomLogicalChildren()
+ {
+ var items = new System.Collections.ObjectModel.ObservableCollection
+ {
+ new("A"),
+ new("B"),
+ };
+
+ var tp = new TabbedPage
+ {
+ Width = 400, Height = 300,
+ ItemsSource = items,
+ PageTemplate = new FuncDataTemplate(
+ (item, _) => new ContentPage { Header = item!.Name }, supportsRecycling: false),
+ Template = CreateTabbedPageTemplate(),
+ };
+
+ var root = new TestRoot { ClientSize = new Size(400, 300), Child = tp };
+ root.ExecuteInitialLayoutPass();
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ var firstSelectedPage = tp.SelectedPage;
+
+ tp.Template = CreateTabbedPageTemplate();
+ root.LayoutManager.ExecuteLayoutPass();
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ Assert.Equal(2, ((ILogical)tp).LogicalChildren.Count);
+ Assert.DoesNotContain(firstSelectedPage!, ((ILogical)tp).LogicalChildren);
+ }
+
+ [Fact]
+ public void PageTemplate_SetToNullAfterContainersRealized_ClearsGeneratedPages()
+ {
+ var items = new System.Collections.ObjectModel.ObservableCollection
+ {
+ new("A"),
+ new("B"),
+ };
+
+ var tp = new TabbedPage
+ {
+ Width = 400, Height = 300,
+ ItemsSource = items,
+ PageTemplate = new FuncDataTemplate(
+ (item, _) => new ContentPage { Header = item!.Name }, supportsRecycling: false),
+ Template = CreateTabbedPageTemplate(),
+ };
+
+ var root = new TestRoot { ClientSize = new Size(400, 300), Child = tp };
+ root.ExecuteInitialLayoutPass();
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ var originalSelectedPage = tp.SelectedPage;
+
+ tp.PageTemplate = null;
+ root.LayoutManager.ExecuteLayoutPass();
+ Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
+
+ Assert.Null(tp.SelectedPage);
+ Assert.Null(tp.CurrentPage);
+ Assert.Empty(((ILogical)tp).LogicalChildren);
+ Assert.DoesNotContain(originalSelectedPage!, ((ILogical)tp).LogicalChildren);
+ }
+
+ private static FuncControlTemplate CreateTabbedPageTemplate()
+ {
+ return new FuncControlTemplate((parent, scope) =>
+ {
+ var tc = new TabControl
+ {
+ Name = "PART_TabControl",
+ Template = new FuncControlTemplate((_, tcScope) =>
+ new ItemsPresenter
+ {
+ Name = "PART_ItemsPresenter",
+ }.RegisterInNameScope(tcScope)),
+ };
+ tc.RegisterInNameScope(scope);
+ return tc;
+ });
+ }
+
+ private static IEnumerable EnumerateItems(params DataItem[] items)
+ {
+ foreach (var item in items)
+ yield return item;
+ }
}
public class SwipeGestureTests : ScopedTestBase
@@ -905,5 +1488,47 @@ public class TabbedPageTests
public int CallFindNextEnabledTab(int start, int dir) => FindNextEnabledTab(start, dir);
protected override void ApplySelectedIndex(int index) => base.ApplySelectedIndex(index);
+
+ public void SimulateKeyDown(Key key)
+ {
+ var e = new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = key };
+ OnKeyDown(e);
+ }
+
+ public bool SimulateKeyDownReturnsHandled(Key key)
+ {
+ var e = new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = key };
+ OnKeyDown(e);
+ return e.Handled;
+ }
+
+ public bool SimulateKeyDownWithModifiersReturnsHandled(Key key, KeyModifiers modifiers)
+ {
+ var e = new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = key, KeyModifiers = modifiers };
+ OnKeyDown(e);
+ return e.Handled;
+ }
+ }
+
+ public class VisualTreeLifecycleTests : ScopedTestBase
+ {
+ [Fact]
+ public void Detach_And_Reattach_CollectionChangedStillFiresPagesChanged()
+ {
+ var pages = new AvaloniaList();
+ var tp = new TabbedPage { Pages = pages };
+ var root = new TestRoot { Child = tp };
+
+ root.Child = null;
+ root.Child = tp;
+
+ int fireCount = 0;
+ tp.PagesChanged += (_, _) => fireCount++;
+
+ pages.Add(new ContentPage { Header = "A" });
+ pages.Add(new ContentPage { Header = "B" });
+
+ Assert.Equal(2, fireCount);
+ }
}
}
diff --git a/tests/Avalonia.Controls.UnitTests/VirtualizingCarouselPanelTests.cs b/tests/Avalonia.Controls.UnitTests/VirtualizingCarouselPanelTests.cs
index 11687fa81d..7a1e735d95 100644
--- a/tests/Avalonia.Controls.UnitTests/VirtualizingCarouselPanelTests.cs
+++ b/tests/Avalonia.Controls.UnitTests/VirtualizingCarouselPanelTests.cs
@@ -11,6 +11,7 @@ using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
using Avalonia.Input;
+using Avalonia.Input.GestureRecognizers;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Threading;
@@ -37,6 +38,37 @@ namespace Avalonia.Controls.UnitTests
Assert.Equal("foo", container.Content);
}
+ [Fact]
+ public void Initial_SelectedIndex_Is_Displayed()
+ {
+ using var app = Start();
+ var items = new[] { "foo", "bar" };
+ var (target, _) = CreateTarget(items, selectedIndex: 1);
+
+ Assert.Single(target.Children);
+ var container = Assert.IsType(target.Children[0]);
+ Assert.Equal("bar", container.Content);
+ }
+
+ [Fact]
+ public void Refreshing_Swipe_Wiring_Reuses_A_Single_Recognizer()
+ {
+ using var app = Start();
+ var items = new[] { "foo", "bar" };
+ var (target, carousel) = CreateTarget(items);
+
+ carousel.IsSwipeEnabled = true;
+ carousel.PageTransition = new PageSlide(TimeSpan.FromMilliseconds(1));
+ carousel.IsSwipeEnabled = false;
+ carousel.IsSwipeEnabled = true;
+ carousel.PageTransition = null;
+ carousel.PageTransition = new PageSlide(TimeSpan.FromMilliseconds(1));
+
+ var recognizers = target.GestureRecognizers.OfType().ToArray();
+ var recognizer = Assert.Single(recognizers);
+ Assert.True(recognizer.IsEnabled);
+ }
+
[Fact]
public void Displays_Next_Item()
{
@@ -256,7 +288,7 @@ namespace Avalonia.Controls.UnitTests
}
[Fact]
- public void Changing_SelectedIndex_transitions_forward_cycle()
+ public void Changing_SelectedIndex_From_First_To_Last_Transitions_Forward()
{
using var app = Start();
Dispatcher.UIThread.Invoke(() => // This sets up a proper sync context
@@ -264,28 +296,23 @@ namespace Avalonia.Controls.UnitTests
var items = new Control[] { new Button(), new Canvas(), new Label() };
var transition = new Mock();
var (target, carousel) = CreateTarget(items, transition.Object);
- var cycleindexes = new[] { 1, 2, 0 };
- for (int cycleIndex = 0; cycleIndex < cycleindexes.Length; cycleIndex++)
- {
- carousel.SelectedIndex = cycleindexes[cycleIndex];
- Layout(target);
-
- Dispatcher.UIThread.RunJobs();
-
- var index = cycleIndex;
- transition.Verify(x => x.Start(
- index > 0 ? items[cycleindexes[index - 1]] : items[0],
- items[cycleindexes[index]],
- true,
- It.IsAny()),
- Times.Once);
- }
+ carousel.SelectedIndex = 2;
+ Layout(target);
+
+ Dispatcher.UIThread.RunJobs();
+
+ transition.Verify(x => x.Start(
+ items[0],
+ items[2],
+ true,
+ It.IsAny()),
+ Times.Once);
});
}
[Fact]
- public void Changing_SelectedIndex_transitions_backward_cycle()
+ public void Changing_SelectedIndex_From_Last_To_First_Transitions_Backward()
{
using var app = Start();
Dispatcher.UIThread.Invoke(() => // This sets up a proper sync context
@@ -294,23 +321,20 @@ namespace Avalonia.Controls.UnitTests
var transition = new Mock();
var (target, carousel) = CreateTarget(items, transition.Object);
- var cycleindexes = new[] { 2, 1, 0 };
+ carousel.SelectedIndex = 2;
+ Layout(target);
+ Dispatcher.UIThread.RunJobs();
- for (int cycleIndex = 0; cycleIndex < cycleindexes.Length; cycleIndex++)
- {
- carousel.SelectedIndex = cycleindexes[cycleIndex];
- Layout(target);
-
- Dispatcher.UIThread.RunJobs();
-
- var index = cycleIndex;
- transition.Verify(x => x.Start(
- index > 0 ? items[cycleindexes[index - 1]] : items[0],
- items[cycleindexes[index]],
- false,
- It.IsAny()),
- Times.Once);
- }
+ carousel.SelectedIndex = 0;
+ Layout(target);
+ Dispatcher.UIThread.RunJobs();
+
+ transition.Verify(x => x.Start(
+ items[2],
+ items[0],
+ false,
+ It.IsAny()),
+ Times.Once);
});
}
@@ -440,6 +464,7 @@ namespace Avalonia.Controls.UnitTests
private static (VirtualizingCarouselPanel, Carousel) CreateTarget(
IEnumerable items,
IPageTransition? transition = null,
+ int? selectedIndex = null,
double viewportFraction = 1d,
Size? clientSize = null)
{
@@ -454,6 +479,9 @@ namespace Avalonia.Controls.UnitTests
Height = size.Height,
};
+ if (selectedIndex.HasValue)
+ carousel.SelectedIndex = selectedIndex.Value;
+
var root = new TestRoot(carousel)
{
ClientSize = size,
diff --git a/tests/Avalonia.RenderTests/Controls/CarouselPageTests.cs b/tests/Avalonia.RenderTests/Controls/CarouselPageTests.cs
new file mode 100644
index 0000000000..00d669bf14
--- /dev/null
+++ b/tests/Avalonia.RenderTests/Controls/CarouselPageTests.cs
@@ -0,0 +1,133 @@
+using System.Collections;
+using System.Threading.Tasks;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Styling;
+using Avalonia.Themes.Simple;
+using Xunit;
+
+#if AVALONIA_SKIA
+namespace Avalonia.Skia.RenderTests
+#else
+namespace Avalonia.Direct2D1.RenderTests.Controls
+#endif
+{
+ public class CarouselPageRenderTests : TestBase
+ {
+ public CarouselPageRenderTests()
+ : base(@"Controls\CarouselPage")
+ {
+ }
+
+ private static Style FontStyle => new Style(x => x.OfType())
+ {
+ Setters = { new Setter(TextBlock.FontFamilyProperty, TestFontFamily) }
+ };
+
+ private static ContentPage MakePage(string label, string bgHex, string fgHex) =>
+ new ContentPage
+ {
+ Header = label,
+ Background = new SolidColorBrush(Color.Parse(bgHex)),
+ Content = new TextBlock
+ {
+ Text = label,
+ Foreground = new SolidColorBrush(Color.Parse(fgHex)),
+ FontSize = 28,
+ FontWeight = FontWeight.Bold,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center
+ },
+ HorizontalContentAlignment = HorizontalAlignment.Center,
+ VerticalContentAlignment = VerticalAlignment.Center
+ };
+
+ [Fact]
+ public async Task CarouselPage_Blue_Page()
+ {
+ var cp = new CarouselPage { Background = Brushes.White, PageTransition = null };
+ ((IList)cp.Pages!).Add(MakePage("Page 1", "#1565C0", "#FFFFFF"));
+
+ var target = new Decorator { Width = 400, Height = 300, Child = cp };
+ target.Styles.Add(new SimpleTheme());
+ target.Styles.Add(FontStyle);
+ await RenderToFile(target);
+ CompareImages(skipImmediate: true);
+ }
+
+ [Fact]
+ public async Task CarouselPage_Green_Page()
+ {
+ var cp = new CarouselPage { Background = Brushes.White, PageTransition = null };
+ ((IList)cp.Pages!).Add(MakePage("Page 2", "#2E7D32", "#FFFFFF"));
+
+ var target = new Decorator { Width = 400, Height = 300, Child = cp };
+ target.Styles.Add(new SimpleTheme());
+ target.Styles.Add(FontStyle);
+ await RenderToFile(target);
+ CompareImages(skipImmediate: true);
+ }
+
+ [Fact]
+ public async Task CarouselPage_Red_Page()
+ {
+ var cp = new CarouselPage { Background = Brushes.White, PageTransition = null };
+ ((IList)cp.Pages!).Add(MakePage("Page 3", "#C62828", "#FFFFFF"));
+
+ var target = new Decorator { Width = 400, Height = 300, Child = cp };
+ target.Styles.Add(new SimpleTheme());
+ target.Styles.Add(FontStyle);
+ await RenderToFile(target);
+ CompareImages(skipImmediate: true);
+ }
+
+ [Fact]
+ public async Task CarouselPage_ThreePages_FirstSelected()
+ {
+ var cp = new CarouselPage { Background = Brushes.White, PageTransition = null };
+ ((IList)cp.Pages!).Add(MakePage("Page 1", "#1565C0", "#FFFFFF"));
+ ((IList)cp.Pages!).Add(MakePage("Page 2", "#2E7D32", "#FFFFFF"));
+ ((IList)cp.Pages!).Add(MakePage("Page 3", "#C62828", "#FFFFFF"));
+
+ var target = new Decorator { Width = 400, Height = 300, Child = cp };
+ target.Styles.Add(new SimpleTheme());
+ target.Styles.Add(FontStyle);
+ await RenderToFile(target);
+ CompareImages(skipImmediate: true);
+ }
+
+ [Fact]
+ public async Task CarouselPage_ThreePages_SecondSelected()
+ {
+ var cp = new CarouselPage { Background = Brushes.White, PageTransition = null };
+ ((IList)cp.Pages!).Add(MakePage("Page 1", "#1565C0", "#FFFFFF"));
+ ((IList)cp.Pages!).Add(MakePage("Page 2", "#2E7D32", "#FFFFFF"));
+ ((IList)cp.Pages!).Add(MakePage("Page 3", "#C62828", "#FFFFFF"));
+ cp.SelectedIndex = 1;
+
+ var target = new Decorator { Width = 400, Height = 300, Child = cp };
+ target.Styles.Add(new SimpleTheme());
+ target.Styles.Add(FontStyle);
+ await RenderToFile(target);
+ CompareImages(skipImmediate: true);
+ }
+
+ [Fact]
+ public async Task CarouselPage_CustomBackground()
+ {
+ var cp = new CarouselPage
+ {
+ Background = new SolidColorBrush(Color.Parse("#212121")),
+ PageTransition = null
+ };
+ ((IList)cp.Pages!).Add(MakePage("Dark Theme", "#F57F17", "#212121"));
+
+ var target = new Decorator { Width = 400, Height = 300, Child = cp };
+ target.Styles.Add(new SimpleTheme());
+ target.Styles.Add(FontStyle);
+ await RenderToFile(target);
+ CompareImages(skipImmediate: true);
+ }
+ }
+}
diff --git a/tests/Avalonia.RenderTests/TestRenderHelper.cs b/tests/Avalonia.RenderTests/TestRenderHelper.cs
index 87132881f7..2bd12cfa81 100644
--- a/tests/Avalonia.RenderTests/TestRenderHelper.cs
+++ b/tests/Avalonia.RenderTests/TestRenderHelper.cs
@@ -5,6 +5,7 @@ using Avalonia.Media.Imaging;
using Avalonia.Rendering;
using SixLabors.ImageSharp;
using Xunit;
+using Avalonia.Input;
using Avalonia.Platform;
using System.Threading.Tasks;
using System;
@@ -33,6 +34,18 @@ static class TestRenderHelper
SkiaPlatform.Initialize();
AvaloniaLocator.CurrentMutable.Bind().ToConstant(new StandardAssetLoader());
AvaloniaLocator.CurrentMutable.Bind().ToConstant(new HarfBuzzTextShaper());
+ AvaloniaLocator.CurrentMutable.Bind().ToConstant(new NullCursorFactory());
+ }
+
+ private sealed class NullCursorFactory : ICursorFactory
+ {
+ public ICursorImpl GetCursor(StandardCursorType cursorType) => new NullCursor();
+ public ICursorImpl CreateCursor(Bitmap cursor, PixelPoint hotSpot) => new NullCursor();
+
+ private sealed class NullCursor : ICursorImpl
+ {
+ public void Dispose() { }
+ }
}
diff --git a/tests/Avalonia.UnitTests/MouseTestHelper.cs b/tests/Avalonia.UnitTests/MouseTestHelper.cs
index 235841fd04..082ae718f3 100644
--- a/tests/Avalonia.UnitTests/MouseTestHelper.cs
+++ b/tests/Avalonia.UnitTests/MouseTestHelper.cs
@@ -68,8 +68,13 @@ namespace Avalonia.UnitTests
public void Move(Interactive target, Interactive source, in Point position, KeyModifiers modifiers = default)
{
- target.RaiseEvent(new PointerEventArgs(InputElement.PointerMovedEvent, source, _pointer, GetRoot(target), position,
- Timestamp(), new PointerPointProperties((RawInputModifiers)_pressedButtons, PointerUpdateKind.Other), modifiers));
+ var e = new PointerEventArgs(InputElement.PointerMovedEvent, source, _pointer, GetRoot(target), position,
+ Timestamp(), new PointerPointProperties((RawInputModifiers)_pressedButtons, PointerUpdateKind.Other), modifiers);
+
+ if (_pointer.CapturedGestureRecognizer != null)
+ _pointer.CapturedGestureRecognizer.PointerMovedInternal(e);
+ else
+ target.RaiseEvent(e);
}
public void Up(Interactive target, MouseButton mouseButton = MouseButton.Left, Point? position = null,
@@ -88,9 +93,17 @@ namespace Avalonia.UnitTests
);
if (ButtonCount(props) == 0)
{
- target.RaiseEvent(new PointerReleasedEventArgs(source, _pointer, GetRoot(target), position ?? MidpointRelativeToRoot(target),
- Timestamp(), props, modifiers, _pressedButton));
+ var e = new PointerReleasedEventArgs(source, _pointer, GetRoot(target), position ?? MidpointRelativeToRoot(target),
+ Timestamp(), props, modifiers, _pressedButton);
+
+ if (_pointer.CapturedGestureRecognizer != null)
+ _pointer.CapturedGestureRecognizer.PointerReleasedInternal(e);
+ else
+ target.RaiseEvent(e);
+
_pointer.Capture(null);
+ _pointer.CaptureGestureRecognizer(null);
+ _pointer.IsGestureRecognitionSkipped = false;
}
else
Move(target, source, position ?? default);
diff --git a/tests/TestFiles/Skia/Controls/CarouselPage/CarouselPage_Blue_Page.expected.png b/tests/TestFiles/Skia/Controls/CarouselPage/CarouselPage_Blue_Page.expected.png
new file mode 100644
index 0000000000..4bc663b85b
Binary files /dev/null and b/tests/TestFiles/Skia/Controls/CarouselPage/CarouselPage_Blue_Page.expected.png differ
diff --git a/tests/TestFiles/Skia/Controls/CarouselPage/CarouselPage_CustomBackground.expected.png b/tests/TestFiles/Skia/Controls/CarouselPage/CarouselPage_CustomBackground.expected.png
new file mode 100644
index 0000000000..3f3373bc75
Binary files /dev/null and b/tests/TestFiles/Skia/Controls/CarouselPage/CarouselPage_CustomBackground.expected.png differ
diff --git a/tests/TestFiles/Skia/Controls/CarouselPage/CarouselPage_Green_Page.expected.png b/tests/TestFiles/Skia/Controls/CarouselPage/CarouselPage_Green_Page.expected.png
new file mode 100644
index 0000000000..651b32b47f
Binary files /dev/null and b/tests/TestFiles/Skia/Controls/CarouselPage/CarouselPage_Green_Page.expected.png differ
diff --git a/tests/TestFiles/Skia/Controls/CarouselPage/CarouselPage_Red_Page.expected.png b/tests/TestFiles/Skia/Controls/CarouselPage/CarouselPage_Red_Page.expected.png
new file mode 100644
index 0000000000..f8ffcb901d
Binary files /dev/null and b/tests/TestFiles/Skia/Controls/CarouselPage/CarouselPage_Red_Page.expected.png differ
diff --git a/tests/TestFiles/Skia/Controls/CarouselPage/CarouselPage_ThreePages_FirstSelected.expected.png b/tests/TestFiles/Skia/Controls/CarouselPage/CarouselPage_ThreePages_FirstSelected.expected.png
new file mode 100644
index 0000000000..4bc663b85b
Binary files /dev/null and b/tests/TestFiles/Skia/Controls/CarouselPage/CarouselPage_ThreePages_FirstSelected.expected.png differ
diff --git a/tests/TestFiles/Skia/Controls/CarouselPage/CarouselPage_ThreePages_SecondSelected.expected.png b/tests/TestFiles/Skia/Controls/CarouselPage/CarouselPage_ThreePages_SecondSelected.expected.png
new file mode 100644
index 0000000000..651b32b47f
Binary files /dev/null and b/tests/TestFiles/Skia/Controls/CarouselPage/CarouselPage_ThreePages_SecondSelected.expected.png differ