diff --git a/api/Avalonia.nupkg.xml b/api/Avalonia.nupkg.xml
index 8e6173a6cb..b2a81dd55d 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
@@ -1861,6 +1867,12 @@
baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
+ CP0002
+ F:Avalonia.Controls.Primitives.FlyoutBase.IsOpenProperty
+ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
CP0002
F:Avalonia.Controls.Primitives.Popup.PlacementModeProperty
@@ -1891,6 +1903,12 @@
baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
+ CP0002
+ F:Avalonia.Controls.TabItem.IconProperty
+ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
CP0002
F:Avalonia.Controls.TextBlock.LetterSpacingProperty
@@ -2029,6 +2047,18 @@
baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
+ CP0002
+ M:Avalonia.Controls.DrawerPage.get_DrawerBreakpointWidth
+ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
+
+ CP0002
+ M:Avalonia.Controls.DrawerPage.set_DrawerBreakpointWidth(System.Double)
+ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
CP0002
M:Avalonia.Controls.Embedding.Offscreen.OffscreenTopLevelImplBase.get_Surfaces
@@ -2065,6 +2095,12 @@
baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
+ CP0002
+ M:Avalonia.Controls.PageSelectionChangedEventArgs.#ctor(Avalonia.Controls.Page,Avalonia.Controls.Page)
+ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
CP0002
M:Avalonia.Controls.Platform.DefaultMenuInteractionHandler.GotFocus(System.Object,Avalonia.Input.GotFocusEventArgs)
@@ -2269,6 +2305,12 @@
baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
+ CP0002
+ M:Avalonia.Controls.Primitives.VisualLayerManager.get_IsPopup
+ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
CP0002
M:Avalonia.Controls.Primitives.VisualLayerManager.get_LightDismissOverlayLayer
@@ -2287,12 +2329,30 @@
baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
+ CP0002
+ M:Avalonia.Controls.Primitives.VisualLayerManager.set_IsPopup(System.Boolean)
+ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
CP0002
M:Avalonia.Controls.Screens.ScreenFromWindow(Avalonia.Platform.IWindowBaseImpl)
baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
+ CP0002
+ M:Avalonia.Controls.TabbedPage.FindNextEnabledTab(System.Int32,System.Int32)
+ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
+
+ CP0002
+ M:Avalonia.Controls.TabItem.get_Icon
+ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
CP0002
M:Avalonia.Controls.TabItem.SubscribeToOwnerProperties(Avalonia.AvaloniaObject)
@@ -3451,6 +3511,12 @@
baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
+ CP0002
+ F:Avalonia.Controls.DrawerPage.DrawerBreakpointWidthProperty
+ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
CP0002
F:Avalonia.Controls.NativeMenuBar.EnableMenuItemClickForwardingProperty
@@ -3463,6 +3529,12 @@
baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
+ CP0002
+ F:Avalonia.Controls.Primitives.FlyoutBase.IsOpenProperty
+ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
CP0002
F:Avalonia.Controls.Primitives.Popup.PlacementModeProperty
@@ -3493,6 +3565,12 @@
baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
+ CP0002
+ F:Avalonia.Controls.TabItem.IconProperty
+ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
CP0002
F:Avalonia.Controls.TextBlock.LetterSpacingProperty
@@ -3631,6 +3709,18 @@
baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
+ CP0002
+ M:Avalonia.Controls.DrawerPage.get_DrawerBreakpointWidth
+ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
+
+ CP0002
+ M:Avalonia.Controls.DrawerPage.set_DrawerBreakpointWidth(System.Double)
+ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
CP0002
M:Avalonia.Controls.Embedding.Offscreen.OffscreenTopLevelImplBase.get_Surfaces
@@ -3667,6 +3757,12 @@
baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
+ CP0002
+ M:Avalonia.Controls.PageSelectionChangedEventArgs.#ctor(Avalonia.Controls.Page,Avalonia.Controls.Page)
+ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
CP0002
M:Avalonia.Controls.Platform.DefaultMenuInteractionHandler.GotFocus(System.Object,Avalonia.Input.GotFocusEventArgs)
@@ -3871,6 +3967,12 @@
baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
+ CP0002
+ M:Avalonia.Controls.Primitives.VisualLayerManager.get_IsPopup
+ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
CP0002
M:Avalonia.Controls.Primitives.VisualLayerManager.get_LightDismissOverlayLayer
@@ -3889,12 +3991,30 @@
baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
+ CP0002
+ M:Avalonia.Controls.Primitives.VisualLayerManager.set_IsPopup(System.Boolean)
+ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
CP0002
M:Avalonia.Controls.Screens.ScreenFromWindow(Avalonia.Platform.IWindowBaseImpl)
baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
+ CP0002
+ M:Avalonia.Controls.TabbedPage.FindNextEnabledTab(System.Int32,System.Int32)
+ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
+
+ CP0002
+ M:Avalonia.Controls.TabItem.get_Icon
+ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
CP0002
M:Avalonia.Controls.TabItem.SubscribeToOwnerProperties(Avalonia.AvaloniaObject)
diff --git a/native/Avalonia.Native/src/OSX/WindowImpl.mm b/native/Avalonia.Native/src/OSX/WindowImpl.mm
index 1d77d3ce44..f42b7c27cb 100644
--- a/native/Avalonia.Native/src/OSX/WindowImpl.mm
+++ b/native/Avalonia.Native/src/OSX/WindowImpl.mm
@@ -563,6 +563,10 @@ NSWindowStyleMask WindowImpl::CalculateStyleMask() {
case SystemDecorationsBorderOnly:
s = s | NSWindowStyleMaskTitled | NSWindowStyleMaskFullSizeContentView;
+
+ if (_canResize && _isEnabled) {
+ s = s | NSWindowStyleMaskResizable;
+ }
break;
case SystemDecorationsFull:
diff --git a/samples/ControlCatalog/Assets/Sanctuary/city_bg.jpg b/samples/ControlCatalog/Assets/Sanctuary/city_bg.jpg
new file mode 100644
index 0000000000..9a8a4b374f
Binary files /dev/null and b/samples/ControlCatalog/Assets/Sanctuary/city_bg.jpg differ
diff --git a/samples/ControlCatalog/Assets/Sanctuary/forest_bg.jpg b/samples/ControlCatalog/Assets/Sanctuary/forest_bg.jpg
new file mode 100644
index 0000000000..32a360431d
Binary files /dev/null and b/samples/ControlCatalog/Assets/Sanctuary/forest_bg.jpg differ
diff --git a/samples/ControlCatalog/Assets/Sanctuary/main_arctic_silence.jpg b/samples/ControlCatalog/Assets/Sanctuary/main_arctic_silence.jpg
new file mode 100644
index 0000000000..61e7b06212
Binary files /dev/null and b/samples/ControlCatalog/Assets/Sanctuary/main_arctic_silence.jpg differ
diff --git a/samples/ControlCatalog/Assets/Sanctuary/main_deep_forest.jpg b/samples/ControlCatalog/Assets/Sanctuary/main_deep_forest.jpg
new file mode 100644
index 0000000000..d8576b626f
Binary files /dev/null and b/samples/ControlCatalog/Assets/Sanctuary/main_deep_forest.jpg differ
diff --git a/samples/ControlCatalog/Assets/Sanctuary/main_desert_sands.jpg b/samples/ControlCatalog/Assets/Sanctuary/main_desert_sands.jpg
new file mode 100644
index 0000000000..e4b44477e3
Binary files /dev/null and b/samples/ControlCatalog/Assets/Sanctuary/main_desert_sands.jpg differ
diff --git a/samples/ControlCatalog/Assets/Sanctuary/main_hero.jpg b/samples/ControlCatalog/Assets/Sanctuary/main_hero.jpg
new file mode 100644
index 0000000000..63dff3f948
Binary files /dev/null and b/samples/ControlCatalog/Assets/Sanctuary/main_hero.jpg differ
diff --git a/samples/ControlCatalog/Assets/Sanctuary/mountain_bg.jpg b/samples/ControlCatalog/Assets/Sanctuary/mountain_bg.jpg
new file mode 100644
index 0000000000..e9fdd5c0d9
Binary files /dev/null and b/samples/ControlCatalog/Assets/Sanctuary/mountain_bg.jpg differ
diff --git a/samples/ControlCatalog/ControlCatalog.csproj b/samples/ControlCatalog/ControlCatalog.csproj
index 8304e3e002..2ce24a4d20 100644
--- a/samples/ControlCatalog/ControlCatalog.csproj
+++ b/samples/ControlCatalog/ControlCatalog.csproj
@@ -11,14 +11,7 @@
Designer
-
-
-
-
-
-
-
-
+
diff --git a/samples/ControlCatalog/MainView.xaml b/samples/ControlCatalog/MainView.xaml
index b6249fe17f..adea1b90fc 100644
--- a/samples/ControlCatalog/MainView.xaml
+++ b/samples/ControlCatalog/MainView.xaml
@@ -54,8 +54,15 @@
ScrollViewer.VerticalScrollBarVisibility="Disabled">
-
-
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/CarouselDemoPage.xaml b/samples/ControlCatalog/Pages/CarouselDemoPage.xaml
new file mode 100644
index 0000000000..df4317fcad
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselDemoPage.xaml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/CarouselDemoPage.xaml.cs b/samples/ControlCatalog/Pages/CarouselDemoPage.xaml.cs
new file mode 100644
index 0000000000..36c9961658
--- /dev/null
+++ b/samples/ControlCatalog/Pages/CarouselDemoPage.xaml.cs
@@ -0,0 +1,91 @@
+using System;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+
+namespace ControlCatalog.Pages
+{
+ public partial class CarouselDemoPage : UserControl
+ {
+ private static readonly (string Group, string Title, string Description, Func Factory)[] Demos =
+ {
+ // Overview
+ ("Overview", "First Look",
+ "Basic CarouselPage with three pages and page indicator.",
+ () => new CarouselPageFirstLookPage()),
+
+ // Populate
+ ("Populate", "Data Templates",
+ "Bind CarouselPage to an ObservableCollection, add or remove pages at runtime, and switch the page template.",
+ () => new CarouselPageDataTemplatePage()),
+
+ // Appearance
+ ("Appearance", "Customization",
+ "Switch slide direction between horizontal and vertical with PageSlide. Page indicator dots update on each selection.",
+ () => new CarouselPageCustomizationPage()),
+
+ // Features
+ ("Features", "Page Transitions",
+ "Animate page switches with CrossFade or PageSlide.",
+ () => new CarouselPageTransitionsPage()),
+ ("Features", "Programmatic Selection",
+ "Jump to any page programmatically with SelectedIndex and respond to SelectionChanged events.",
+ () => new CarouselPageSelectionPage()),
+ ("Features", "Gesture & Keyboard",
+ "Swipe left/right to navigate pages. Toggle IsGestureEnabled and IsKeyboardNavigationEnabled.",
+ () => new CarouselPageGesturePage()),
+ ("Features", "Events",
+ "SelectionChanged, NavigatedTo, and NavigatedFrom events. Swipe or navigate to see the live event log.",
+ () => new CarouselPageEventsPage()),
+
+ // Performance
+ ("Performance", "Performance Monitor",
+ "Track page count, live page instances, and managed heap size. Observe how GC reclaims memory after removing pages.",
+ () => new CarouselPagePerformancePage()),
+
+ // Showcases
+ ("Showcases", "Sanctuary",
+ "Travel discovery app with 3 full-screen immersive pages. Each page has a real background photo, gradient overlay, and themed content. Built as a 1:1 replica of a Stitch design.",
+ () => new SanctuaryShowcasePage()),
+ ("Showcases", "Care Companion",
+ "Healthcare onboarding with CarouselPage (3 pages), then a TabbedPage patient dashboard. Skip or complete onboarding to navigate to the dashboard via RemovePage.",
+ () => new CareCompanionAppPage()),
+
+ // Carousel (ItemsControl) demos
+ ("Carousel", "Getting Started",
+ "Basic Carousel with image items and previous/next navigation buttons.",
+ () => new CarouselGettingStartedPage()),
+ ("Carousel", "Transitions",
+ "Configure page transitions: PageSlide, CrossFade, 3D Rotation, or None.",
+ () => new CarouselTransitionsPage()),
+ ("Carousel", "Customization",
+ "Adjust orientation and transition type to tailor the carousel layout.",
+ () => new CarouselCustomizationPage()),
+ ("Carousel", "Gestures & Keyboard",
+ "Navigate items via swipe gesture and arrow keys. Toggle each input mode on and off.",
+ () => new CarouselGesturesPage()),
+ ("Carousel", "Vertical Orientation",
+ "Carousel with Orientation set to Vertical, navigated with Up/Down keys, swipe, or buttons.",
+ () => new CarouselVerticalPage()),
+ ("Carousel", "Multi-Item Peek",
+ "Adjust ViewportFraction to show multiple items simultaneously with adjacent cards peeking.",
+ () => new CarouselMultiItemPage()),
+ ("Carousel", "Data Binding",
+ "Bind Carousel to an ObservableCollection and add, remove, or shuffle items at runtime.",
+ () => new CarouselDataBindingPage()),
+ ("Carousel", "Curated Gallery",
+ "Editorial art gallery app with DrawerPage navigation, hero Carousel with PipsPager dots, and a horizontal peek carousel for collection highlights.",
+ () => new CarouselGalleryAppPage()),
+ };
+
+ public CarouselDemoPage()
+ {
+ InitializeComponent();
+ Loaded += OnLoaded;
+ }
+
+ private async void OnLoaded(object? sender, RoutedEventArgs e)
+ {
+ await SampleNav.PushAsync(NavigationDemoHelper.CreateGalleryHomePage(SampleNav, Demos), null);
+ }
+ }
+}
diff --git a/samples/ControlCatalog/Pages/CarouselPage/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/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 c1126536ee..0559a265e4 100644
--- a/samples/ControlCatalog/Pages/PipsPagerPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/PipsPagerPage.xaml.cs
@@ -31,6 +31,13 @@ namespace ControlCatalog.Pages
("Appearance", "Custom Templates",
"Override pip item templates to create squares, pills, numbers, or any custom shape.",
() => new PipsPagerCustomTemplatesPage()),
+
+ ("Showcases", "Care Companion",
+ "A health care onboarding flow using PipsPager as the page indicator for a CarouselPage.",
+ () => new CareCompanionAppPage()),
+ ("Showcases", "Sanctuary",
+ "A travel discovery app using PipsPager as the page indicator for a CarouselPage.",
+ () => new SanctuaryShowcasePage()),
};
public PipsPagerPage()
diff --git a/samples/ControlCatalog/Pages/TabbedDemoPage.xaml.cs b/samples/ControlCatalog/Pages/TabbedDemoPage.xaml.cs
index f4e65249ce..51da6330a1 100644
--- a/samples/ControlCatalog/Pages/TabbedDemoPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/TabbedDemoPage.xaml.cs
@@ -18,7 +18,7 @@ namespace ControlCatalog.Pages
"Populate a TabbedPage by adding ContentPage objects directly to the Pages collection.",
() => new TabbedPageCollectionPage()),
("Populate", "Data Templates",
- "Populate a TabbedPage with a data collection and a custom PageTemplate to render each item.",
+ "Bind TabbedPage to an ObservableCollection, add or remove tabs at runtime, and switch the page template.",
() => new TabbedPageDataTemplatePage()),
// Appearance
diff --git a/samples/ControlCatalog/Pages/TabbedPage/TabbedPageCustomTabBarPage.xaml.cs b/samples/ControlCatalog/Pages/TabbedPage/TabbedPageCustomTabBarPage.xaml.cs
index bb7302c1cd..b1a692b85a 100644
--- a/samples/ControlCatalog/Pages/TabbedPage/TabbedPageCustomTabBarPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/TabbedPage/TabbedPageCustomTabBarPage.xaml.cs
@@ -5,7 +5,6 @@ namespace ControlCatalog.Pages
{
public partial class TabbedPageCustomTabBarPage : UserControl
{
- // Fluent UI icon geometries (24x24 viewbox)
private static readonly StreamGeometry HomeGeometry =
StreamGeometry.Parse("M12.9942 2.79444C12.4118 2.30208 11.5882 2.30208 11.0058 2.79444L3.50582 9.39444C3.18607 9.66478 3 10.0634 3 10.4828V20.25C3 20.9404 3.55964 21.5 4.25 21.5H8.25C8.94036 21.5 9.5 20.9404 9.5 20.25V14.75C9.5 14.6119 9.61193 14.5 9.75 14.5H14.25C14.3881 14.5 14.5 14.6119 14.5 14.75V20.25C14.5 20.9404 15.0596 21.5 15.75 21.5H19.75C20.4404 21.5 21 20.9404 21 20.25V10.4828C21 10.0634 20.8139 9.66478 20.4942 9.39444L12.9942 2.79444Z");
private static readonly StreamGeometry WalletGeometry =
@@ -25,16 +24,11 @@ namespace ControlCatalog.Pages
private void SetupIcons()
{
- SetIcon(HomePage, HomeGeometry);
- SetIcon(WalletPage, WalletGeometry);
- SetIcon(SendPage, SendGeometry);
- SetIcon(ActivityPage, ActivityGeometry);
- SetIcon(ProfilePage, ProfileGeometry);
- }
-
- private static void SetIcon(ContentPage page, StreamGeometry geometry)
- {
- page.Icon = geometry;
+ HomePage.Icon = new PathIcon { Data = HomeGeometry };
+ WalletPage.Icon = new PathIcon { Data = WalletGeometry };
+ SendPage.Icon = new PathIcon { Data = SendGeometry };
+ ActivityPage.Icon = new PathIcon { Data = ActivityGeometry };
+ ProfilePage.Icon = new PathIcon { Data = ProfileGeometry };
}
}
}
diff --git a/samples/ControlCatalog/Pages/TabbedPage/TabbedPageCustomizationPage.xaml.cs b/samples/ControlCatalog/Pages/TabbedPage/TabbedPageCustomizationPage.xaml.cs
index dc72759c5e..b4eb6d9b49 100644
--- a/samples/ControlCatalog/Pages/TabbedPage/TabbedPageCustomizationPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/TabbedPage/TabbedPageCustomizationPage.xaml.cs
@@ -109,14 +109,9 @@ namespace ControlCatalog.Pages
private void OnShowIconsChanged(object? sender, RoutedEventArgs e)
{
bool show = ShowIconsCheck.IsChecked == true;
- SetIcon(HomePage, show ? HomeGeometry : null);
- SetIcon(SearchPage, show ? SearchGeometry : null);
- SetIcon(SettingsPage, show ? SettingsGeometry : null);
- }
-
- private static void SetIcon(ContentPage page, StreamGeometry? geometry)
- {
- page.Icon = geometry;
+ HomePage.Icon = show ? new PathIcon { Data = HomeGeometry } : null;
+ SearchPage.Icon = show ? new PathIcon { Data = SearchGeometry } : null;
+ SettingsPage.Icon = show ? new PathIcon { Data = SettingsGeometry } : null;
}
private void OnTabEnabledChanged(object? sender, RoutedEventArgs e)
diff --git a/samples/ControlCatalog/Pages/TabbedPage/TabbedPageDataTemplatePage.xaml b/samples/ControlCatalog/Pages/TabbedPage/TabbedPageDataTemplatePage.xaml
index e2f5771028..dcb16a99a5 100644
--- a/samples/ControlCatalog/Pages/TabbedPage/TabbedPageDataTemplatePage.xaml
+++ b/samples/ControlCatalog/Pages/TabbedPage/TabbedPageDataTemplatePage.xaml
@@ -8,16 +8,25 @@
Foreground="{DynamicResource SystemControlHighlightAccentBrush}" />
+ Text="Bind a TabbedPage to a data collection, render each item with PageTemplate, and switch templates at runtime." />
+
-
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/TabbedPage/TabbedPageDataTemplatePage.xaml.cs b/samples/ControlCatalog/Pages/TabbedPage/TabbedPageDataTemplatePage.xaml.cs
index 44f5179247..169735ce65 100644
--- a/samples/ControlCatalog/Pages/TabbedPage/TabbedPageDataTemplatePage.xaml.cs
+++ b/samples/ControlCatalog/Pages/TabbedPage/TabbedPageDataTemplatePage.xaml.cs
@@ -1,5 +1,7 @@
using System.Collections.ObjectModel;
+using Avalonia;
using Avalonia.Controls;
+using Avalonia.Controls.Templates;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
@@ -8,18 +10,32 @@ namespace ControlCatalog.Pages
{
public partial class TabbedPageDataTemplatePage : UserControl
{
- private static readonly (string Name, string Color)[] CategoryData =
+ private sealed class CategoryViewModel
{
- ("Electronics", "#1565C0"),
- ("Books", "#2E7D32"),
- ("Clothing", "#6A1B9A"),
+ public string Name { get; }
+ public string Color { get; }
+
+ public CategoryViewModel(string name, string color)
+ {
+ Name = name;
+ Color = color;
+ }
+ }
+
+ private static readonly CategoryViewModel[] InitialData =
+ {
+ new("Electronics", "#1565C0"), new("Books", "#2E7D32"), new("Clothing", "#6A1B9A"),
};
- private static readonly string[] AddNames = { "Sports", "Music", "Garden", "Toys", "Food" };
- private static readonly string[] AddColors = { "#E53935", "#F57C00", "#00796B", "#E91E63", "#3F51B5" };
+ private static readonly CategoryViewModel[] AddData =
+ {
+ new("Sports", "#E53935"), new("Music", "#F57C00"), new("Garden", "#00796B"), new("Toys", "#E91E63"),
+ new("Food", "#3F51B5")
+ };
- private readonly ObservableCollection _pages = new();
+ private readonly ObservableCollection _items = new();
private int _addCounter;
+ private bool _useDetailTemplate = true;
private TabbedPage? _tabbedPage;
public TabbedPageDataTemplatePage()
@@ -30,48 +46,99 @@ namespace ControlCatalog.Pages
private void OnLoaded(object? sender, RoutedEventArgs e)
{
- foreach (var (name, color) in CategoryData)
- _pages.Add(CreatePage(name, color));
+ if (_tabbedPage != null)
+ return;
- _addCounter = CategoryData.Length;
+ foreach (var vm in InitialData)
+ _items.Add(vm);
+ _addCounter = InitialData.Length;
+ _useDetailTemplate = true;
_tabbedPage = new TabbedPage
{
- TabPlacement = TabPlacement.Top,
- Pages = _pages
+ TabPlacement = TabPlacement.Top, ItemsSource = _items, PageTemplate = CreatePageTemplate()
};
+ _tabbedPage.SelectionChanged += OnSelectionChanged;
TabbedPageHost.Children.Add(_tabbedPage);
UpdateStatus();
}
private void OnAddCategory(object? sender, RoutedEventArgs e)
{
- var idx = _addCounter % AddNames.Length;
- var name = AddNames[idx] + (_addCounter >= AddNames.Length ? $" {_addCounter / AddNames.Length + 1}" : "");
- _pages.Add(CreatePage(name, AddColors[idx]));
+ var idx = _addCounter % AddData.Length;
+ var vm = AddData[idx];
+ var suffix = _addCounter >= AddData.Length ? $" {_addCounter / AddData.Length + 1}" : "";
+ _items.Add(new CategoryViewModel(vm.Name + suffix, vm.Color));
_addCounter++;
UpdateStatus();
}
private void OnRemoveCategory(object? sender, RoutedEventArgs e)
{
- if (_pages.Count > 0)
+ if (_items.Count > 0)
{
- _pages.RemoveAt(_pages.Count - 1);
+ _items.RemoveAt(_items.Count - 1);
UpdateStatus();
}
}
+ private void OnSelectionChanged(object? sender, PageSelectionChangedEventArgs e) => UpdateStatus();
+
+ private void OnSwitchTemplate(object? sender, RoutedEventArgs e)
+ {
+ if (_tabbedPage == null)
+ return;
+
+ _useDetailTemplate = !_useDetailTemplate;
+ _tabbedPage.PageTemplate = CreatePageTemplate();
+ UpdateStatus();
+ }
+
+ private void OnPrevious(object? sender, RoutedEventArgs e)
+ {
+ if (_tabbedPage == null)
+ return;
+
+ if (_tabbedPage.SelectedIndex > 0)
+ _tabbedPage.SelectedIndex--;
+ }
+
+ private void OnNext(object? sender, RoutedEventArgs e)
+ {
+ if (_tabbedPage == null)
+ return;
+
+ if (_tabbedPage.SelectedIndex < _items.Count - 1)
+ _tabbedPage.SelectedIndex++;
+ }
+
private void UpdateStatus()
{
- StatusText.Text = $"{_pages.Count} categor{(_pages.Count == 1 ? "y" : "ies")}";
+ var count = _items.Count;
+ var index = _tabbedPage?.SelectedIndex ?? -1;
+ StatusText.Text = count == 0 ? "No tabs" : $"Tab {index + 1} of {count} (index {index})";
}
- private static ContentPage CreatePage(string name, string color) => new()
+ private IDataTemplate CreatePageTemplate()
{
- Header = name,
- Content = new StackPanel
+ return new FuncDataTemplate((vm, _) => CreatePage(vm, _useDetailTemplate));
+ }
+
+ private static ContentPage CreatePage(CategoryViewModel? vm, bool useDetailTemplate)
+ {
+ if (vm is null)
+ return new ContentPage();
+
+ return new ContentPage
+ {
+ Header = vm.Name, Content = useDetailTemplate ? CreateDetailContent(vm) : CreateShowcaseContent(vm)
+ };
+ }
+
+ private static Control CreateDetailContent(CategoryViewModel vm)
+ {
+ return new StackPanel
{
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
@@ -80,15 +147,15 @@ namespace ControlCatalog.Pages
{
new TextBlock
{
- Text = name,
+ Text = vm.Name,
FontSize = 24,
FontWeight = FontWeight.SemiBold,
- Foreground = new SolidColorBrush(Color.Parse(color)),
+ Foreground = new SolidColorBrush(Color.Parse(vm.Color)),
HorizontalAlignment = HorizontalAlignment.Center
},
new TextBlock
{
- Text = $"Tab for category: {name}",
+ Text = $"Tab for category: {vm.Name}",
FontSize = 13,
Opacity = 0.7,
TextWrapping = TextWrapping.Wrap,
@@ -96,7 +163,45 @@ namespace ControlCatalog.Pages
MaxWidth = 280
}
}
- }
- };
+ };
+ }
+
+ private static Control CreateShowcaseContent(CategoryViewModel vm)
+ {
+ var accent = Color.Parse(vm.Color);
+
+ return new Border
+ {
+ Margin = new Thickness(24),
+ CornerRadius = new CornerRadius(18),
+ Background = new SolidColorBrush(accent),
+ Padding = new Thickness(28),
+ Child = new StackPanel
+ {
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ Spacing = 10,
+ Children =
+ {
+ new TextBlock
+ {
+ Text = vm.Name,
+ FontSize = 28,
+ FontWeight = FontWeight.Bold,
+ Foreground = Brushes.White,
+ HorizontalAlignment = HorizontalAlignment.Center
+ },
+ new TextBlock
+ {
+ Text = "Template switched at runtime",
+ FontSize = 14,
+ Foreground = Brushes.White,
+ Opacity = 0.9,
+ HorizontalAlignment = HorizontalAlignment.Center
+ }
+ }
+ }
+ };
+ }
}
}
diff --git a/samples/ControlCatalog/Pages/TabbedPage/TabbedPageFabPage.xaml.cs b/samples/ControlCatalog/Pages/TabbedPage/TabbedPageFabPage.xaml.cs
index 5c10a50df7..b52bfd4d8a 100644
--- a/samples/ControlCatalog/Pages/TabbedPage/TabbedPageFabPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/TabbedPage/TabbedPageFabPage.xaml.cs
@@ -28,10 +28,10 @@ namespace ControlCatalog.Pages
private void SetupIcons()
{
- FeedPage.Icon = FeedGeometry;
- DiscoverPage.Icon = DiscoverGeometry;
- AlertsPage.Icon = AlertsGeometry;
- ProfilePage.Icon = ProfileGeometry;
+ FeedPage.Icon = new PathIcon { Data = FeedGeometry };
+ DiscoverPage.Icon = new PathIcon { Data = DiscoverGeometry };
+ AlertsPage.Icon = new PathIcon { Data = AlertsGeometry };
+ ProfilePage.Icon = new PathIcon { Data = ProfileGeometry };
}
private void OnFabClicked(object? sender, RoutedEventArgs e)
diff --git a/samples/ControlCatalog/Pages/TabbedPage/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/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs b/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs
index 47d95d8da1..59ec332b2d 100644
--- a/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs
+++ b/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs
@@ -12,6 +12,7 @@ using Avalonia.Logging;
using Avalonia.Platform.Storage;
using Avalonia.Platform.Storage.FileIO;
using Java.Lang;
+using static Android.Provider.DocumentsContract;
using AndroidUri = Android.Net.Uri;
using Exception = System.Exception;
using JavaFile = Java.IO.File;
@@ -35,10 +36,10 @@ internal abstract class AndroidStorageItem : IStorageBookmarkItem
}
internal AndroidUri Uri { get; set; }
-
+
protected Activity Activity => _activity ?? throw new ObjectDisposedException(nameof(AndroidStorageItem));
- public virtual string Name => GetColumnValue(Activity, Uri, DocumentsContract.Document.ColumnDisplayName)
+ public virtual string Name => GetColumnValue(Activity, Uri, Document.ColumnDisplayName)
?? GetColumnValue(Activity, Uri, MediaStore.IMediaColumns.DisplayName)
?? Uri.PathSegments?.LastOrDefault()?.Split("/", StringSplitOptions.RemoveEmptyEntries).LastOrDefault() ?? string.Empty;
@@ -67,7 +68,7 @@ internal abstract class AndroidStorageItem : IStorageBookmarkItem
Activity.ContentResolver?.ReleasePersistableUriPermission(Uri, ActivityFlags.GrantWriteUriPermission | ActivityFlags.GrantReadUriPermission);
}
-
+
public abstract Task GetBasicPropertiesAsync();
protected static string? GetColumnValue(Context context, AndroidUri contentUri, string column, string? selection = null, string[]? selectionArgs = null)
@@ -98,7 +99,7 @@ internal abstract class AndroidStorageItem : IStorageBookmarkItem
return null;
}
- if(_parent != null)
+ if (_parent != null)
{
return _parent;
}
@@ -106,8 +107,8 @@ internal abstract class AndroidStorageItem : IStorageBookmarkItem
using var javaFile = new JavaFile(Uri.Path!);
// Java file represents files AND directories. Don't be confused.
- if (javaFile.ParentFile is {} parentFile
- && AndroidUri.FromFile(parentFile) is {} androidUri)
+ if (javaFile.ParentFile is { } parentFile
+ && AndroidUri.FromFile(parentFile) is { } androidUri)
{
return new AndroidStorageFolder(Activity, androidUri, false);
}
@@ -124,12 +125,12 @@ internal abstract class AndroidStorageItem : IStorageBookmarkItem
return await _activity!.CheckPermission(Manifest.Permission.ReadExternalStorage);
}
-
+
public void Dispose()
{
_activity = null;
}
-
+
internal AndroidUri? PermissionRoot => _permissionRoot;
public abstract Task DeleteAsync();
@@ -138,8 +139,8 @@ internal abstract class AndroidStorageItem : IStorageBookmarkItem
public static IStorageItem CreateItem(Activity activity, AndroidUri uri)
{
- var mimeType = GetColumnValue(activity, uri, DocumentsContract.Document.ColumnMimeType);
- if (mimeType == DocumentsContract.Document.MimeTypeDir)
+ var mimeType = GetColumnValue(activity, uri, Document.ColumnMimeType);
+ if (mimeType == Document.MimeTypeDir)
{
return new AndroidStorageFolder(activity, uri, false);
}
@@ -160,8 +161,8 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder
{
var mimeType = MimeTypeMap.Singleton?.GetMimeTypeFromExtension(MimeTypeMap.GetFileExtensionFromUrl(name)) ?? "application/octet-stream";
var treeUri = GetTreeUri().treeUri;
- var newFile = DocumentsContract.CreateDocument(Activity.ContentResolver!, treeUri!, mimeType, name);
- if(newFile == null)
+ var newFile = CreateDocument(Activity.ContentResolver!, treeUri!, mimeType, name);
+ if (newFile == null)
{
return Task.FromResult(null);
}
@@ -172,7 +173,7 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder
public Task CreateFolderAsync(string name)
{
var treeUri = GetTreeUri().treeUri;
- var newFolder = DocumentsContract.CreateDocument(Activity.ContentResolver!, treeUri!, DocumentsContract.Document.MimeTypeDir, name);
+ var newFolder = CreateDocument(Activity.ContentResolver!, treeUri!, Document.MimeTypeDir, name);
if (newFolder == null)
{
return Task.FromResult(null);
@@ -197,24 +198,76 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder
{
await foreach (var file in storageFolder.GetItemsAsync())
{
- if(file is AndroidStorageFolder folder)
+ if (file is AndroidStorageFolder folder)
{
await DeleteContents(folder);
}
- else if(file is AndroidStorageFile storageFile)
+ else if (file is AndroidStorageFile storageFile)
{
await storageFile.DeleteAsync();
}
}
var treeUri = GetTreeUri().treeUri;
- DocumentsContract.DeleteDocument(Activity.ContentResolver!, treeUri!);
+ DeleteDocument(Activity.ContentResolver!, treeUri!);
}
}
public override Task GetBasicPropertiesAsync()
{
- return Task.FromResult(new StorageItemProperties());
+ DateTimeOffset? dateModified = null;
+
+ AndroidUri? queryUri = null;
+
+ try
+ {
+ try
+ {
+ // When Uri is a tree URI, use its document id to build a document URI.
+ var folderId = GetTreeDocumentId(Uri);
+ queryUri = BuildDocumentUriUsingTree(Uri, folderId);
+ }
+ catch (UnsupportedOperationException)
+ {
+ // For non-root items, Uri may already be a document URI; use it directly.
+ queryUri = Uri;
+ }
+
+ if (queryUri != null)
+ {
+ var projection = new[]
+ {
+ Document.ColumnLastModified
+ };
+ using var cursor = Activity.ContentResolver!.Query(queryUri, projection, null, null, null);
+
+ if (cursor?.MoveToFirst() == true)
+ {
+ try
+ {
+ var columnIndex = cursor.GetColumnIndex(Document.ColumnLastModified);
+ if (columnIndex != -1)
+ {
+ var longValue = cursor.GetLong(columnIndex);
+ dateModified = longValue > 0 ? DateTimeOffset.FromUnixTimeMilliseconds(longValue) : null;
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)?
+ .Log(this, "Directory LastModified metadata reader failed: '{Exception}'", ex);
+ }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ // Data may not be available for this item or the URI may not be in the expected shape.
+ Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)?
+ .Log(this, "Directory basic properties metadata unavailable: '{Exception}'", ex);
+ }
+
+ return Task.FromResult(new StorageItemProperties(null, null, dateModified));
}
public async IAsyncEnumerable GetItemsAsync()
@@ -234,8 +287,8 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder
var projection = new[]
{
- DocumentsContract.Document.ColumnDocumentId,
- DocumentsContract.Document.ColumnMimeType
+ Document.ColumnDocumentId,
+ Document.ColumnMimeType
};
if (childrenUri != null)
{
@@ -247,8 +300,8 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder
var mime = cursor.GetString(1);
var id = cursor.GetString(0);
- bool isDirectory = mime == DocumentsContract.Document.MimeTypeDir;
- var uri = DocumentsContract.BuildDocumentUriUsingTree(root, id);
+ bool isDirectory = mime == Document.MimeTypeDir;
+ var uri = BuildDocumentUriUsingTree(root, id);
if (uri == null)
{
@@ -313,9 +366,9 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder
var projection = new[]
{
- DocumentsContract.Document.ColumnDocumentId,
- DocumentsContract.Document.ColumnMimeType,
- DocumentsContract.Document.ColumnDisplayName
+ Document.ColumnDocumentId,
+ Document.ColumnMimeType,
+ Document.ColumnDisplayName
};
if (childrenUri != null)
@@ -332,15 +385,15 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder
if (fileName != name)
{
continue;
- }
+ }
- bool mineDirectory = mime == DocumentsContract.Document.MimeTypeDir;
+ bool mineDirectory = mime == Document.MimeTypeDir;
if (isDirectory != mineDirectory)
{
return null;
}
- var uri = DocumentsContract.BuildDocumentUriUsingTree(root, id);
+ var uri = BuildDocumentUriUsingTree(root, id);
if (uri == null)
{
return null;
@@ -370,8 +423,8 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder
private (AndroidUri root, AndroidUri? treeUri) GetTreeUri()
{
var root = PermissionRoot ?? Uri;
- var folderId = root != Uri ? DocumentsContract.GetDocumentId(Uri) : DocumentsContract.GetTreeDocumentId(Uri);
- return (root, DocumentsContract.BuildChildDocumentsUriUsingTree(root, folderId));
+ var folderId = root != Uri ? GetDocumentId(Uri) : GetTreeDocumentId(Uri);
+ return (root, BuildChildDocumentsUriUsingTree(root, folderId));
}
}
@@ -419,10 +472,10 @@ internal sealed class AndroidStorageFile : AndroidStorageItem, IStorageBookmarkF
if (!OperatingSystem.IsAndroidVersionAtLeast(24))
return false;
- if (!DocumentsContract.IsDocumentUri(context, uri))
+ if (!IsDocumentUri(context, uri))
return false;
- var value = GetColumnValue(context, uri, DocumentsContract.Document.ColumnFlags);
+ var value = GetColumnValue(context, uri, Document.ColumnFlags);
if (!string.IsNullOrEmpty(value) && int.TryParse(value, out var flagsInt))
{
var flags = (DocumentContractFlags)flagsInt;
@@ -530,7 +583,7 @@ internal sealed class AndroidStorageFile : AndroidStorageItem, IStorageBookmarkF
if (Activity != null)
{
- DocumentsContract.DeleteDocument(Activity.ContentResolver!, Uri);
+ DeleteDocument(Activity.ContentResolver!, Uri);
}
}
@@ -553,7 +606,7 @@ internal sealed class AndroidStorageFile : AndroidStorageItem, IStorageBookmarkF
storageFolder.Uri is { } targetParentUri &&
await GetParentAsync() is AndroidStorageFolder parentFolder)
{
- movedUri = DocumentsContract.MoveDocument(contentResolver, Uri, parentFolder.Uri, targetParentUri);
+ movedUri = MoveDocument(contentResolver, Uri, parentFolder.Uri, targetParentUri);
}
}
catch (Exception)
diff --git a/src/Avalonia.Base/Animation/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.Base/Input/FindNextElementOptions.cs b/src/Avalonia.Base/Input/FindNextElementOptions.cs
index 72d83ec419..ee9c2e6fef 100644
--- a/src/Avalonia.Base/Input/FindNextElementOptions.cs
+++ b/src/Avalonia.Base/Input/FindNextElementOptions.cs
@@ -1,10 +1,4 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-
-namespace Avalonia.Input
+namespace Avalonia.Input
{
///
/// Provides options to customize the behavior when identifying the next element to focus
@@ -12,14 +6,28 @@ namespace Avalonia.Input
///
public sealed class FindNextElementOptions
{
+ ///
+ /// Gets or sets the element that will be treated as the starting point of the search
+ /// for the next focusable element. This does not need to be the element that is
+ /// currently focused. If null, is used.
+ ///
+ public IInputElement? FocusedElement { get; init; }
+
///
/// Gets or sets the root within which the search for the next
/// focusable element will be conducted.
///
///
+ ///
/// This property defines the boundary for focus navigation operations. It determines the root element
/// in the visual tree under which the focusable item search is performed. If not specified, the search
/// will default to the current scope.
+ ///
+ ///
+ /// This option is only used with , ,
+ /// , and . It is ignored for other
+ /// directions.
+ ///
///
public InputElement? SearchRoot { get; init; }
@@ -27,6 +35,11 @@ namespace Avalonia.Input
/// Gets or sets the rectangular region within the visual hierarchy that will be excluded
/// from consideration during focus navigation.
///
+ ///
+ /// This option is only used with , ,
+ /// , and . It is ignored for other
+ /// directions.
+ ///
public Rect ExclusionRect { get; init; }
///
@@ -35,12 +48,22 @@ namespace Avalonia.Input
/// which can be used as a preferred or prioritized target when navigating focus.
/// It can be null if no specific hint region is provided.
///
+ ///
+ /// This option is only used with , ,
+ /// , and . It is ignored for other
+ /// directions.
+ ///
public Rect? FocusHintRectangle { get; init; }
///
/// Specifies an optional override for the navigation strategy used in XY focus navigation.
/// This property allows customizing the focus movement behavior when navigating between UI elements.
///
+ ///
+ /// This option is only used with , ,
+ /// , and . It is ignored for other
+ /// directions.
+ ///
public XYFocusNavigationStrategy? NavigationStrategyOverride { get; init; }
///
@@ -49,6 +72,11 @@ namespace Avalonia.Input
/// the navigation logic disregards obstructions that may block a potential
/// focus target, allowing elements behind such obstructions to be considered.
///
+ ///
+ /// This option is only used with , ,
+ /// , and . It is ignored for other
+ /// directions.
+ ///
public bool IgnoreOcclusivity { get; init; }
}
}
diff --git a/src/Avalonia.Base/Input/FocusManager.cs b/src/Avalonia.Base/Input/FocusManager.cs
index dc62171f48..a273ff6d89 100644
--- a/src/Avalonia.Base/Input/FocusManager.cs
+++ b/src/Avalonia.Base/Input/FocusManager.cs
@@ -172,7 +172,7 @@ namespace Avalonia.Input
ValidateDirection(direction);
var focusOptions = ToFocusOptions(options, true);
- var result = FindAndSetNextFocus(direction, focusOptions);
+ var result = FindAndSetNextFocus(options?.FocusedElement ?? Current, direction, focusOptions);
_reusableFocusOptions = focusOptions;
return result;
}
@@ -319,7 +319,7 @@ namespace Avalonia.Input
ValidateDirection(direction);
var focusOptions = ToFocusOptions(options, false);
- var result = FindNextFocus(direction, focusOptions);
+ var result = FindNextFocus(options?.FocusedElement ?? Current, direction, focusOptions);
_reusableFocusOptions = focusOptions;
return result;
}
@@ -368,27 +368,29 @@ namespace Avalonia.Input
return focusOptions;
}
- internal IInputElement? FindNextFocus(NavigationDirection direction, XYFocusOptions focusOptions, bool updateManifolds = true)
+ private IInputElement? FindNextFocus(
+ IInputElement? focusedElement,
+ NavigationDirection direction,
+ XYFocusOptions focusOptions,
+ bool updateManifolds = true)
{
- IInputElement? nextFocusedElement = null;
+ IInputElement? nextFocusedElement;
- var currentlyFocusedElement = Current;
-
- if (direction is NavigationDirection.Previous or NavigationDirection.Next || currentlyFocusedElement == null)
+ if (direction is NavigationDirection.Previous or NavigationDirection.Next || focusedElement == null)
{
var isReverse = direction == NavigationDirection.Previous;
- nextFocusedElement = ProcessTabStopInternal(isReverse, true);
+ nextFocusedElement = ProcessTabStopInternal(focusedElement, isReverse, true);
}
else
{
- if (currentlyFocusedElement is InputElement inputElement &&
+ if (focusedElement is InputElement inputElement &&
XYFocus.GetBoundsForRanking(inputElement, focusOptions.IgnoreClipping) is { } bounds)
{
focusOptions.FocusedElementBounds = bounds;
}
nextFocusedElement = _xyFocus.GetNextFocusableElement(direction,
- currentlyFocusedElement as InputElement,
+ focusedElement as InputElement,
null,
updateManifolds,
focusOptions);
@@ -509,14 +511,14 @@ namespace Avalonia.Input
return lastFocus;
}
- private IInputElement? ProcessTabStopInternal(bool isReverse, bool queryOnly)
+ private IInputElement? ProcessTabStopInternal(IInputElement? focusedElement, bool isReverse, bool queryOnly)
{
IInputElement? newTabStop = null;
- var defaultCandidateTabStop = GetTabStopCandidateElement(isReverse, queryOnly, out var didCycleFocusAtRootVisualScope);
+ var defaultCandidateTabStop = GetTabStopCandidateElement(focusedElement, isReverse, queryOnly, out var didCycleFocusAtRootVisualScope);
var isTabStopOverriden = InputElement.ProcessTabStop(_contentRoot,
- Current,
+ focusedElement,
defaultCandidateTabStop,
isReverse,
didCycleFocusAtRootVisualScope,
@@ -535,24 +537,27 @@ namespace Avalonia.Input
return newTabStop;
}
- private IInputElement? GetTabStopCandidateElement(bool isReverse, bool queryOnly, out bool didCycleFocusAtRootVisualScope)
+ private IInputElement? GetTabStopCandidateElement(
+ IInputElement? focusedElement,
+ bool isReverse,
+ bool queryOnly,
+ out bool didCycleFocusAtRootVisualScope)
{
didCycleFocusAtRootVisualScope = false;
- var currentFocus = Current;
- IInputElement? newTabStop = null;
- var root = this._contentRoot as IInputElement;
+ IInputElement? newTabStop;
+ var root = _contentRoot;
if (root == null)
return null;
bool internalCycleWorkaround = false;
- if (Current != null)
+ if (focusedElement != null)
{
- internalCycleWorkaround = CanProcessTabStop(isReverse);
+ internalCycleWorkaround = CanProcessTabStop(focusedElement, isReverse);
}
- if (currentFocus == null)
+ if (focusedElement == null)
{
if (!isReverse)
{
@@ -567,7 +572,7 @@ namespace Avalonia.Input
}
else if (!isReverse)
{
- newTabStop = GetNextTabStop();
+ newTabStop = GetNextTabStop(focusedElement);
if (newTabStop == null && (internalCycleWorkaround || queryOnly))
{
@@ -578,7 +583,7 @@ namespace Avalonia.Input
}
else
{
- newTabStop = GetPreviousTabStop();
+ newTabStop = GetPreviousTabStop(focusedElement);
if (newTabStop == null && (internalCycleWorkaround || queryOnly))
{
@@ -590,9 +595,9 @@ namespace Avalonia.Input
return newTabStop;
}
- private IInputElement? GetNextTabStop(IInputElement? currentTabStop = null, bool ignoreCurrentTabStop = false)
+ private IInputElement? GetNextTabStop(IInputElement? currentTabStop, bool ignoreCurrentTabStop = false)
{
- var focused = currentTabStop ?? Current;
+ var focused = currentTabStop;
if (focused == null || _contentRoot == null)
{
return null;
@@ -698,9 +703,9 @@ namespace Avalonia.Input
return newTabStop;
}
- private IInputElement? GetPreviousTabStop(IInputElement? currentTabStop = null, bool ignoreCurrentTabStop = false)
+ private IInputElement? GetPreviousTabStop(IInputElement? currentTabStop, bool ignoreCurrentTabStop = false)
{
- var focused = currentTabStop ?? Current;
+ var focused = currentTabStop;
if (focused == null || _contentRoot == null)
{
return null;
@@ -930,23 +935,23 @@ namespace Avalonia.Input
return int.MaxValue;
}
- private bool CanProcessTabStop(bool isReverse)
+ private bool CanProcessTabStop(IInputElement? focusedElement, bool isReverse)
{
bool isFocusOnFirst = false;
bool isFocusOnLast = false;
bool canProcessTab = true;
- if (IsFocusedElementInPopup())
+ if (IsFocusedElementInPopup(focusedElement))
{
return true;
}
if (isReverse)
{
- isFocusOnFirst = IsFocusOnFirstTabStop();
+ isFocusOnFirst = IsFocusOnFirstTabStop(focusedElement);
}
else
{
- isFocusOnLast = IsFocusOnLastTabStop();
+ isFocusOnLast = IsFocusOnLastTabStop(focusedElement);
}
if (isFocusOnFirst || isFocusOnLast)
@@ -961,7 +966,7 @@ namespace Avalonia.Input
if (edge != null)
{
var edgeParent = GetParentTabStopElement(edge);
- if (edgeParent is InputElement inputElement && KeyboardNavigation.GetTabNavigation(inputElement) == KeyboardNavigationMode.Once && edgeParent == GetParentTabStopElement(Current))
+ if (edgeParent is InputElement inputElement && KeyboardNavigation.GetTabNavigation(inputElement) == KeyboardNavigationMode.Once && edgeParent == GetParentTabStopElement(focusedElement))
{
canProcessTab = false;
}
@@ -975,13 +980,13 @@ namespace Avalonia.Input
{
if (isFocusOnLast || isFocusOnFirst)
{
- if (Current is InputElement inputElement && KeyboardNavigation.GetTabNavigation(inputElement) == KeyboardNavigationMode.Cycle)
+ if (focusedElement is InputElement inputElement && KeyboardNavigation.GetTabNavigation(inputElement) == KeyboardNavigationMode.Cycle)
{
canProcessTab = true;
}
else
{
- var focusedParent = GetParentTabStopElement(Current);
+ var focusedParent = GetParentTabStopElement(focusedElement);
while (focusedParent != null)
{
if (focusedParent is InputElement iE && KeyboardNavigation.GetTabNavigation(iE) == KeyboardNavigationMode.Cycle)
@@ -1041,9 +1046,9 @@ namespace Avalonia.Input
return null;
}
- private bool IsFocusOnLastTabStop()
+ private bool IsFocusOnLastTabStop(IInputElement? focusedElement)
{
- if (Current == null || _contentRoot is not Visual visual)
+ if (focusedElement == null || _contentRoot is not Visual visual)
return false;
var root = visual.VisualRoot as IInputElement;
@@ -1051,12 +1056,12 @@ namespace Avalonia.Input
var lastFocus = GetLastFocusableElement(root, null);
- return lastFocus == Current;
+ return lastFocus == focusedElement;
}
- private bool IsFocusOnFirstTabStop()
+ private bool IsFocusOnFirstTabStop(IInputElement? focusedElement)
{
- if (Current == null || _contentRoot is not Visual visual)
+ if (focusedElement == null || _contentRoot is not Visual visual)
return false;
var root = visual.VisualRoot as IInputElement;
@@ -1064,7 +1069,7 @@ namespace Avalonia.Input
var firstFocus = GetFirstFocusableElement(root, null);
- return firstFocus == Current;
+ return firstFocus == focusedElement;
}
private static IInputElement? GetFirstFocusableElement(IInputElement searchStart, IInputElement? firstFocus = null)
@@ -1091,7 +1096,7 @@ namespace Avalonia.Input
return lastFocus;
}
- private bool IsFocusedElementInPopup() => Current != null && GetRootOfPopupSubTree(Current) != null;
+ private bool IsFocusedElementInPopup(IInputElement? focusedElement) => focusedElement != null && GetRootOfPopupSubTree(focusedElement) != null;
private Visual? GetRootOfPopupSubTree(IInputElement? current)
{
@@ -1099,7 +1104,7 @@ namespace Avalonia.Input
return null;
}
- private bool FindAndSetNextFocus(NavigationDirection direction, XYFocusOptions xYFocusOptions)
+ private bool FindAndSetNextFocus(IInputElement? focusedElement, NavigationDirection direction, XYFocusOptions xYFocusOptions)
{
var focusChanged = false;
if (xYFocusOptions.UpdateManifoldsFromFocusHintRect && xYFocusOptions.FocusHintRectangle != null)
@@ -1107,7 +1112,7 @@ namespace Avalonia.Input
_xyFocus.SetManifoldsFromBounds(xYFocusOptions.FocusHintRectangle ?? default);
}
- if (FindNextFocus(direction, xYFocusOptions, false) is { } nextFocusedElement)
+ if (FindNextFocus(focusedElement, direction, xYFocusOptions, false) is { } nextFocusedElement)
{
focusChanged = nextFocusedElement.Focus();
diff --git a/src/Avalonia.Base/Input/IFocusManager.cs b/src/Avalonia.Base/Input/IFocusManager.cs
index 9bd1fb4239..d9e8d36f8b 100644
--- a/src/Avalonia.Base/Input/IFocusManager.cs
+++ b/src/Avalonia.Base/Input/IFocusManager.cs
@@ -41,10 +41,7 @@ namespace Avalonia.Input
/// , ,
/// and .
///
- ///
- /// The options to help identify the next element to receive focus.
- /// They only apply to directional navigation.
- ///
+ /// The options to help identify the next element to receive focus.
/// true if focus moved; otherwise, false.
bool TryMoveFocus(NavigationDirection direction, FindNextElementOptions? options = null);
@@ -69,10 +66,7 @@ namespace Avalonia.Input
/// , ,
/// and .
///
- ///
- /// The options to help identify the next element to receive focus.
- /// They only apply to directional navigation.
- ///
+ /// The options to help identify the next element to receive focus.
/// The next element to receive focus, if any.
IInputElement? FindNextElement(NavigationDirection direction, FindNextElementOptions? options = null);
}
diff --git a/src/Avalonia.Base/Rect.cs b/src/Avalonia.Base/Rect.cs
index 58a8c56c8b..9c901254a6 100644
--- a/src/Avalonia.Base/Rect.cs
+++ b/src/Avalonia.Base/Rect.cs
@@ -1,7 +1,6 @@
using System;
using System.Globalization;
using System.Numerics;
-using Avalonia.Animation.Animators;
using Avalonia.Utilities;
namespace Avalonia
diff --git a/src/Avalonia.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/Button.cs b/src/Avalonia.Controls/Button.cs
index ceadae432c..c094db57ec 100644
--- a/src/Avalonia.Controls/Button.cs
+++ b/src/Avalonia.Controls/Button.cs
@@ -543,10 +543,13 @@ namespace Avalonia.Controls
oldFlyout.Hide();
}
+ (oldFlyout as PopupFlyoutBase)?.SetDefaultPlacementTarget(null);
+
// Must unregister events here while a reference to the old flyout still exists
UnregisterFlyoutEvents(oldFlyout);
RegisterFlyoutEvents(newFlyout);
+ (newFlyout as PopupFlyoutBase)?.SetDefaultPlacementTarget(this);
UpdatePseudoClasses();
}
}
diff --git a/src/Avalonia.Controls/Carousel.cs b/src/Avalonia.Controls/Carousel.cs
index 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/ComboBox.cs b/src/Avalonia.Controls/ComboBox.cs
index c977ca0a38..eb1eab5856 100644
--- a/src/Avalonia.Controls/ComboBox.cs
+++ b/src/Avalonia.Controls/ComboBox.cs
@@ -62,13 +62,13 @@ namespace Avalonia.Controls
/// Defines the property.
///
public static readonly StyledProperty PlaceholderTextProperty =
- AvaloniaProperty.Register(nameof(PlaceholderText));
+ TextBox.PlaceholderTextProperty.AddOwner();
///
/// Defines the property.
///
public static readonly StyledProperty PlaceholderForegroundProperty =
- AvaloniaProperty.Register(nameof(PlaceholderForeground));
+ TextBox.PlaceholderForegroundProperty.AddOwner();
///
/// Defines the property.
diff --git a/src/Avalonia.Controls/CommandBar/CommandBar.cs b/src/Avalonia.Controls/CommandBar/CommandBar.cs
index 2f068cbb9f..392ffb7ddc 100644
--- a/src/Avalonia.Controls/CommandBar/CommandBar.cs
+++ b/src/Avalonia.Controls/CommandBar/CommandBar.cs
@@ -142,11 +142,9 @@ namespace Avalonia.Controls
OverflowItems = new ReadOnlyObservableCollection(_overflowItems);
var primaryCommands = new ObservableCollection();
- primaryCommands.CollectionChanged += OnPrimaryCommandsChanged;
SetCurrentValue(PrimaryCommandsProperty, (IList)primaryCommands);
var secondaryCommands = new ObservableCollection();
- secondaryCommands.CollectionChanged += OnSecondaryCommandsChanged;
SetCurrentValue(SecondaryCommandsProperty, (IList)secondaryCommands);
SizeChanged += CommandBar_SizeChanged;
diff --git a/src/Avalonia.Controls/Converters/BorderGapMaskConverter.cs b/src/Avalonia.Controls/Converters/BorderGapMaskConverter.cs
new file mode 100644
index 0000000000..913b2f1534
--- /dev/null
+++ b/src/Avalonia.Controls/Converters/BorderGapMaskConverter.cs
@@ -0,0 +1,126 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using Avalonia.Controls.Shapes;
+using Avalonia.Data.Converters;
+using Avalonia.Media;
+
+namespace Avalonia.Controls.Converters
+{
+ // Ported from https://github.com/dotnet/wpf/blob/main/src/Microsoft.DotNet.Wpf/src/PresentationFramework/System/Windows/Controls/BorderGapMaskConverter.cs
+
+ ///
+ /// Converter that generates the visual brush for
+ ///
+ public class BorderGapMaskConverter : IMultiValueConverter
+ {
+ ///
+ /// Convert a value.
+ ///
+ /// values as produced by source binding
+ /// target type
+ /// converter parameter
+ /// culture information
+ ///
+ /// Converted value.
+ /// Visual Brush that is used as the opacity mask for the Border
+ /// in the style for GroupBox.
+ ///
+ public object? Convert(IList
public async Task PopToRootAsync(IPageTransition? transition)
{
+ if (_isNavigating)
+ return;
_overrideTransition = transition;
_hasOverrideTransition = true;
- try { await PopToRootAsync(); }
- finally { _hasOverrideTransition = false; _overrideTransition = null; }
+ try
+ {
+ await PopToRootAsync();
+ }
+ finally
+ {
+ _hasOverrideTransition = false;
+ _overrideTransition = null;
+ }
}
///
/// Pops to a specific page in the stack using .
///
+ ///
+ /// All pages above are removed from the stack. Each removed page
+ /// receives a call with ,
+ /// and is raised for each one. The target page receives
+ /// with . If
+ /// is already the top of the stack the method returns immediately
+ /// without raising any events.
+ ///
+ /// If a navigation transition is already in progress ( is ),
+ /// the call returns immediately without modifying the stack and without raising any events.
+ ///
+ ///
public async Task PopToPageAsync(Page page)
{
ArgumentNullException.ThrowIfNull(page);
@@ -1022,10 +1099,13 @@ namespace Avalonia.Controls
if (!_pageSet.Contains(page))
throw new ArgumentException("Page is not in the navigation stack.", nameof(page));
+ if (_navigationStack.Count > 0 && ReferenceEquals(_navigationStack.Peek(), page))
+ return;
+
if (_isNavigating)
return;
- _isNavigating = true;
+ SetAndRaise(IsNavigatingProperty, ref _isNavigating, true);
try
{
var currentPage = CurrentPage;
@@ -1037,33 +1117,21 @@ namespace Avalonia.Controls
return;
}
- bool isIncc = Pages is INotifyCollectionChanged;
var poppedPages = new List();
void TearDownPopped(Page popped)
{
_pageSet.Remove(popped);
- if (!isIncc && popped is ILogical poppedLogical)
+ if (popped is ILogical poppedLogical)
LogicalChildren.Remove(poppedLogical);
popped.Navigation = null;
popped.SetInNavigationPage(false);
+ popped.SafeAreaPadding = default;
poppedPages.Add(popped);
}
- if (Pages is Stack stack)
- {
- while (stack.Count > 1 && stack.Peek() != page)
- TearDownPopped(stack.Pop());
- }
- else if (Pages is IList list)
- {
- while (list.Count > 1 && list[list.Count - 1] != page)
- {
- var last = list[list.Count - 1];
- list.RemoveAt(list.Count - 1);
- TearDownPopped(last);
- }
- }
+ while (_navigationStack.Count > 1 && _navigationStack.Peek() != page)
+ TearDownPopped(_navigationStack.Pop());
InvalidateNavigationStackCache();
_isPop = true;
@@ -1085,7 +1153,7 @@ namespace Avalonia.Controls
}
finally
{
- _isNavigating = false;
+ SetAndRaise(IsNavigatingProperty, ref _isNavigating, false);
}
}
@@ -1094,28 +1162,50 @@ namespace Avalonia.Controls
///
public async Task PopToPageAsync(Page page, IPageTransition? transition)
{
+ if (_isNavigating)
+ return;
_overrideTransition = transition;
_hasOverrideTransition = true;
- try { await PopToPageAsync(page); }
- finally { _hasOverrideTransition = false; _overrideTransition = null; }
+ try
+ {
+ await PopToPageAsync(page);
+ }
+ finally
+ {
+ _hasOverrideTransition = false;
+ _overrideTransition = null;
+ }
}
///
/// Pushes a modal page using .
///
+ ///
+ /// If a navigation transition is already in progress ( is ),
+ /// the call returns immediately without pushing the page and without raising any events.
+ ///
public async Task PushModalAsync(Page page)
{
ArgumentNullException.ThrowIfNull(page);
if (_isNavigating)
return;
+ ThrowIfPageIsAlreadyPresent(page);
- _isNavigating = true;
+ SetAndRaise(IsNavigatingProperty, ref _isNavigating, true);
try
{
var previousModal = _modalStack.Count > 0 ? (Page?)_modalStack.Peek() : null;
-
var coveredPage = previousModal ?? CurrentPage;
+ if (coveredPage != null)
+ {
+ var navigatingArgs = new NavigatingFromEventArgs(page, NavigationType.PushModal);
+ await coveredPage.SendNavigatingAsync(navigatingArgs);
+
+ if (navigatingArgs.Cancel)
+ return;
+ }
+
_modalStack.Push(page);
_cachedModalStack = null;
page.Navigation = this;
@@ -1143,11 +1233,11 @@ namespace Avalonia.Controls
}
_currentModalTransition?.Cancel();
_currentModalTransition?.Dispose();
- _currentModalTransition = new CancellationTokenSource();
- var modalCt = _currentModalTransition.Token;
+ var modalCts = new CancellationTokenSource();
+ _currentModalTransition = modalCts;
try
{
- await effectiveModalTransition.Start(null, _modalPresenter, forward: true, modalCt);
+ await effectiveModalTransition.Start(null, _modalPresenter, forward: true, modalCts.Token);
}
catch (OperationCanceledException) { /* Transition cancelled; lifecycle events still fire below. */ }
catch (Exception ex)
@@ -1155,11 +1245,18 @@ namespace Avalonia.Controls
Logger.TryGet(LogEventLevel.Error, LogArea.Control)
?.Log(this, "Modal transition threw an unhandled exception: {Exception}", ex);
}
-
- if (_modalBackPresenter != null)
+ finally
{
- _modalBackPresenter.IsVisible = false;
- _modalBackPresenter.Content = null;
+ if (_modalBackPresenter != null)
+ {
+ _modalBackPresenter.IsVisible = false;
+ _modalBackPresenter.Content = null;
+ }
+ if (ReferenceEquals(_currentModalTransition, modalCts))
+ {
+ _currentModalTransition = null;
+ modalCts.Dispose();
+ }
}
}
else
@@ -1175,7 +1272,7 @@ namespace Avalonia.Controls
}
finally
{
- _isNavigating = false;
+ SetAndRaise(IsNavigatingProperty, ref _isNavigating, false);
}
}
@@ -1184,15 +1281,28 @@ namespace Avalonia.Controls
///
public async Task PushModalAsync(Page page, IPageTransition? transition)
{
+ if (_isNavigating)
+ return;
_overrideTransition = transition;
_hasOverrideTransition = true;
- try { await PushModalAsync(page); }
- finally { _hasOverrideTransition = false; _overrideTransition = null; }
+ try
+ {
+ await PushModalAsync(page);
+ }
+ finally
+ {
+ _hasOverrideTransition = false;
+ _overrideTransition = null;
+ }
}
///
/// Pops the top modal page using .
///
+ ///
+ /// Returns if there are no modal pages or if a navigation transition
+ /// is already in progress ( is ).
+ ///
public async Task PopModalAsync()
{
if (_modalStack.Count == 0)
@@ -1200,10 +1310,30 @@ namespace Avalonia.Controls
if (_isNavigating)
return null;
- _isNavigating = true;
+ SetAndRaise(IsNavigatingProperty, ref _isNavigating, true);
try
{
- var modal = _modalStack.Pop();
+ var modal = _modalStack.Peek();
+ Page? revealedPage;
+ if (_modalStack.Count < 2)
+ {
+ revealedPage = CurrentPage;
+ }
+ else
+ {
+ using var enumerator = _modalStack.GetEnumerator();
+ enumerator.MoveNext();
+ enumerator.MoveNext();
+ revealedPage = enumerator.Current;
+ }
+
+ var navigatingArgs = new NavigatingFromEventArgs(revealedPage, NavigationType.PopModal);
+ await modal.SendNavigatingAsync(navigatingArgs);
+
+ if (navigatingArgs.Cancel)
+ return null;
+
+ modal = _modalStack.Pop();
_cachedModalStack = null;
modal.Navigation = null;
@@ -1226,11 +1356,11 @@ namespace Avalonia.Controls
_currentModalTransition?.Cancel();
_currentModalTransition?.Dispose();
- _currentModalTransition = new CancellationTokenSource();
- var popCt1 = _currentModalTransition.Token;
+ var popCts1 = new CancellationTokenSource();
+ _currentModalTransition = popCts1;
try
{
- await effectiveModalTransition.Start(_modalPresenter, null, forward: false, popCt1);
+ await effectiveModalTransition.Start(_modalPresenter, null, forward: false, popCts1.Token);
SwapModalPresenters();
if (_modalBackPresenter != null)
_modalBackPresenter.Content = null;
@@ -1244,6 +1374,11 @@ namespace Avalonia.Controls
finally
{
SetCurrentValue(ModalContentProperty, (object?)next);
+ if (ReferenceEquals(_currentModalTransition, popCts1))
+ {
+ _currentModalTransition = null;
+ popCts1.Dispose();
+ }
}
}
else
@@ -1257,11 +1392,11 @@ namespace Avalonia.Controls
{
_currentModalTransition?.Cancel();
_currentModalTransition?.Dispose();
- _currentModalTransition = new CancellationTokenSource();
- var popCt2 = _currentModalTransition.Token;
+ var popCts2 = new CancellationTokenSource();
+ _currentModalTransition = popCts2;
try
{
- await effectiveModalTransition.Start(_modalPresenter, null, forward: false, popCt2);
+ await effectiveModalTransition.Start(_modalPresenter, null, forward: false, popCts2.Token);
}
catch (OperationCanceledException) { /* Transition cancelled; lifecycle events still fire below. */ }
catch (Exception ex)
@@ -1273,6 +1408,11 @@ namespace Avalonia.Controls
{
SetCurrentValue(IsModalVisibleProperty, false);
SetCurrentValue(ModalContentProperty, (object?)null);
+ if (ReferenceEquals(_currentModalTransition, popCts2))
+ {
+ _currentModalTransition = null;
+ popCts2.Dispose();
+ }
}
}
else
@@ -1282,7 +1422,6 @@ namespace Avalonia.Controls
}
}
- var revealedPage = _modalStack.Count > 0 ? (Page?)_modalStack.Peek() : CurrentPage;
modal.SendNavigatedFrom(new NavigatedFromEventArgs(revealedPage, NavigationType.PopModal));
revealedPage?.SendNavigatedTo(new NavigatedToEventArgs(modal, NavigationType.PopModal));
@@ -1291,7 +1430,7 @@ namespace Avalonia.Controls
}
finally
{
- _isNavigating = false;
+ SetAndRaise(IsNavigatingProperty, ref _isNavigating, false);
}
}
@@ -1300,15 +1439,41 @@ namespace Avalonia.Controls
///
public async Task PopModalAsync(IPageTransition? transition)
{
+ if (_isNavigating)
+ return null;
_overrideTransition = transition;
_hasOverrideTransition = true;
- try { return await PopModalAsync(); }
- finally { _hasOverrideTransition = false; _overrideTransition = null; }
+ try
+ {
+ return await PopModalAsync();
+ }
+ finally
+ {
+ _hasOverrideTransition = false;
+ _overrideTransition = null;
+ }
}
///
/// Pops all modal pages using .
///
+ ///
+ /// All modals are dismissed in a single transition rather than one-by-one, so lifecycle
+ /// events differ from calling in a loop:
+ ///
+ /// -
+ /// is consulted only on the topmost modal. Intermediate
+ /// modals do not receive a cancellation opportunity. If the top modal cancels, the
+ /// entire dismiss is aborted and no modals are popped.
+ ///
+ /// -
+ /// fires on every dismissed modal in LIFO order.
+ ///
+ /// -
+ /// fires only on .
+ ///
+ ///
+ ///
public async Task PopAllModalsAsync()
{
if (_modalStack.Count == 0)
@@ -1316,9 +1481,18 @@ namespace Avalonia.Controls
if (_isNavigating)
return;
- _isNavigating = true;
+ SetAndRaise(IsNavigatingProperty, ref _isNavigating, true);
try
{
+ var topModal = _modalStack.Peek();
+ var revealedPage = CurrentPage;
+
+ var navigatingArgs = new NavigatingFromEventArgs(revealedPage, NavigationType.PopModal);
+ await topModal.SendNavigatingAsync(navigatingArgs);
+
+ if (navigatingArgs.Cancel)
+ return;
+
var effectiveModalTransition = _hasOverrideTransition ? _overrideTransition : ModalTransition;
_hasOverrideTransition = false;
_overrideTransition = null;
@@ -1327,10 +1501,11 @@ namespace Avalonia.Controls
{
_currentModalTransition?.Cancel();
_currentModalTransition?.Dispose();
- _currentModalTransition = new CancellationTokenSource();
+ var allModalsCts = new CancellationTokenSource();
+ _currentModalTransition = allModalsCts;
try
{
- await effectiveModalTransition.Start(_modalPresenter, null, forward: false, _currentModalTransition.Token);
+ await effectiveModalTransition.Start(_modalPresenter, null, forward: false, allModalsCts.Token);
}
catch (OperationCanceledException) { /* Transition cancelled; lifecycle events still fire below. */ }
catch (Exception ex)
@@ -1338,13 +1513,19 @@ namespace Avalonia.Controls
Logger.TryGet(LogEventLevel.Error, LogArea.Control)
?.Log(this, "Modal transition threw an unhandled exception: {Exception}", ex);
}
+ finally
+ {
+ if (ReferenceEquals(_currentModalTransition, allModalsCts))
+ {
+ _currentModalTransition = null;
+ allModalsCts.Dispose();
+ }
+ }
}
SetCurrentValue(ModalContentProperty, (object?)null);
SetCurrentValue(IsModalVisibleProperty, false);
- Page? topModal = _modalStack.Count > 0 ? _modalStack.Peek() : null;
-
while (_modalStack.Count > 0)
{
var modal = _modalStack.Pop();
@@ -1356,82 +1537,74 @@ namespace Avalonia.Controls
}
_cachedModalStack = null;
- var newCurrentPage = CurrentPage;
- newCurrentPage?.SendNavigatedTo(new NavigatedToEventArgs(topModal, NavigationType.PopModal));
+ revealedPage?.SendNavigatedTo(new NavigatedToEventArgs(topModal, NavigationType.PopModal));
}
finally
{
- _isNavigating = false;
+ SetAndRaise(IsNavigatingProperty, ref _isNavigating, false);
}
}
///
/// Pops all modal pages using .
///
+ ///
public async Task PopAllModalsAsync(IPageTransition? transition)
{
+ if (_isNavigating)
+ return;
_overrideTransition = transition;
_hasOverrideTransition = true;
- try { await PopAllModalsAsync(); }
- finally { _hasOverrideTransition = false; _overrideTransition = null; }
+ try
+ {
+ await PopAllModalsAsync();
+ }
+ finally
+ {
+ _hasOverrideTransition = false;
+ _overrideTransition = null;
+ }
}
///
/// Removes a page from the navigation stack without animation.
///
+ ///
+ /// If a navigation transition is already in progress ( is
+ /// ), this call is a no-op: the page is not removed and no events
+ /// are raised.
+ ///
public void RemovePage(Page page)
{
ArgumentNullException.ThrowIfNull(page);
if (_isNavigating)
return;
- if (Pages is Stack stack)
+ if (_navigationStack.Count > 0 && ReferenceEquals(_navigationStack.Peek(), page))
{
- if (stack.Count > 0 && ReferenceEquals(stack.Peek(), page))
- {
- var old = ExecutePopCore();
- if (old != null)
- SendPopLifecycleEvents(old, NavigationType.Pop);
- PageRemoved?.Invoke(this, new PageRemovedEventArgs(page));
- return;
- }
-
- bool found = false;
- foreach (var p in stack)
- if (ReferenceEquals(p, page)) { found = true; break; }
- if (!found)
- return;
-
- var retained = new List(stack.Count - 1);
- foreach (var p in stack)
+ var old = ExecutePopCore();
+ if (old != null)
{
- if (!ReferenceEquals(p, page))
- retained.Add(p);
+ var newCurrentPage = CurrentPage;
+ old.SendNavigatedFrom(new NavigatedFromEventArgs(newCurrentPage, NavigationType.Remove));
+ newCurrentPage?.SendNavigatedTo(new NavigatedToEventArgs(old, NavigationType.Remove));
}
- stack.Clear();
- for (int i = retained.Count - 1; i >= 0; i--)
- stack.Push(retained[i]);
+ PageRemoved?.Invoke(this, new PageRemovedEventArgs(page));
+ return;
}
- else if (Pages is IList list)
- {
- int idx = -1;
- for (int i = 0; i < list.Count; i++)
- if (ReferenceEquals(list[i], page)) { idx = i; break; }
- if (idx < 0)
- return;
- if (idx == list.Count - 1)
- {
- var old = ExecutePopCore();
- if (old != null)
- SendPopLifecycleEvents(old, NavigationType.Pop);
- PageRemoved?.Invoke(this, new PageRemovedEventArgs(page));
- return;
- }
+ if (!_pageSet.Contains(page))
+ return;
- list.RemoveAt(idx);
+ var retained = new List(_navigationStack.Count - 1);
+ foreach (var p in _navigationStack)
+ {
+ if (!ReferenceEquals(p, page))
+ retained.Add(p);
}
- else return;
+ _navigationStack.Clear();
+ for (int i = retained.Count - 1; i >= 0; i--)
+ _navigationStack.Push(retained[i]);
_pageSet.Remove(page);
@@ -1441,8 +1614,7 @@ namespace Avalonia.Controls
page.SetInNavigationPage(false);
page.SafeAreaPadding = default;
- if (Pages is not INotifyCollectionChanged)
- LogicalChildren.Remove(page);
+ LogicalChildren.Remove(page);
InvalidateNavigationStackCache();
UpdateIsBackButtonEffectivelyVisible();
@@ -1453,6 +1625,11 @@ namespace Avalonia.Controls
///
/// Inserts a page into the stack before the specified page.
///
+ ///
+ /// If a navigation transition is already in progress ( is
+ /// ), this call is a no-op: the page is not inserted and no events
+ /// are raised.
+ ///
public void InsertPage(Page page, Page before)
{
ArgumentNullException.ThrowIfNull(page);
@@ -1460,52 +1637,35 @@ namespace Avalonia.Controls
if (_isNavigating)
return;
- if (_pageSet.Contains(page))
- throw new InvalidOperationException("The page is already in the navigation stack.");
-
- bool inserted = false;
+ ThrowIfPageIsAlreadyPresent(page);
- if (Pages is Stack stack)
+ var arr = _navigationStack.ToArray();
+ int beforeIdx = -1;
+ for (int i = 0; i < arr.Length; i++)
{
- var arr = stack.ToArray();
- int beforeIdx = -1;
- for (int i = 0; i < arr.Length; i++)
- if (ReferenceEquals(arr[i], before)) { beforeIdx = i; break; }
- if (beforeIdx < 0)
- return;
-
- stack.Clear();
- for (int i = arr.Length - 1; i >= 0; i--)
+ if (ReferenceEquals(arr[i], before))
{
- if (i == beforeIdx)
- stack.Push(page);
- stack.Push(arr[i]);
+ beforeIdx = i;
+ break;
}
-
- inserted = true;
}
- else if (Pages is IList list)
- {
- int beforeIdx = -1;
- for (int i = 0; i < list.Count; i++)
- if (ReferenceEquals(list[i], before)) { beforeIdx = i; break; }
- if (beforeIdx < 0)
- return;
+ if (beforeIdx < 0)
+ throw new InvalidOperationException("The 'before' page is not in the navigation stack.");
- list.Insert(beforeIdx, page);
- inserted = true;
+ _navigationStack.Clear();
+ for (int i = arr.Length - 1; i >= 0; i--)
+ {
+ if (i == beforeIdx)
+ _navigationStack.Push(page);
+ _navigationStack.Push(arr[i]);
}
- if (!inserted)
- return;
-
_pageSet.Add(page);
page.Navigation = this;
page.SetInNavigationPage(true);
page.SafeAreaPadding = SafeAreaPadding;
- if (Pages is not System.Collections.Specialized.INotifyCollectionChanged)
- LogicalChildren.Add(page);
+ LogicalChildren.Add(page);
InvalidateNavigationStackCache();
UpdateIsBackButtonEffectivelyVisible();
@@ -1516,14 +1676,25 @@ namespace Avalonia.Controls
///
/// Replaces the top page with using .
///
+ ///
+ /// If a navigation transition is already in progress ( is ),
+ /// the call returns immediately without modifying the stack and without raising any events.
+ ///
public async Task ReplaceAsync(Page page)
{
ArgumentNullException.ThrowIfNull(page);
- if (StackDepth == 0) { await PushAsync(page); return; }
+ if (StackDepth == 0)
+ {
+ await PushAsync(page);
+ return;
+ }
+ if (ReferenceEquals(page, CurrentPage))
+ return;
if (_isNavigating)
return;
+ ThrowIfPageIsAlreadyPresent(page);
- _isNavigating = true;
+ SetAndRaise(IsNavigatingProperty, ref _isNavigating, true);
try
{
var previousPage = CurrentPage;
@@ -1537,10 +1708,22 @@ namespace Avalonia.Controls
}
ExecuteReplaceCore(page, previousPage);
+
+ await AwaitPageTransitionAsync();
+
+ if (previousPage != null)
+ {
+ previousPage.Navigation = null;
+ previousPage.SetInNavigationPage(false);
+ previousPage.SafeAreaPadding = default;
+ previousPage.SendNavigatedFrom(new NavigatedFromEventArgs(page, NavigationType.Replace));
+ }
+
+ page.SendNavigatedTo(new NavigatedToEventArgs(previousPage, NavigationType.Replace));
}
finally
{
- _isNavigating = false;
+ SetAndRaise(IsNavigatingProperty, ref _isNavigating, false);
}
}
@@ -1549,10 +1732,19 @@ namespace Avalonia.Controls
///
public async Task ReplaceAsync(Page page, IPageTransition? transition)
{
+ if (_isNavigating)
+ return;
_overrideTransition = transition;
_hasOverrideTransition = true;
- try { await ReplaceAsync(page); }
- finally { _hasOverrideTransition = false; _overrideTransition = null; }
+ try
+ {
+ await ReplaceAsync(page);
+ }
+ finally
+ {
+ _hasOverrideTransition = false;
+ _overrideTransition = null;
+ }
}
// navigationType is intentionally unused; lifecycle events are fired in each navigation
@@ -1567,16 +1759,7 @@ namespace Avalonia.Controls
_hasOverrideTransition = false;
_overrideTransition = null;
- Page? page = null;
- if (Pages is Stack pages)
- {
- pages.TryPeek(out page);
- }
- else if (Pages is IList list)
- {
- if (list.Count > 0)
- page = list[list.Count - 1];
- }
+ _navigationStack.TryPeek(out var page);
if (_contentHost != null && _pagePresenter != null && _pageBackPresenter != null)
{
@@ -1672,6 +1855,9 @@ namespace Avalonia.Controls
_barHeightSub?.Dispose();
_barHeightSub = null;
+ _backButtonContentSub?.Dispose();
+ _backButtonContentSub = null;
+
if (page != null)
{
_hasNavigationBarSub = page.GetObservable(HasNavigationBarProperty)
@@ -1688,6 +1874,9 @@ namespace Avalonia.Controls
_barHeightSub = page.GetObservable(BarHeightOverrideProperty)
.Subscribe(new AnonymousObserver(_ => UpdateEffectiveBarHeight()));
+
+ _backButtonContentSub = page.GetObservable(BackButtonContentProperty)
+ .Subscribe(new AnonymousObserver(_ => UpdateBackButtonContent()));
}
UpdateIsNavBarEffectivelyVisible();
@@ -1698,7 +1887,7 @@ namespace Avalonia.Controls
UpdateContentSafeAreaPadding();
UpdateIsBackButtonEffectivelyVisible();
UpdateIsBackButtonEffectivelyEnabled();
- UpdateDrawerToggleIcon();
+ UpdateBackButtonContent();
}
private async Task RunPageTransitionAsync(
@@ -1757,47 +1946,25 @@ namespace Avalonia.Controls
{
Page? removed = null;
- if (Pages is Stack pagesStack && pagesStack.Count > 0)
- {
- removed = pagesStack.Pop();
- _pageSet.Remove(removed);
- }
- else if (Pages is IList pagesList && pagesList.Count > 0)
+ if (_navigationStack.Count > 0)
{
- removed = pagesList[pagesList.Count - 1];
+ removed = _navigationStack.Pop();
_pageSet.Remove(removed);
- pagesList.RemoveAt(pagesList.Count - 1);
}
- if (Pages is Stack pushStack)
- pushStack.Push(page);
- else if (Pages is IList pushList)
- pushList.Add(page);
-
+ _navigationStack.Push(page);
_pageSet.Add(page);
InvalidateNavigationStackCache();
- if (Pages is not INotifyCollectionChanged)
- {
- if (removed is ILogical removedLogical)
- LogicalChildren.Remove(removedLogical);
- if (page is ILogical addedLogical)
- LogicalChildren.Add(addedLogical);
- }
+ if (removed is ILogical removedLogical)
+ LogicalChildren.Remove(removedLogical);
+ if (page is ILogical addedLogical)
+ LogicalChildren.Add(addedLogical);
page.Navigation = this;
page.SetInNavigationPage(true);
UpdateActivePage();
-
- if (replacedPage != null)
- {
- replacedPage.Navigation = null;
- replacedPage.SetInNavigationPage(false);
- replacedPage.SendNavigatedFrom(new NavigatedFromEventArgs(page, NavigationType.Replace));
- }
-
- page.SendNavigatedTo(new NavigatedToEventArgs(replacedPage, NavigationType.Replace));
}
private void SwapModalPresenters()
@@ -1813,6 +1980,36 @@ namespace Avalonia.Controls
private void InvalidateNavigationStackCache() => _cachedNavigationStack = null;
+ private void RestoreNavigationState()
+ {
+ foreach (var page in NavigationStack)
+ {
+ page.Navigation = this;
+ page.SetInNavigationPage(true);
+ }
+
+ foreach (var modal in _modalStack)
+ {
+ modal.Navigation = this;
+ modal.SetInNavigationPage(true);
+ }
+ }
+
+ private void ClearNavigationState()
+ {
+ foreach (var modal in _modalStack)
+ {
+ modal.Navigation = null;
+ modal.SetInNavigationPage(false);
+ }
+
+ foreach (var page in NavigationStack)
+ {
+ page.Navigation = null;
+ page.SetInNavigationPage(false);
+ }
+ }
+
internal void UpdateIsBackButtonEffectivelyVisible()
{
var depth = StackDepth;
@@ -1855,31 +2052,34 @@ namespace Avalonia.Controls
{
_drawerPage = drawerPage;
UpdateIsBackButtonEffectivelyVisible();
- UpdateDrawerToggleIcon();
+ UpdateBackButtonContent();
}
-
- private void UpdateDrawerToggleIcon()
+ private void UpdateBackButtonContent()
{
- if (_drawerPage == null || CurrentPage == null)
- return;
+ object? content = CurrentPage != null ? GetBackButtonContent(CurrentPage) : null;
- bool showToggle = _drawerPage.DrawerBehavior != DrawerBehavior.Locked
+ bool showToggle = _drawerPage != null
+ && CurrentPage != null
+ && StackDepth <= 1
+ && _drawerPage.DrawerBehavior != DrawerBehavior.Locked
&& _drawerPage.DrawerBehavior != DrawerBehavior.Disabled;
- if (StackDepth <= 1 && showToggle)
+ if (content is null
+ && showToggle
+ && this.TryFindResource("NavigationPageMenuIcon", out var iconData)
+ && iconData is StreamGeometry geometry)
{
- if (GetBackButtonContent(CurrentPage) is null
- && this.TryFindResource("NavigationPageMenuIcon", out var iconData)
- && iconData is StreamGeometry geometry)
- {
- SetBackButtonContent(CurrentPage, new PathIcon { Data = geometry });
- }
+ content = new PathIcon { Data = geometry };
}
- else
+
+ if (_backButtonDefaultIcon != null)
+ _backButtonDefaultIcon.IsVisible = content is null;
+
+ if (_backButtonContentPresenter != null)
{
- if (GetBackButtonContent(CurrentPage) is PathIcon)
- SetBackButtonContent(CurrentPage, null);
+ _backButtonContentPresenter.Content = content;
+ _backButtonContentPresenter.IsVisible = content is not null;
}
UpdateBackButtonAccessibility();
@@ -1903,7 +2103,7 @@ namespace Avalonia.Controls
{
e.Handled = true;
_lastSwipeGestureId = e.Id;
- _ = PopAsync();
+ _ = PopAsync(); // gesture handler cannot be async; fire-and-forget is intentional
}
}
@@ -1918,7 +2118,7 @@ namespace Avalonia.Controls
if (e.Key == Key.Left && e.KeyModifiers == KeyModifiers.Alt && StackDepth > 1)
{
- _ = PopAsync();
+ _ = PopAsync(); // key handler cannot be async; fire-and-forget is intentional
e.Handled = true;
}
}
diff --git a/src/Avalonia.Controls/Page/Page.cs b/src/Avalonia.Controls/Page/Page.cs
index 48b7bd1b0c..a12ce76ddf 100644
--- a/src/Avalonia.Controls/Page/Page.cs
+++ b/src/Avalonia.Controls/Page/Page.cs
@@ -2,6 +2,7 @@ using System;
using System.Threading.Tasks;
using Avalonia.Automation;
using Avalonia.Controls.Primitives;
+using Avalonia.Controls.Templates;
using Avalonia.Interactivity;
namespace Avalonia.Controls
@@ -31,6 +32,12 @@ namespace Avalonia.Controls
public static readonly StyledProperty IconProperty =
AvaloniaProperty.Register(nameof(Icon));
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty IconTemplateProperty =
+ AvaloniaProperty.Register(nameof(IconTemplate));
+
///
/// Defines the property.
///
@@ -94,6 +101,15 @@ namespace Avalonia.Controls
set => SetValue(IconProperty, value);
}
+ ///
+ /// Gets or sets the data template used to display the icon.
+ ///
+ public IDataTemplate? IconTemplate
+ {
+ get => GetValue(IconTemplateProperty);
+ set => SetValue(IconTemplateProperty, value);
+ }
+
///
/// Gets or sets the safe-area padding applied to this page's content.
///
@@ -147,6 +163,14 @@ namespace Avalonia.Controls
///
/// Occurs when the page is about to be navigated from.
///
+ ///
+ /// Each subscriber is awaited in turn. Set to
+ /// to abort the navigation; remaining subscribers are not invoked once
+ /// cancellation is requested. If a subscriber throws an exception, the exception propagates
+ /// to the calling navigation method (such as )
+ /// and the navigation is aborted. Subscribers should use try/catch internally if they need
+ /// guaranteed cancellation semantics regardless of errors.
+ ///
public event Func? Navigating;
///
@@ -162,6 +186,11 @@ namespace Avalonia.Controls
///
/// Called when the page is about to be navigated from.
///
+ ///
+ /// Setting to here
+ /// prevents the async handlers from running and aborts the
+ /// navigation. This method is called before the event.
+ ///
protected virtual void OnNavigatingFrom(NavigatingFromEventArgs args) { }
///
@@ -181,11 +210,18 @@ namespace Avalonia.Controls
{
OnNavigatingFrom(args);
+ if (args.Cancel)
+ return;
+
var navigating = Navigating;
if (navigating != null)
{
foreach (Func handler in navigating.GetInvocationList())
+ {
await handler(args);
+ if (args.Cancel)
+ return;
+ }
}
}
@@ -201,7 +237,7 @@ namespace Avalonia.Controls
UpdateContentSafeAreaPadding();
if (change.Property == HeaderProperty)
- AutomationProperties.SetName(this, change.NewValue as string ?? string.Empty);
+ AutomationProperties.SetName(this, change.GetNewValue() as string ?? string.Empty);
}
protected override void OnLoaded(RoutedEventArgs e)
diff --git a/src/Avalonia.Controls/Page/PageNavigationHost.cs b/src/Avalonia.Controls/Page/PageNavigationHost.cs
index 06ef2d697a..025a8166ab 100644
--- a/src/Avalonia.Controls/Page/PageNavigationHost.cs
+++ b/src/Avalonia.Controls/Page/PageNavigationHost.cs
@@ -129,12 +129,14 @@ namespace Avalonia.Controls
private void ContentPresenter_PropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
{
- if (e.Property == ContentPresenter.ChildProperty
- && e.NewValue is Page page
- && _insetManager != null)
- {
- page.SafeAreaPadding = _insetManager.SafeAreaPadding;
- }
+ if (e.Property != ContentPresenter.ChildProperty)
+ return;
+
+ if (e.GetOldValue() is Page oldPage)
+ oldPage.SafeAreaPadding = default;
+
+ if (e.GetNewValue() is Page newPage && _insetManager != null)
+ newPage.SafeAreaPadding = _insetManager.SafeAreaPadding;
}
private void TopLevel_BackRequested(object? sender, RoutedEventArgs e)
diff --git a/src/Avalonia.Controls/Page/PageSelectionChangedEventArgs.cs b/src/Avalonia.Controls/Page/PageSelectionChangedEventArgs.cs
index e3759b615b..7163c2075a 100644
--- a/src/Avalonia.Controls/Page/PageSelectionChangedEventArgs.cs
+++ b/src/Avalonia.Controls/Page/PageSelectionChangedEventArgs.cs
@@ -1,18 +1,20 @@
-using System;
+using Avalonia.Interactivity;
namespace Avalonia.Controls
{
///
/// Provides data for a page selection-changed event.
///
- public class PageSelectionChangedEventArgs : EventArgs
+ public class PageSelectionChangedEventArgs : RoutedEventArgs
{
///
/// Initializes a new instance of the class.
///
+ /// The routed event associated with this event args instance.
/// The page that was selected before the change, or if no page was selected.
/// The page that is now selected, or if selection was cleared.
- public PageSelectionChangedEventArgs(Page? previousPage, Page? currentPage)
+ public PageSelectionChangedEventArgs(RoutedEvent routedEvent, Page? previousPage, Page? currentPage)
+ : base(routedEvent)
{
PreviousPage = previousPage;
CurrentPage = currentPage;
diff --git a/src/Avalonia.Controls/Page/SelectingMultiPage.cs b/src/Avalonia.Controls/Page/SelectingMultiPage.cs
index 7f94a86464..9963a0ec2b 100644
--- a/src/Avalonia.Controls/Page/SelectingMultiPage.cs
+++ b/src/Avalonia.Controls/Page/SelectingMultiPage.cs
@@ -1,4 +1,6 @@
using System;
+using System.Collections.Generic;
+using Avalonia.Interactivity;
namespace Avalonia.Controls
{
@@ -24,6 +26,14 @@ namespace Avalonia.Controls
nameof(SelectedPage),
o => o._selectedPage);
+ ///
+ /// Defines the routed event.
+ ///
+ public static readonly RoutedEvent SelectionChangedEvent =
+ RoutedEvent.Register(
+ nameof(SelectionChanged),
+ RoutingStrategies.Bubble);
+
private int _selectedIndex = -1;
private Page? _selectedPage;
@@ -44,11 +54,21 @@ namespace Avalonia.Controls
///
/// Raised when the selected page changes.
///
- public event EventHandler? SelectionChanged;
+ public event EventHandler? SelectionChanged
+ {
+ add => AddHandler(SelectionChangedEvent, value);
+ remove => RemoveHandler(SelectionChangedEvent, value);
+ }
///
/// Commits a selection change and fires lifecycle events on the outgoing and incoming pages.
///
+ ///
+ /// Raises , then on the outgoing
+ /// page and on the incoming page. If the incoming and outgoing
+ /// pages are the same object no events are fired. Subclasses should call this method rather
+ /// than modifying selection state directly.
+ ///
protected void CommitSelection(int newIndex, Page? newPage, NavigationType navigationType = NavigationType.Replace)
{
var previousPage = _selectedPage;
@@ -57,7 +77,7 @@ namespace Avalonia.Controls
SetCurrentValue(CurrentPageProperty, newPage);
if (!ReferenceEquals(previousPage, newPage))
{
- SelectionChanged?.Invoke(this, new PageSelectionChangedEventArgs(previousPage, newPage));
+ RaiseEvent(new PageSelectionChangedEventArgs(SelectionChangedEvent, previousPage, newPage));
if (previousPage != null)
{
@@ -83,5 +103,28 @@ namespace Avalonia.Controls
{
SetAndRaise(SelectedIndexProperty, ref _selectedIndex, index);
}
+
+ ///
+ /// Returns the page at from ,
+ /// or if the index is out of range.
+ ///
+ protected Page? ResolvePageAtIndex(int index)
+ {
+ if (Pages is IList list)
+ return (uint)index < (uint)list.Count ? list[index] : null;
+
+ if (Pages != null)
+ {
+ int i = 0;
+ foreach (var page in Pages)
+ {
+ if (i == index)
+ return page;
+ i++;
+ }
+ }
+
+ return null;
+ }
}
}
diff --git a/src/Avalonia.Controls/Page/TabbedPage.cs b/src/Avalonia.Controls/Page/TabbedPage.cs
index 8fccb45223..69815eb56a 100644
--- a/src/Avalonia.Controls/Page/TabbedPage.cs
+++ b/src/Avalonia.Controls/Page/TabbedPage.cs
@@ -6,13 +6,10 @@ using Avalonia.Automation.Peers;
using Avalonia.Collections;
using Avalonia.Controls.Metadata;
using Avalonia.Controls.Primitives;
-using Avalonia.Controls.Shapes;
using Avalonia.Controls.Templates;
using Avalonia.Input;
using Avalonia.Input.GestureRecognizers;
-using Avalonia.Layout;
using Avalonia.LogicalTree;
-using Avalonia.Media;
using Avalonia.Threading;
namespace Avalonia.Controls
@@ -24,8 +21,10 @@ namespace Avalonia.Controls
public class TabbedPage : SelectingMultiPage
{
private TabControl? _tabControl;
+ private bool _ignoringDisabledSelection;
private readonly Dictionary _containerPageMap = new();
private readonly Dictionary _pageContainerMap = new();
+ private readonly HashSet _templateCreatedPages = new(ReferenceEqualityComparer.Instance);
private int _lastSwipeGestureId;
private readonly SwipeGestureRecognizer _swipeRecognizer = new SwipeGestureRecognizer
{
@@ -84,15 +83,20 @@ namespace Avalonia.Controls
public static void SetIsTabEnabled(Page page, bool value) =>
page.SetValue(IsTabEnabledProperty, value);
+ private static readonly bool s_isMobilePlatform = OperatingSystem.IsAndroid() || OperatingSystem.IsIOS();
+
+ static TabbedPage()
+ {
+ FocusableProperty.OverrideDefaultValue(true);
+ }
+
///
/// Initializes a new instance of the class.
///
public TabbedPage()
{
SetCurrentValue(PagesProperty, new AvaloniaList());
- Focusable = true;
GestureRecognizers.Add(_swipeRecognizer);
- AddHandler(InputElement.SwipeGestureEvent, OnSwipeGesture);
UpdateSwipeRecognizerAxes();
}
@@ -117,6 +121,11 @@ namespace Avalonia.Controls
///
/// Gets or sets whether swipe gestures can be used to navigate between tabs.
///
+ ///
+ /// Defaults to because tab strips do not respond to swipe gestures
+ /// on most platforms (iOS, desktop). Enable this only when the host platform and the
+ /// content inside each tab page do not conflict with horizontal swipe input.
+ ///
public bool IsGestureEnabled
{
get => GetValue(IsGestureEnabledProperty);
@@ -141,6 +150,18 @@ namespace Avalonia.Controls
set => SetValue(IndicatorTemplateProperty, value);
}
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ AddHandler(InputElement.SwipeGestureEvent, OnSwipeGesture);
+ }
+
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnDetachedFromVisualTree(e);
+ RemoveHandler(InputElement.SwipeGestureEvent, OnSwipeGesture);
+ }
+
protected override void ApplySelectedIndex(int index)
{
if (_tabControl != null)
@@ -162,13 +183,10 @@ namespace Avalonia.Controls
_tabControl.SelectionChanged -= TabControl_SelectionChanged;
_tabControl.ContainerPrepared -= OnContainerPrepared;
_tabControl.ContainerClearing -= OnContainerClearing;
-
- foreach (var page in _containerPageMap.Values)
- page.PropertyChanged -= OnPagePropertyChanged;
- _containerPageMap.Clear();
- _pageContainerMap.Clear();
}
+ ClearContainerPages();
+
_tabControl = e.NameScope.Find("PART_TabControl");
if (_tabControl != null)
@@ -176,6 +194,7 @@ namespace Avalonia.Controls
_tabControl.SelectionChanged += TabControl_SelectionChanged;
_tabControl.ContainerPrepared += OnContainerPrepared;
_tabControl.ContainerClearing += OnContainerClearing;
+ _tabControl.ItemsSource = (IEnumerable?)ItemsSource ?? Pages;
if (SelectedIndex >= 0)
_tabControl.SelectedIndex = SelectedIndex;
@@ -187,7 +206,10 @@ namespace Avalonia.Controls
ApplyIndicatorTemplate();
UpdateActivePage();
- Dispatcher.UIThread.Post(SyncAllTabEnabledStates, DispatcherPriority.Loaded);
+ var capturedTabControl = _tabControl;
+ Dispatcher.UIThread.Post(
+ () => SyncAllTabEnabledStates(capturedTabControl),
+ DispatcherPriority.Loaded);
}
}
@@ -206,6 +228,12 @@ namespace Avalonia.Controls
ApplyIndicatorTemplate();
else if (change.Property == IsGestureEnabledProperty)
_swipeRecognizer.IsEnabled = change.GetNewValue();
+ else if (change.Property == ItemsSourceProperty && _tabControl != null)
+ _tabControl.ItemsSource = change.GetNewValue() ?? Pages;
+ else if (change.Property == PagesProperty && ItemsSource == null && _tabControl != null)
+ _tabControl.ItemsSource = change.GetNewValue?>();
+ else if (change.Property == PageTemplateProperty && ItemsSource != null && _tabControl != null)
+ RebuildTemplateCreatedPages();
}
private TabPlacement ResolveTabPlacement()
@@ -213,9 +241,7 @@ namespace Avalonia.Controls
if (TabPlacement != TabPlacement.Auto)
return TabPlacement;
- return OperatingSystem.IsAndroid() || OperatingSystem.IsIOS()
- ? TabPlacement.Bottom
- : TabPlacement.Top;
+ return s_isMobilePlatform ? TabPlacement.Bottom : TabPlacement.Top;
}
private void ApplyTabPlacement()
@@ -248,15 +274,13 @@ namespace Avalonia.Controls
_tabControl.IndicatorTemplate = IndicatorTemplate;
}
- private bool _ignoringDisabledSelection;
-
private void TabControl_SelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (_ignoringDisabledSelection)
return;
int newIndex = _tabControl!.SelectedIndex;
- var newPage = _tabControl.SelectedItem as Page ?? ResolvePageAtIndex(newIndex);
+ var newPage = ResolvePageAtIndex(newIndex);
if (newPage != null && !GetIsTabEnabled(newPage))
{
@@ -276,51 +300,60 @@ namespace Avalonia.Controls
if (target == SelectedIndex)
return;
- CommitSelection(target, ResolvePageAtIndex(target), NavigationType.Replace);
+ CommitSelectionIfResolved(target, NavigationType.Replace);
UpdateContentSafeAreaPadding();
return;
}
- CommitSelection(newIndex, newPage, NavigationType.Replace);
+ CommitSelectionIfResolved(newIndex, NavigationType.Replace);
UpdateContentSafeAreaPadding();
}
private void OnContainerPrepared(object? sender, ContainerPreparedEventArgs e)
{
- if (e.Container is not TabItem tabItem) return;
- if (Pages is not IList pages || e.Index >= pages.Count) return;
-
- var item = pages[e.Index];
- Page? page;
+ if (e.Container is not TabItem tabItem)
+ return;
- if (item is Page directPage)
- {
- page = directPage;
- }
- else
- {
- // Data-template mode: build the page and use it directly as the tab's content.
- page = PageTemplate?.Build(item) as Page;
- if (page == null) return;
- tabItem.Content = page;
- }
+ var page = PreparePageForContainer(tabItem, e.Index);
+ if (page == null)
+ return;
tabItem.IsEnabled = GetIsTabEnabled(page);
tabItem.Header = page.Header;
- tabItem.Icon = CreateIconControl(page.Icon);
+ tabItem.Icon = page.Icon;
+ tabItem.IconTemplate = page.IconTemplate;
- _containerPageMap[tabItem] = page;
- _pageContainerMap[page] = tabItem;
- page.PropertyChanged += OnPagePropertyChanged;
+ if (e.Index == (_tabControl?.SelectedIndex ?? -1))
+ UpdateActivePage();
}
private void OnContainerClearing(object? sender, ContainerClearingEventArgs e)
{
- if (e.Container is TabItem tabItem && _containerPageMap.Remove(tabItem, out var page))
+ if (e.Container is TabItem tabItem)
+ ClearContainerPage(tabItem);
+ }
+
+ private void RebuildTemplateCreatedPages()
+ {
+ if (_tabControl == null || ItemsSource == null)
+ return;
+
+ for (int i = 0; i < _tabControl.ItemCount; i++)
{
- _pageContainerMap.Remove(page);
- page.PropertyChanged -= OnPagePropertyChanged;
+ if (_tabControl.ContainerFromIndex(i) is not TabItem tabItem)
+ continue;
+
+ var page = PreparePageForContainer(tabItem, i);
+ if (page == null)
+ continue;
+
+ tabItem.IsEnabled = GetIsTabEnabled(page);
+ tabItem.Header = page.Header;
+ tabItem.Icon = page.Icon;
+ tabItem.IconTemplate = page.IconTemplate;
}
+
+ UpdateActivePage();
}
private void OnPagePropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
@@ -331,7 +364,12 @@ namespace Avalonia.Controls
if (e.Property == Page.IconProperty)
{
if (_pageContainerMap.TryGetValue(page, out var tabItem))
- tabItem.Icon = CreateIconControl(page.Icon);
+ tabItem.Icon = page.Icon;
+ }
+ else if (e.Property == Page.IconTemplateProperty)
+ {
+ if (_pageContainerMap.TryGetValue(page, out var tabItem))
+ tabItem.IconTemplate = page.IconTemplate;
}
else if (e.Property == Page.HeaderProperty)
{
@@ -344,118 +382,103 @@ namespace Avalonia.Controls
}
}
- ///
- /// Creates a visual control from a page icon value.
- ///
- internal static Control? CreateIconControl(object? icon)
- {
- Geometry? geometry = icon switch
- {
- Geometry g => g,
- PathIcon pi => pi.Data,
- DrawingImage { Drawing: GeometryDrawing { Geometry: { } gd } } => gd,
- string s when !string.IsNullOrEmpty(s) => Geometry.Parse(s),
- _ => null
- };
-
- if (geometry != null)
- {
- var path = new Path
- {
- Data = geometry,
- Stretch = Stretch.Uniform,
- HorizontalAlignment = HorizontalAlignment.Center,
- };
-
- path.Bind(
- Path.FillProperty,
- path.GetObservable(Documents.TextElement.ForegroundProperty));
-
- return path;
- }
-
- if (icon is IImage image)
- {
- return new Image
- {
- HorizontalAlignment = HorizontalAlignment.Center,
- Source = image,
- };
- }
-
- return null;
- }
-
private int FindNearestEnabledTab(int disabledIndex)
{
- if (Pages is not IList pages) return -1;
- int count = pages.Count;
+ int count = GetTabCount();
for (int dist = 1; dist < count; dist++)
{
int p = disabledIndex - dist;
- if (p >= 0 && pages[p] is Page pp && GetIsTabEnabled(pp)) return p;
+ if (p >= 0 && IsTabEnabledAtIndex(p))
+ return p;
int n = disabledIndex + dist;
- if (n < count && pages[n] is Page np && GetIsTabEnabled(np)) return n;
+ if (n < count && IsTabEnabledAtIndex(n))
+ return n;
}
return -1;
}
- protected internal int FindNextEnabledTab(int start, int direction)
+ private protected int FindNextEnabledTab(int start, int direction)
{
- if (Pages is not IList pages) return -1;
- int count = pages.Count;
+ int count = GetTabCount();
int i = start;
while (i >= 0 && i < count)
{
- if (pages[i] is Page page && GetIsTabEnabled(page)) return i;
+ if (IsTabEnabledAtIndex(i))
+ return i;
i += direction;
}
return -1;
}
- private Page? ResolvePageAtIndex(int index)
+ internal int GetTabCount()
+ {
+ if (_tabControl != null)
+ return _tabControl.ItemCount;
+
+ var source = (IEnumerable?)ItemsSource ?? Pages;
+ if (source is ICollection collection)
+ return collection.Count;
+
+ if (source == null)
+ return 0;
+
+ int count = 0;
+ foreach (var _ in source)
+ count++;
+
+ return count;
+ }
+
+ private bool IsTabEnabledAtIndex(int index)
{
if (_tabControl?.ContainerFromIndex(index) is TabItem ti && _containerPageMap.TryGetValue(ti, out var p))
- return p;
- if (Pages is IList pages && (uint)index < (uint)pages.Count)
- return pages[index] as Page;
- return null;
+ return GetIsTabEnabled(p);
+ if (ResolvePageAtIndex(index) is Page page)
+ return GetIsTabEnabled(page);
+ return true;
}
- private void SyncTabEnabledState(Page page)
+ private new Page? ResolvePageAtIndex(int index)
{
- if (_tabControl == null || Pages is not IList pages)
- return;
+ if (_tabControl?.ContainerFromIndex(index) is TabItem ti && _containerPageMap.TryGetValue(ti, out var p))
+ return p;
- for (int i = 0; i < pages.Count; i++)
+ if (_tabControl != null)
{
- if (!ReferenceEquals(pages[i], page))
- continue;
+ var itemsView = _tabControl.ItemsView;
+ if ((uint)index < (uint)itemsView.Count)
+ return itemsView[index] as Page;
+ }
- if (_tabControl.ContainerFromIndex(i) is TabItem tabItem)
- tabItem.IsEnabled = GetIsTabEnabled(page);
+ if (_tabControl?.ContainerFromIndex(index) is TabItem { Content: Page page })
+ return page;
- if (!GetIsTabEnabled(page) && i == _tabControl.SelectedIndex)
- {
- int target = FindNearestEnabledTab(i);
- if (target >= 0 && target != i)
- _tabControl.SelectedIndex = target;
- }
+ return base.ResolvePageAtIndex(index);
+ }
+ private void SyncTabEnabledState(Page page)
+ {
+ if (_tabControl == null || !_pageContainerMap.TryGetValue(page, out var tabItem))
return;
+
+ tabItem.IsEnabled = GetIsTabEnabled(page);
+
+ if (!GetIsTabEnabled(page) && ReferenceEquals(tabItem, _tabControl.ContainerFromIndex(_tabControl.SelectedIndex)))
+ {
+ int i = _tabControl.SelectedIndex;
+ int target = FindNearestEnabledTab(i);
+ if (target >= 0 && target != i)
+ _tabControl.SelectedIndex = target;
}
}
- private void SyncAllTabEnabledStates()
+ private void SyncAllTabEnabledStates(TabControl tabControl)
{
- if (_tabControl == null || Pages is not IList pages)
- return;
-
- for (int i = 0; i < pages.Count; i++)
+ for (int i = 0; i < tabControl.ItemCount; i++)
{
- if (_tabControl.ContainerFromIndex(i) is TabItem tabItem &&
+ if (tabControl.ContainerFromIndex(i) is TabItem tabItem &&
_containerPageMap.TryGetValue(tabItem, out var page))
{
tabItem.IsEnabled = GetIsTabEnabled(page);
@@ -468,11 +491,123 @@ namespace Avalonia.Controls
if (_tabControl != null)
{
int index = _tabControl.SelectedIndex;
- CommitSelection(index, _tabControl.SelectedItem as Page ?? ResolvePageAtIndex(index), navigationType);
+ CommitSelectionIfResolved(index, navigationType);
UpdateContentSafeAreaPadding();
}
}
+ private Page? PreparePageForContainer(TabItem tabItem, int index)
+ {
+ ClearContainerPage(tabItem);
+
+ Page? page;
+
+ if (ItemsSource != null)
+ {
+ if (!TryGetItemAtIndex(index, out var item))
+ return null;
+
+ page = PageTemplate?.Build(item) as Page;
+ tabItem.Content = page;
+
+ if (page == null)
+ return null;
+
+ _templateCreatedPages.Add(page);
+ LogicalChildren.Add(page);
+ }
+ else
+ {
+ if (!TryGetItemAtIndex(index, out var item))
+ return null;
+
+ page = item as Page;
+ if (page == null)
+ return null;
+
+ tabItem.Content = page;
+ }
+
+ _containerPageMap[tabItem] = page;
+ _pageContainerMap[page] = tabItem;
+ page.PropertyChanged += OnPagePropertyChanged;
+
+ return page;
+ }
+
+ private void ClearContainerPages()
+ {
+ foreach (var page in _containerPageMap.Values)
+ page.PropertyChanged -= OnPagePropertyChanged;
+
+ foreach (var page in _templateCreatedPages)
+ LogicalChildren.Remove(page);
+
+ _containerPageMap.Clear();
+ _pageContainerMap.Clear();
+ _templateCreatedPages.Clear();
+ }
+
+ private void ClearContainerPage(TabItem tabItem)
+ {
+ if (!_containerPageMap.Remove(tabItem, out var page))
+ return;
+
+ _pageContainerMap.Remove(page);
+ page.PropertyChanged -= OnPagePropertyChanged;
+
+ if (_templateCreatedPages.Remove(page))
+ LogicalChildren.Remove(page);
+ }
+
+ private bool TryGetItemAtIndex(int index, out object? item)
+ {
+ if (_tabControl != null)
+ {
+ var itemsView = _tabControl.ItemsView;
+ if ((uint)index < (uint)itemsView.Count)
+ {
+ item = itemsView[index];
+ return true;
+ }
+ }
+
+ var source = (IEnumerable?)ItemsSource ?? Pages;
+ if (source != null)
+ {
+ int currentIndex = 0;
+ foreach (var candidate in source)
+ {
+ if (currentIndex == index)
+ {
+ item = candidate;
+ return true;
+ }
+
+ currentIndex++;
+ }
+ }
+
+ item = null;
+ return false;
+ }
+
+ private void CommitSelectionIfResolved(int index, NavigationType navigationType)
+ {
+ var page = ResolvePageAtIndex(index);
+
+ if (page == null &&
+ ItemsSource != null &&
+ index >= 0 &&
+ _tabControl?.ContainerFromIndex(index) == null)
+ {
+ StoreSelectedIndex(index);
+ return;
+ }
+
+ CommitSelection(index, page, navigationType);
+ }
+
protected override void UpdateContentSafeAreaPadding()
{
base.UpdateContentSafeAreaPadding();
@@ -518,7 +653,7 @@ namespace Avalonia.Controls
var placement = ResolveTabPlacement();
bool isHorizontal = placement == TabPlacement.Top || placement == TabPlacement.Bottom;
- bool isRtl = FlowDirection == FlowDirection.RightToLeft;
+ bool isRtl = FlowDirection == Media.FlowDirection.RightToLeft;
int delta = (e.SwipeDirection, isHorizontal, isRtl) switch
{
@@ -531,7 +666,8 @@ namespace Avalonia.Controls
_ => 0
};
- if (delta == 0) return;
+ if (delta == 0)
+ return;
int next = FindNextEnabledTab(_tabControl.SelectedIndex + delta, delta);
if (next >= 0)
@@ -551,7 +687,7 @@ namespace Avalonia.Controls
var resolved = ResolveTabPlacement();
bool isHorizontal = resolved == TabPlacement.Top || resolved == TabPlacement.Bottom;
- bool isRtl = FlowDirection == FlowDirection.RightToLeft;
+ bool isRtl = FlowDirection == Media.FlowDirection.RightToLeft;
bool next = isHorizontal ? (isRtl ? e.Key == Key.Left : e.Key == Key.Right) : e.Key == Key.Down;
bool prev = isHorizontal ? (isRtl ? e.Key == Key.Right : e.Key == Key.Left) : e.Key == Key.Up;
diff --git a/src/Avalonia.Controls/Primitives/AdornerLayer.cs b/src/Avalonia.Controls/Primitives/AdornerLayer.cs
index 8d3d97b94f..1c8b24f627 100644
--- a/src/Avalonia.Controls/Primitives/AdornerLayer.cs
+++ b/src/Avalonia.Controls/Primitives/AdornerLayer.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Specialized;
+using System.Linq;
using Avalonia.Input.TextInput;
using Avalonia.Media;
using Avalonia.Reactive;
@@ -71,7 +72,32 @@ namespace Avalonia.Controls.Primitives
public static AdornerLayer? GetAdornerLayer(Visual visual)
{
- return visual.FindAncestorOfType()?.AdornerLayer;
+ // Check if the visual is inside an OverlayLayer with a dedicated AdornerLayer
+ foreach (var ancestor in visual.GetVisualAncestors())
+ {
+ if (GetDirectAdornerLayer(ancestor) is { } adornerLayer)
+ return adornerLayer;
+ }
+
+ if (TopLevel.GetTopLevel(visual) is { } topLevel)
+ {
+ foreach (var descendant in topLevel.GetVisualDescendants())
+ {
+ if (GetDirectAdornerLayer(descendant) is { } adornerLayer)
+ return adornerLayer;
+ }
+ }
+
+ return null;
+
+ static AdornerLayer? GetDirectAdornerLayer(Visual visual)
+ {
+ if (visual is OverlayLayer { AdornerLayer: { } adornerLayer })
+ return adornerLayer;
+ if (visual is VisualLayerManager vlm)
+ return vlm.AdornerLayer;
+ return null;
+ }
}
public static bool GetIsClipEnabled(Visual adorner)
diff --git a/src/Avalonia.Controls/Primitives/OverlayLayer.cs b/src/Avalonia.Controls/Primitives/OverlayLayer.cs
index 3337288a13..a9d9b072f2 100644
--- a/src/Avalonia.Controls/Primitives/OverlayLayer.cs
+++ b/src/Avalonia.Controls/Primitives/OverlayLayer.cs
@@ -13,6 +13,11 @@ namespace Avalonia.Controls.Primitives
public Size AvailableSize { get; private set; }
+ ///
+ /// Gets the dedicated adorner layer for this overlay layer.
+ ///
+ internal AdornerLayer? AdornerLayer { get; set; }
+
internal OverlayLayer()
{
}
diff --git a/src/Avalonia.Controls/Primitives/VisualLayerManager.cs b/src/Avalonia.Controls/Primitives/VisualLayerManager.cs
index 6630f1e09c..eb912d2cf8 100644
--- a/src/Avalonia.Controls/Primitives/VisualLayerManager.cs
+++ b/src/Avalonia.Controls/Primitives/VisualLayerManager.cs
@@ -3,6 +3,9 @@ using Avalonia.LogicalTree;
namespace Avalonia.Controls.Primitives
{
+ ///
+ /// A control that manages multiple layers such as adorners, overlays, text selectors, and popups.
+ ///
public sealed class VisualLayerManager : Decorator
{
private const int AdornerZIndex = int.MaxValue - 100;
@@ -13,13 +16,37 @@ namespace Avalonia.Controls.Primitives
private ILogicalRoot? _logicalRoot;
private readonly List _layers = new();
-
- public bool IsPopup { get; set; }
-
- internal AdornerLayer AdornerLayer
+ private OverlayLayer? _overlayLayer;
+
+ ///
+ /// Gets or sets a value indicating whether an is
+ /// created for this . When enabled, the adorner layer is added to the
+ /// visual tree, providing a dedicated layer for rendering adorners.
+ ///
+ public bool EnableAdornerLayer { get; set; } = true;
+
+ ///
+ /// Gets or sets a value indicating whether an is
+ /// created for this . When enabled, the overlay layer is added to the
+ /// visual tree, providing a dedicated layer for rendering overlay visuals.
+ ///
+ public bool EnableOverlayLayer { get; set; }
+
+ internal bool EnablePopupOverlayLayer { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether a is
+ /// created for this . When enabled, the overlay layer is added to the
+ /// visual tree, providing a dedicated layer for rendering text selection handles.
+ ///
+ public bool EnableTextSelectorLayer { get; set; }
+
+ internal AdornerLayer? AdornerLayer
{
get
{
+ if (!EnableAdornerLayer)
+ return null;
var rv = FindLayer();
if (rv == null)
AddLayer(rv = new AdornerLayer(), AdornerZIndex);
@@ -31,7 +58,7 @@ namespace Avalonia.Controls.Primitives
{
get
{
- if (IsPopup)
+ if (!EnablePopupOverlayLayer)
return null;
var rv = FindLayer();
if (rv == null)
@@ -44,12 +71,21 @@ namespace Avalonia.Controls.Primitives
{
get
{
- if (IsPopup)
+ if (!EnableOverlayLayer)
return null;
- var rv = FindLayer();
- if (rv == null)
- AddLayer(rv = new OverlayLayer(), OverlayZIndex);
- return rv;
+ if (_overlayLayer == null)
+ {
+ _overlayLayer = new OverlayLayer();
+ var adorner = new AdornerLayer();
+ _overlayLayer.AdornerLayer = adorner;
+
+ var panel = new Panel();
+ panel.Children.Add(_overlayLayer);
+ panel.Children.Add(adorner);
+
+ AddLayer(panel, OverlayZIndex);
+ }
+ return _overlayLayer;
}
}
@@ -57,7 +93,7 @@ namespace Avalonia.Controls.Primitives
{
get
{
- if (IsPopup)
+ if (!EnableTextSelectorLayer)
return null;
var rv = FindLayer();
if (rv == null)
diff --git a/src/Avalonia.Controls/SplitButton/SplitButton.cs b/src/Avalonia.Controls/SplitButton/SplitButton.cs
index 28225a5ad1..390f679191 100644
--- a/src/Avalonia.Controls/SplitButton/SplitButton.cs
+++ b/src/Avalonia.Controls/SplitButton/SplitButton.cs
@@ -331,10 +331,13 @@ namespace Avalonia.Controls
oldFlyout.Hide();
}
+ (oldFlyout as PopupFlyoutBase)?.SetDefaultPlacementTarget(null);
+
// Must unregister events here while a reference to the old flyout still exists
UnregisterFlyoutEvents(oldFlyout);
RegisterFlyoutEvents(newFlyout);
+ (newFlyout as PopupFlyoutBase)?.SetDefaultPlacementTarget(this);
UpdatePseudoClasses();
}
diff --git a/src/Avalonia.Controls/TabItem.cs b/src/Avalonia.Controls/TabItem.cs
index a4829c16ca..6a116b1e28 100644
--- a/src/Avalonia.Controls/TabItem.cs
+++ b/src/Avalonia.Controls/TabItem.cs
@@ -33,8 +33,14 @@ namespace Avalonia.Controls
///
/// Defines the property.
///
- public static readonly StyledProperty IconProperty =
- AvaloniaProperty.Register(nameof(Icon));
+ public static readonly StyledProperty IconProperty =
+ AvaloniaProperty.Register(nameof(Icon));
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty IconTemplateProperty =
+ AvaloniaProperty.Register(nameof(IconTemplate));
///
/// Defines the property.
@@ -77,12 +83,21 @@ namespace Avalonia.Controls
///
/// Gets or sets the icon displayed alongside the tab header.
///
- public Control? Icon
+ public object? Icon
{
get => GetValue(IconProperty);
set => SetValue(IconProperty, value);
}
+ ///
+ /// Gets or sets the data template used to display the icon of the control.
+ ///
+ public IDataTemplate? IconTemplate
+ {
+ get => GetValue(IconTemplateProperty);
+ set => SetValue(IconTemplateProperty, value);
+ }
+
///
/// Gets or sets the data template used to render the selection indicator.
///
diff --git a/src/Avalonia.Controls/TopLevel.cs b/src/Avalonia.Controls/TopLevel.cs
index 8556d03d91..ceb9590564 100644
--- a/src/Avalonia.Controls/TopLevel.cs
+++ b/src/Avalonia.Controls/TopLevel.cs
@@ -38,6 +38,7 @@ namespace Avalonia.Controls
/// tracking the widget's .
///
[TemplatePart("PART_TransparencyFallback", typeof(Border))]
+ [TemplatePart("PART_VisualLayerManager", typeof(VisualLayerManager))]
public abstract class TopLevel : ContentControl,
ICloseable,
IStyleHost,
@@ -125,6 +126,7 @@ namespace Avalonia.Controls
private Size? _frameSize;
private WindowTransparencyLevel _actualTransparencyLevel;
private Border? _transparencyFallbackBorder;
+ private VisualLayerManager? _visualLayerManager;
private TargetWeakEventSubscriber? _resourcesChangesSubscriber;
private IStorageProvider? _storageProvider;
private Screens? _screens;
@@ -133,6 +135,18 @@ namespace Avalonia.Controls
internal TopLevelHost TopLevelHost => _topLevelHost;
internal new PresentationSource PresentationSource => _source;
internal IInputRoot InputRoot => _source;
+
+ private protected VisualLayerManager? VisualLayerManager => _visualLayerManager;
+
+ private protected void EnableVisualLayerManagerLayers()
+ {
+ if (_visualLayerManager is { } vlm)
+ {
+ vlm.EnableOverlayLayer = true;
+ vlm.EnablePopupOverlayLayer = true;
+ vlm.EnableTextSelectorLayer = true;
+ }
+ }
///
/// Initializes static members of the class.
@@ -723,6 +737,8 @@ namespace Avalonia.Controls
{
base.OnApplyTemplate(e);
+ _visualLayerManager = e.NameScope.Find("PART_VisualLayerManager");
+
if (PlatformImpl is null)
return;
diff --git a/src/Avalonia.Controls/VirtualizingCarouselPanel.cs b/src/Avalonia.Controls/VirtualizingCarouselPanel.cs
index 66e717d265..88a89856bf 100644
--- a/src/Avalonia.Controls/VirtualizingCarouselPanel.cs
+++ b/src/Avalonia.Controls/VirtualizingCarouselPanel.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Diagnostics;
@@ -111,7 +111,7 @@ namespace Avalonia.Controls
Size IScrollable.Extent => Extent;
Size IScrollable.Viewport => Viewport;
- Vector IScrollable.Offset
+ Vector IScrollable.Offset
{
get => _offset;
set => SetOffset(value);
@@ -428,6 +428,14 @@ namespace Avalonia.Controls
_offsetAnimationCts = null;
}
+ protected override void OnItemsControlChanged(ItemsControl? oldValue)
+ {
+ base.OnItemsControlChanged(oldValue);
+ // ItemsPresenter attaches the panel to the visual tree before calling Attach(ItemsControl),
+ // so OnAttachedToVisualTree fires with a null ItemsControl; re-run setup here.
+ RefreshGestureRecognizer();
+ }
+
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
@@ -454,7 +462,7 @@ namespace Avalonia.Controls
private Size MeasureSinglePageOverride(Size availableSize)
{
var items = Items;
- var index = (int)_offset.X;
+ var index = (ItemsControl as Carousel)?.SelectedIndex ?? (int)_offset.X;
CompleteFinishedTransitionIfNeeded();
@@ -490,8 +498,7 @@ namespace Avalonia.Controls
_realized = null;
_realizedIndex = -1;
}
-
- // Get or create an element for the new item.
+
if (index >= 0 && index < items.Count)
{
_realized = GetOrCreateElement(items, index);
@@ -531,14 +538,10 @@ namespace Avalonia.Controls
_realized is { } to &&
GetTransition() is { } transition)
{
- _transition = new CancellationTokenSource();
+ var transitionCts = new CancellationTokenSource();
+ _transition = transitionCts;
- var forward = (_realizedIndex > _transitionFromIndex);
- if (Items.Count > 2)
- {
- forward = forward || (_transitionFromIndex == Items.Count - 1 && _realizedIndex == 0);
- forward = forward && !(_transitionFromIndex == 0 && _realizedIndex == Items.Count - 1);
- }
+ var forward = _realizedIndex > _transitionFromIndex;
_transitionTask = RunTransitionAsync(_transition, _transitionFrom, to, forward, transition);
}
@@ -729,26 +732,20 @@ namespace Avalonia.Controls
break;
case NotifyCollectionChangedAction.Replace:
if (e.OldStartingIndex < 0)
- {
goto case NotifyCollectionChangedAction.Reset;
- }
Remove(e.OldStartingIndex, e.OldItems!.Count);
Add(e.NewStartingIndex, e.NewItems!.Count);
break;
case NotifyCollectionChangedAction.Move:
if (e.OldStartingIndex < 0)
- {
goto case NotifyCollectionChangedAction.Reset;
- }
Remove(e.OldStartingIndex, e.OldItems!.Count);
var insertIndex = e.NewStartingIndex;
if (e.NewStartingIndex > e.OldStartingIndex)
- {
insertIndex -= e.OldItems.Count - 1;
- }
Add(insertIndex, e.NewItems!.Count);
break;
@@ -759,6 +756,7 @@ namespace Avalonia.Controls
_realized = null;
_realizedIndex = -1;
}
+
break;
}
@@ -769,25 +767,25 @@ namespace Avalonia.Controls
{
Debug.Assert(ItemContainerGenerator is not null);
- var e = GetRealizedElement(index);
+ var element = GetRealizedElement(index);
- if (e is null)
+ if (element is null)
{
var item = items[index];
var generator = ItemContainerGenerator!;
if (generator.NeedsContainer(item, index, out var recycleKey))
{
- e = GetRecycledElement(item, index, recycleKey) ??
+ element = GetRecycledElement(item, index, recycleKey) ??
CreateElement(item, index, recycleKey);
}
else
{
- e = GetItemAsOwnContainer(item, index);
+ element = GetItemAsOwnContainer(item, index);
}
}
- return e;
+ return element;
}
private Control? GetRealizedElement(int index)
@@ -869,19 +867,17 @@ namespace Avalonia.Controls
{
return;
}
- else
- {
- ItemContainerGenerator.ClearItemContainer(element);
- _recyclePool ??= new();
- if (!_recyclePool.TryGetValue(recycleKey, out var pool))
- {
- pool = new();
- _recyclePool.Add(recycleKey, pool);
- }
+ ItemContainerGenerator.ClearItemContainer(element);
+ _recyclePool ??= new();
- pool.Push(element);
+ if (!_recyclePool.TryGetValue(recycleKey, out var pool))
+ {
+ pool = new();
+ _recyclePool.Add(recycleKey, pool);
}
+
+ pool.Push(element);
}
private IPageTransition? GetTransition() => (ItemsControl as Carousel)?.PageTransition;
@@ -948,7 +944,7 @@ namespace Avalonia.Controls
return;
}
- if (_isDragging || _offsetAnimationCts is { IsCancellationRequested: false })
+ if (_isDragging)
return;
var transition = GetTransition();
@@ -976,6 +972,10 @@ namespace Avalonia.Controls
{
ResetViewportTransitionState();
ClearFractionalProgressContext();
+ // SyncScrollOffset is blocked during animation and the post-animation layout
+ // still sees a live CTS, so re-sync explicitly in case SelectedIndex changed.
+ if (ItemsControl is Carousel carousel)
+ SyncSelectionOffset(carousel.SelectedIndex);
});
}
@@ -995,7 +995,6 @@ namespace Avalonia.Controls
{
CanHorizontallySwipe = _swipeAxis != PageSlide.SlideAxis.Vertical,
CanVerticallySwipe = _swipeAxis != PageSlide.SlideAxis.Horizontal,
- IsMouseEnabled = true,
};
GestureRecognizers.Add(_swipeGestureRecognizer);
diff --git a/src/Avalonia.Controls/VirtualizingStackPanel.cs b/src/Avalonia.Controls/VirtualizingStackPanel.cs
index bc529b526c..669e75d81a 100644
--- a/src/Avalonia.Controls/VirtualizingStackPanel.cs
+++ b/src/Avalonia.Controls/VirtualizingStackPanel.cs
@@ -84,7 +84,8 @@ namespace Avalonia.Controls
private bool _hasReachedStart = false;
private bool _hasReachedEnd = false;
- private Rect _extendedViewport;
+ private Rect _lastMeasuredExtendedViewport;
+ private Rect _lastKnownExtendedViewport;
static VirtualizingStackPanel()
{
@@ -182,7 +183,7 @@ namespace Avalonia.Controls
///
/// Returns the extended viewport that contains any visible elements and the additional elements for fast scrolling (viewport * CacheLength * 2)
///
- internal Rect ExtendedViewPort => _extendedViewport;
+ internal Rect LastMeasuredExtendedViewPort => _lastMeasuredExtendedViewport;
protected override Size MeasureOverride(Size availableSize)
{
@@ -692,7 +693,7 @@ namespace Avalonia.Controls
Debug.Assert(_realizedElements is not null);
// Use the extended viewport for calculations
- var viewport = _extendedViewport;
+ var viewport = _lastMeasuredExtendedViewport;
// Get the viewport in the orientation direction.
var viewportStart = orientation == Orientation.Horizontal ? viewport.X : viewport.Y;
@@ -1165,40 +1166,25 @@ namespace Avalonia.Controls
ItemContainerGenerator.ItemContainerIndexChanged(element, oldIndex, newIndex);
}
- private void OnEffectiveViewportChanged(object? sender, EffectiveViewportChangedEventArgs e)
+ private Rect CalculateExtendedViewport(bool vertical, double viewportSize, double bufferSize)
{
- var vertical = Orientation == Orientation.Vertical;
- var oldViewportStart = vertical ? _viewport.Top : _viewport.Left;
- var oldViewportEnd = vertical ? _viewport.Bottom : _viewport.Right;
- var oldExtendedViewportStart = vertical ? _extendedViewport.Top : _extendedViewport.Left;
- var oldExtendedViewportEnd = vertical ? _extendedViewport.Bottom : _extendedViewport.Right;
-
- // Update current viewport
- _viewport = e.EffectiveViewport.Intersect(new(Bounds.Size));
- _isWaitingForViewportUpdate = false;
- // Calculate buffer sizes based on viewport dimensions
- var viewportSize = vertical ? _viewport.Height : _viewport.Width;
- var bufferSize = viewportSize * _bufferFactor;
-
- // Calculate extended viewport with relative buffers
- var extendedViewportStart = vertical ?
- Math.Max(0, _viewport.Top - bufferSize) :
+ var extendedViewportStart = vertical ?
+ Math.Max(0, _viewport.Top - bufferSize) :
Math.Max(0, _viewport.Left - bufferSize);
-
- var extendedViewportEnd = vertical ?
- Math.Min(Bounds.Height, _viewport.Bottom + bufferSize) :
+
+ var extendedViewportEnd = vertical ?
+ Math.Min(Bounds.Height, _viewport.Bottom + bufferSize) :
Math.Min(Bounds.Width, _viewport.Right + bufferSize);
- // special case:
// If we are at the start of the list, append 2 * CacheLength additional items
// If we are at the end of the list, prepend 2 * CacheLength additional items
- // - this way we always maintain "2 * CacheLength * element" items.
+ // - this way we always maintain "2 * CacheLength * element" items.
if (vertical)
{
var spaceAbove = _viewport.Top - bufferSize;
var spaceBelow = Bounds.Height - (_viewport.Bottom + bufferSize);
-
+
if (spaceAbove < 0 && spaceBelow >= 0)
extendedViewportEnd = Math.Min(Bounds.Height, extendedViewportEnd + Math.Abs(spaceAbove));
if (spaceAbove >= 0 && spaceBelow < 0)
@@ -1208,30 +1194,48 @@ namespace Avalonia.Controls
{
var spaceLeft = _viewport.Left - bufferSize;
var spaceRight = Bounds.Width - (_viewport.Right + bufferSize);
-
+
if (spaceLeft < 0 && spaceRight >= 0)
extendedViewportEnd = Math.Min(Bounds.Width, extendedViewportEnd + Math.Abs(spaceLeft));
- if(spaceLeft >= 0 && spaceRight < 0)
+ if (spaceLeft >= 0 && spaceRight < 0)
extendedViewportStart = Math.Max(0, extendedViewportStart - Math.Abs(spaceRight));
}
- Rect extendedViewPort;
if (vertical)
{
- extendedViewPort = new Rect(
- _viewport.X,
+ return new Rect(
+ _viewport.X,
extendedViewportStart,
_viewport.Width,
extendedViewportEnd - extendedViewportStart);
}
else
{
- extendedViewPort = new Rect(
+ return new Rect(
extendedViewportStart,
_viewport.Y,
extendedViewportEnd - extendedViewportStart,
_viewport.Height);
}
+ }
+
+ private void OnEffectiveViewportChanged(object? sender, EffectiveViewportChangedEventArgs e)
+ {
+ var vertical = Orientation == Orientation.Vertical;
+ var oldViewportStart = vertical ? _viewport.Top : _viewport.Left;
+ var oldViewportEnd = vertical ? _viewport.Bottom : _viewport.Right;
+ var oldExtendedViewportStart = vertical ? _lastMeasuredExtendedViewport.Top : _lastMeasuredExtendedViewport.Left;
+ var oldExtendedViewportEnd = vertical ? _lastMeasuredExtendedViewport.Bottom : _lastMeasuredExtendedViewport.Right;
+
+ // Update current viewport
+ _viewport = e.EffectiveViewport.Intersect(new(Bounds.Size));
+ _isWaitingForViewportUpdate = false;
+
+ // Calculate buffer sizes based on viewport dimensions
+ var viewportSize = vertical ? _viewport.Height : _viewport.Width;
+ var bufferSize = viewportSize * _bufferFactor;
+
+ var extendedViewPort = CalculateExtendedViewport(vertical, viewportSize, bufferSize);
// Determine if we need a new measure
var newViewportStart = vertical ? _viewport.Top : _viewport.Left;
@@ -1240,14 +1244,13 @@ namespace Avalonia.Controls
var newExtendedViewportEnd = vertical ? extendedViewPort.Bottom : extendedViewPort.Right;
var needsMeasure = false;
-
-
+
// Case 1: Viewport has changed significantly
if (!MathUtilities.AreClose(oldViewportStart, newViewportStart) ||
!MathUtilities.AreClose(oldViewportEnd, newViewportEnd))
{
// Case 1a: The new viewport exceeds the old extended viewport
- if (newViewportStart < oldExtendedViewportStart ||
+ if (newViewportStart < oldExtendedViewportStart ||
newViewportEnd > oldExtendedViewportEnd)
{
needsMeasure = true;
@@ -1259,19 +1262,19 @@ namespace Avalonia.Controls
// Check if we're about to scroll into an area where we don't have realized elements
// This would be the case if we're near the edge of our current extended viewport
var nearingEdge = false;
-
+
if (_realizedElements != null)
{
var firstRealizedElementU = _realizedElements.StartU;
var lastRealizedElementU = _realizedElements.StartU;
-
+
for (var i = 0; i < _realizedElements.Count; i++)
{
lastRealizedElementU += _realizedElements.SizeU[i];
}
-
+
// If scrolling up/left and nearing the top/left edge of realized elements
- if (newViewportStart < oldViewportStart &&
+ if (newViewportStart < oldViewportStart &&
newViewportStart - newExtendedViewportStart < bufferSize)
{
// Edge case: We're at item 0 with excess measurement space.
@@ -1279,9 +1282,9 @@ namespace Avalonia.Controls
// This prevents redundant Measure-Arrange cycles when at list beginning.
nearingEdge = !_hasReachedStart;
}
-
+
// If scrolling down/right and nearing the bottom/right edge of realized elements
- if (newViewportEnd > oldViewportEnd &&
+ if (newViewportEnd > oldViewportEnd &&
newExtendedViewportEnd - newViewportEnd < bufferSize)
{
// Edge case: We're at the last item with excess measurement space.
@@ -1294,16 +1297,34 @@ namespace Avalonia.Controls
{
nearingEdge = true;
}
-
+
needsMeasure = nearingEdge;
}
}
+ // Supplementary check: detect viewport growth after a previous shrink.
+ // The main comparison (Cases 1a/1b) uses _extendedViewport which only updates
+ // on measure. When the viewport shrinks (e.g. ComboBox popup during filtering),
+ // _extendedViewport stays stale-large, masking subsequent growth. Compare against
+ // _lastKnownExtendedViewport (always updated) to catch this case.
+ if (!needsMeasure)
+ {
+ var lastKnownStart = vertical ? _lastKnownExtendedViewport.Top : _lastKnownExtendedViewport.Left;
+ var lastKnownEnd = vertical ? _lastKnownExtendedViewport.Bottom : _lastKnownExtendedViewport.Right;
+ if (newViewportStart < lastKnownStart || newViewportEnd > lastKnownEnd)
+ {
+ needsMeasure = true;
+ }
+ }
+
+ _lastKnownExtendedViewport = extendedViewPort;
+
if (needsMeasure)
{
- // only store the new "old" extended viewport if we _did_ actually measure
- _extendedViewport = extendedViewPort;
-
+ // Only update the measure viewport when triggering a measure. This keeps the
+ // wider realization range available for externally-triggered measures (e.g. from
+ // OnItemsChanged), ensuring enough items are realized.
+ _lastMeasuredExtendedViewport = extendedViewPort;
InvalidateMeasure();
}
}
diff --git a/src/Avalonia.Controls/Window.cs b/src/Avalonia.Controls/Window.cs
index db3ec6a077..e7a4ce953e 100644
--- a/src/Avalonia.Controls/Window.cs
+++ b/src/Avalonia.Controls/Window.cs
@@ -6,6 +6,7 @@ using System.Threading.Tasks;
using Avalonia.Automation.Peers;
using Avalonia.Controls.Chrome;
using Avalonia.Controls.Platform;
+using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
@@ -795,6 +796,12 @@ namespace Avalonia.Controls
ShowCore(null, false);
}
+ protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
+ {
+ base.OnApplyTemplate(e);
+ EnableVisualLayerManagerLayers();
+ }
+
protected override void IsVisibleChanged(AvaloniaPropertyChangedEventArgs e)
{
if (!IgnoreVisibilityChanges)
diff --git a/src/Avalonia.Themes.Fluent/Controls/CarouselPage.xaml b/src/Avalonia.Themes.Fluent/Controls/CarouselPage.xaml
new file mode 100644
index 0000000000..25b5d67cb3
--- /dev/null
+++ b/src/Avalonia.Themes.Fluent/Controls/CarouselPage.xaml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Avalonia.Themes.Fluent/Controls/DrawerPage.xaml b/src/Avalonia.Themes.Fluent/Controls/DrawerPage.xaml
index 4f892153b1..985814c967 100644
--- a/src/Avalonia.Themes.Fluent/Controls/DrawerPage.xaml
+++ b/src/Avalonia.Themes.Fluent/Controls/DrawerPage.xaml
@@ -47,6 +47,8 @@
VerticalAlignment="Center"/>
@@ -101,6 +103,8 @@
VerticalAlignment="Center"/>
@@ -146,6 +150,8 @@
VerticalAlignment="Center"/>
diff --git a/src/Avalonia.Themes.Fluent/Controls/EmbeddableControlRoot.xaml b/src/Avalonia.Themes.Fluent/Controls/EmbeddableControlRoot.xaml
index 1fc931db36..2ea83ec6a9 100644
--- a/src/Avalonia.Themes.Fluent/Controls/EmbeddableControlRoot.xaml
+++ b/src/Avalonia.Themes.Fluent/Controls/EmbeddableControlRoot.xaml
@@ -12,7 +12,7 @@
-
+
+
diff --git a/src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml b/src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml
index 4278b91e36..5b31fdb55c 100644
--- a/src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml
+++ b/src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml
@@ -1,11 +1,13 @@
+
@@ -18,62 +20,60 @@
-
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+ VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
+ UseLayoutRounding="{TemplateBinding UseLayoutRounding}" />
+
+
+
@@ -84,7 +84,7 @@
+
diff --git a/src/Avalonia.Themes.Fluent/Controls/NavigationPage.xaml b/src/Avalonia.Themes.Fluent/Controls/NavigationPage.xaml
index dd12d3ce27..8c2172a787 100644
--- a/src/Avalonia.Themes.Fluent/Controls/NavigationPage.xaml
+++ b/src/Avalonia.Themes.Fluent/Controls/NavigationPage.xaml
@@ -18,12 +18,26 @@
-
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
@@ -107,16 +121,15 @@
ToolTip.Tip="{x:Null}">
+ VerticalAlignment="Center" />
diff --git a/src/Avalonia.Themes.Fluent/Controls/OverlayPopupHost.xaml b/src/Avalonia.Themes.Fluent/Controls/OverlayPopupHost.xaml
index 3b1b5f6c3d..132400d859 100644
--- a/src/Avalonia.Themes.Fluent/Controls/OverlayPopupHost.xaml
+++ b/src/Avalonia.Themes.Fluent/Controls/OverlayPopupHost.xaml
@@ -11,7 +11,7 @@
-
+
-
+
+ VerticalAlignment="Stretch" />
diff --git a/src/Avalonia.Themes.Fluent/Controls/Window.xaml b/src/Avalonia.Themes.Fluent/Controls/Window.xaml
index f56c9fbd10..0f25079004 100644
--- a/src/Avalonia.Themes.Fluent/Controls/Window.xaml
+++ b/src/Avalonia.Themes.Fluent/Controls/Window.xaml
@@ -14,7 +14,7 @@
-
+
-
-
-
-
+
-
+
+
diff --git a/src/Avalonia.Themes.Simple/Controls/NavigationPage.xaml b/src/Avalonia.Themes.Simple/Controls/NavigationPage.xaml
index fee14e61ba..66325a9ba7 100644
--- a/src/Avalonia.Themes.Simple/Controls/NavigationPage.xaml
+++ b/src/Avalonia.Themes.Simple/Controls/NavigationPage.xaml
@@ -17,12 +17,26 @@
-
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
@@ -101,16 +115,15 @@
ToolTip.Tip="{x:Null}">
+ VerticalAlignment="Center" />
diff --git a/src/Avalonia.Themes.Simple/Controls/OverlayPopupHost.xaml b/src/Avalonia.Themes.Simple/Controls/OverlayPopupHost.xaml
index 6e2f41cf2c..515d821169 100644
--- a/src/Avalonia.Themes.Simple/Controls/OverlayPopupHost.xaml
+++ b/src/Avalonia.Themes.Simple/Controls/OverlayPopupHost.xaml
@@ -13,7 +13,7 @@
-
+
-
+
+
diff --git a/src/Avalonia.Themes.Simple/Controls/TabbedPage.xaml b/src/Avalonia.Themes.Simple/Controls/TabbedPage.xaml
index 2ce01f4392..c18e57581c 100644
--- a/src/Avalonia.Themes.Simple/Controls/TabbedPage.xaml
+++ b/src/Avalonia.Themes.Simple/Controls/TabbedPage.xaml
@@ -133,6 +133,7 @@
VerticalAlignment="{TemplateBinding VerticalContentAlignment}">
+ VerticalAlignment="Stretch" />
diff --git a/src/Avalonia.Themes.Simple/Controls/Window.xaml b/src/Avalonia.Themes.Simple/Controls/Window.xaml
index 43eef2e515..e524a8a8a8 100644
--- a/src/Avalonia.Themes.Simple/Controls/Window.xaml
+++ b/src/Avalonia.Themes.Simple/Controls/Window.xaml
@@ -17,7 +17,7 @@
IsHitTestVisible="False" />
-
+
-
-
-