From b7febe892dba12a8dc12573dbe0f7eaf07eb46d9 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Wed, 13 May 2026 20:15:03 +0200 Subject: [PATCH 1/3] Fix intermediate pages not receiving query parameters in multi-page Shell navigation When Shell.GoToAsync pushes multiple pages (e.g. 'product/review'), only the last page received query parameters. Intermediate pages silently got nothing because their Parent is null (not yet in visual tree), causing baseShellItem to be null. Neither the ShellContent branch nor the isLastItem branch would run, so parameters were silently dropped. Added a third branch in ApplyQueryAttributes for the case where the element is a Page but baseShellItem is null. It sets the QueryAttributesProperty with prefix-filtered parameters, which triggers the existing property-changed handler to apply IQueryAttributable, BindingContext propagation, and QueryProperty attributes. Fixes #35107 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/Core/Shell/ShellNavigationManager.cs | 20 +++- .../ShellParameterPassingTests.cs | 95 +++++++++++++++++++ 2 files changed, 110 insertions(+), 5 deletions(-) diff --git a/src/Controls/src/Core/Shell/ShellNavigationManager.cs b/src/Controls/src/Core/Shell/ShellNavigationManager.cs index efac0f54f2e6..8de16a83f67d 100644 --- a/src/Controls/src/Core/Shell/ShellNavigationManager.cs +++ b/src/Controls/src/Core/Shell/ShellNavigationManager.cs @@ -221,14 +221,12 @@ public void HandleNavigated(ShellNavigatedEventArgs args) _shell.PropertyChanged += WaitForWindowToSet; var shellContent = _shell?.CurrentItem?.CurrentItem?.CurrentItem; - if (shellContent != null) - shellContent.ChildAdded += WaitForWindowToSet; + shellContent?.ChildAdded += WaitForWindowToSet; _waitingForWindow = new ActionDisposable(() => { _shell.PropertyChanged -= WaitForWindowToSet; - if (shellContent != null) - shellContent.ChildAdded -= WaitForWindowToSet; + shellContent?.ChildAdded -= WaitForWindowToSet; }); void WaitForWindowToSet(object sender, EventArgs e) @@ -313,7 +311,7 @@ public static void ApplyQueryAttributes(Element element, ShellRouteParameters qu var mergedData = MergeData(element, filteredQuery, isPopping); //if we are pop or navigating back, we need to apply the query attributes to the ShellContent - if (isPopping && mergedData.Count > 0 ) + if (isPopping && mergedData.Count > 0) { element.SetValue(ShellContent.QueryAttributesProperty, mergedData); } @@ -334,6 +332,18 @@ public static void ApplyQueryAttributes(Element element, ShellRouteParameters qu element.SetValue(ShellContent.QueryAttributesProperty, mergedData); } } + else if (element is Page) + { + // Intermediate page not yet in visual tree (Parent is null). + // Apply prefix-filtered query parameters directly via the attached property, + // which triggers OnQueryAttributesPropertyChanged to handle IQueryAttributable, + // BindingContext propagation, and [QueryProperty] attributes. + var mergedData = MergeData(element, filteredQuery, isPopping); + if (mergedData.Count > 0 || !isPopping) + { + element.SetValue(ShellContent.QueryAttributesProperty, mergedData); + } + } ShellRouteParameters MergeData(Element shellElement, ShellRouteParameters data, bool isPopping) { diff --git a/src/Controls/tests/Core.UnitTests/ShellParameterPassingTests.cs b/src/Controls/tests/Core.UnitTests/ShellParameterPassingTests.cs index 2bd24d36e2b9..19516d68413f 100644 --- a/src/Controls/tests/Core.UnitTests/ShellParameterPassingTests.cs +++ b/src/Controls/tests/Core.UnitTests/ShellParameterPassingTests.cs @@ -778,5 +778,100 @@ public async Task ShellSectionChangedSetsQueryAttributesProperty() Assert.True(content2.IsSet(ShellContent.QueryAttributesProperty), "QueryAttributesProperty should be set when changing ShellSection"); } + + [Fact] + public async Task IntermediatePageReceivesPrefixedQueryParamsViaIQueryAttributable() + { + var shell = new TestShell(); + var item = CreateShellItem(shellSectionRoute: "section", shellContentRoute: "content"); + shell.Items.Add(item); + + var intermediatePage = new ShellTestPage(); + shell.RegisterPage("product", intermediatePage); + Routing.RegisterRoute("review", typeof(ShellTestPage)); + + await shell.GoToAsync("product/review?product.sku=seed-tomato&stars=5"); + + // Intermediate page should have received the prefixed param "sku" + Assert.Single(intermediatePage.AppliedQueryAttributes); + Assert.True(intermediatePage.AppliedQueryAttributes[0].ContainsKey("sku")); + Assert.Equal("seed-tomato", intermediatePage.AppliedQueryAttributes[0]["sku"]); + + // Last page should have received "stars" + var lastPage = shell.CurrentPage as ShellTestPage; + Assert.NotNull(lastPage); + Assert.NotEqual(intermediatePage, lastPage); + Assert.Single(lastPage.AppliedQueryAttributes); + Assert.True(lastPage.AppliedQueryAttributes[0].ContainsKey("stars")); + Assert.Equal("5", lastPage.AppliedQueryAttributes[0]["stars"]); + } + + [Fact] + public async Task IntermediatePageReceivesQueryPropertyAttributes() + { + var shell = new TestShell(); + var item = CreateShellItem(shellSectionRoute: "section", shellContentRoute: "content"); + shell.Items.Add(item); + + var intermediatePage = new ShellTestPage(); + shell.RegisterPage("product", intermediatePage); + Routing.RegisterRoute("review", typeof(ShellTestPage)); + + await shell.GoToAsync($"product/review?product.{nameof(ShellTestPage.SomeQueryParameter)}=hello"); + + Assert.Equal("hello", intermediatePage.SomeQueryParameter); + } + + [Fact] + public async Task LastPageStillReceivesUnprefixedParamsWithIntermediatePages() + { + var shell = new TestShell(); + var item = CreateShellItem(shellSectionRoute: "section", shellContentRoute: "content"); + shell.Items.Add(item); + + Routing.RegisterRoute("product", typeof(ShellTestPage)); + Routing.RegisterRoute("review", typeof(ShellTestPage)); + + await shell.GoToAsync($"product/review?{nameof(ShellTestPage.SomeQueryParameter)}=world&stars=5"); + + // Last page (review) should get the unprefixed params + var lastPage = shell.CurrentPage as ShellTestPage; + Assert.NotNull(lastPage); + Assert.True(lastPage.AppliedQueryAttributes[0].ContainsKey(nameof(ShellTestPage.SomeQueryParameter))); + Assert.Equal("world", lastPage.AppliedQueryAttributes[0][nameof(ShellTestPage.SomeQueryParameter)]); + Assert.True(lastPage.AppliedQueryAttributes[0].ContainsKey("stars")); + } + + [Fact] + public async Task MultipleIntermediatePagesEachReceiveOwnPrefixedParams() + { + var shell = new TestShell(); + var item = CreateShellItem(shellSectionRoute: "section", shellContentRoute: "content"); + shell.Items.Add(item); + + var categoryPage = new ShellTestPage(); + var productPage = new ShellTestPage(); + shell.RegisterPage("category", categoryPage); + shell.RegisterPage("product", productPage); + Routing.RegisterRoute("review", typeof(ShellTestPage)); + + await shell.GoToAsync("category/product/review?category.name=seeds&product.sku=tomato&stars=5"); + + // First intermediate page gets category-prefixed params + Assert.Single(categoryPage.AppliedQueryAttributes); + Assert.True(categoryPage.AppliedQueryAttributes[0].ContainsKey("name")); + Assert.Equal("seeds", categoryPage.AppliedQueryAttributes[0]["name"]); + + // Second intermediate page gets product-prefixed params + Assert.Single(productPage.AppliedQueryAttributes); + Assert.True(productPage.AppliedQueryAttributes[0].ContainsKey("sku")); + Assert.Equal("tomato", productPage.AppliedQueryAttributes[0]["sku"]); + + // Last page gets unprefixed params + var lastPage = shell.CurrentPage as ShellTestPage; + Assert.NotNull(lastPage); + Assert.True(lastPage.AppliedQueryAttributes[0].ContainsKey("stars")); + Assert.Equal("5", lastPage.AppliedQueryAttributes[0]["stars"]); + } } } From dcb7947e440fe17ecde69d6e3ae486e8fe341949 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Wed, 13 May 2026 21:13:59 +0200 Subject: [PATCH 2/3] Add UI and unit tests for intermediate page parameter scoping Add UI tests (Order 148-149) in ShellNavigationFeatureTests to verify multi-page GoToAsync delivers prefixed params to intermediate pages and unprefixed params to the last page, including overlapping param name scoping. Enhance QueryIntermediatePage to implement IQueryAttributable and display received params. Add multi-page prefixed navigation button to QuerySenderPage. Add unit tests for overlapping param names and verifying intermediate pages do not receive unprefixed params meant for the last page. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ShellParameterPassingTests.cs | 58 + .../ShellNavigationControlPage.xaml.cs | 1655 +++++++++-------- .../ShellNavigationFeatureTests.cs | 94 +- 3 files changed, 997 insertions(+), 810 deletions(-) diff --git a/src/Controls/tests/Core.UnitTests/ShellParameterPassingTests.cs b/src/Controls/tests/Core.UnitTests/ShellParameterPassingTests.cs index 19516d68413f..b4b62e265a38 100644 --- a/src/Controls/tests/Core.UnitTests/ShellParameterPassingTests.cs +++ b/src/Controls/tests/Core.UnitTests/ShellParameterPassingTests.cs @@ -873,5 +873,63 @@ public async Task MultipleIntermediatePagesEachReceiveOwnPrefixedParams() Assert.True(lastPage.AppliedQueryAttributes[0].ContainsKey("stars")); Assert.Equal("5", lastPage.AppliedQueryAttributes[0]["stars"]); } + + [Fact] + public async Task OverlappingParamNamesDeliverCorrectValueToEachPage() + { + var shell = new TestShell(); + var item = CreateShellItem(shellSectionRoute: "section", shellContentRoute: "content"); + shell.Items.Add(item); + + var intermediatePage = new ShellTestPage(); + shell.RegisterPage("product", intermediatePage); + Routing.RegisterRoute("review", typeof(ShellTestPage)); + + // Both pages have "name" as key, but intermediate gets it via prefix + await shell.GoToAsync("product/review?product.name=IntermediateValue&name=DetailValue"); + + // Intermediate page should get "name=IntermediateValue" + Assert.Single(intermediatePage.AppliedQueryAttributes); + Assert.True(intermediatePage.AppliedQueryAttributes[0].ContainsKey("name")); + Assert.Equal("IntermediateValue", intermediatePage.AppliedQueryAttributes[0]["name"]); + + // Last page should get "name=DetailValue", NOT "IntermediateValue" + var lastPage = shell.CurrentPage as ShellTestPage; + Assert.NotNull(lastPage); + Assert.NotEqual(intermediatePage, lastPage); + Assert.True(lastPage.AppliedQueryAttributes[0].ContainsKey("name")); + Assert.Equal("DetailValue", lastPage.AppliedQueryAttributes[0]["name"]); + } + + [Fact] + public async Task IntermediatePageDoesNotReceiveUnprefixedParams() + { + var shell = new TestShell(); + var item = CreateShellItem(shellSectionRoute: "section", shellContentRoute: "content"); + shell.Items.Add(item); + + var intermediatePage = new ShellTestPage(); + shell.RegisterPage("product", intermediatePage); + Routing.RegisterRoute("review", typeof(ShellTestPage)); + + // "stars" is unprefixed — should only go to the last page + // "product.sku" is prefixed — should only go to intermediate + await shell.GoToAsync("product/review?product.sku=seed-tomato&stars=5&name=Alice"); + + // Intermediate page should only get "sku" (from "product.sku") + Assert.Single(intermediatePage.AppliedQueryAttributes); + Assert.True(intermediatePage.AppliedQueryAttributes[0].ContainsKey("sku")); + Assert.Equal("seed-tomato", intermediatePage.AppliedQueryAttributes[0]["sku"]); + // Should NOT contain unprefixed params + Assert.False(intermediatePage.AppliedQueryAttributes[0].ContainsKey("stars")); + Assert.False(intermediatePage.AppliedQueryAttributes[0].ContainsKey("name")); + + // Last page should get both unprefixed params + var lastPage = shell.CurrentPage as ShellTestPage; + Assert.NotNull(lastPage); + Assert.True(lastPage.AppliedQueryAttributes[0].ContainsKey("stars")); + Assert.True(lastPage.AppliedQueryAttributes[0].ContainsKey("name")); + Assert.Equal("Alice", lastPage.AppliedQueryAttributes[0]["name"]); + } } } diff --git a/src/Controls/tests/TestCases.HostApp/FeatureMatrix/Shell/ShellNavigation/ShellNavigationControlPage.xaml.cs b/src/Controls/tests/TestCases.HostApp/FeatureMatrix/Shell/ShellNavigation/ShellNavigationControlPage.xaml.cs index 97913b1628c4..8f6eaddd5d4e 100644 --- a/src/Controls/tests/TestCases.HostApp/FeatureMatrix/Shell/ShellNavigation/ShellNavigationControlPage.xaml.cs +++ b/src/Controls/tests/TestCases.HostApp/FeatureMatrix/Shell/ShellNavigation/ShellNavigationControlPage.xaml.cs @@ -5,803 +5,860 @@ using Microsoft.Maui.Controls; namespace Maui.Controls.Sample { - public partial class ShellNavigationControlPage : Shell - { - readonly ShellViewModel _viewModel; - string _previousPageTitle = "null"; - public ShellNavigationControlPage() - { - _viewModel = new ShellViewModel(); - BindingContext = _viewModel; - InitializeComponent(); - Routing.RegisterRoute("detail1", typeof(DetailPage1)); - Routing.RegisterRoute("detail2", typeof(DetailPage2)); - Routing.RegisterRoute("detail1/subdetail", typeof(SubDetailPage)); // Contextual route from Detail1 - Routing.RegisterRoute("detail2/subdetail", typeof(SubDetailPage)); // Contextual route from Detail2 - - // Register navigation test pages - Routing.RegisterRoute("navtest1", typeof(NavigationTestPage1)); - Routing.RegisterRoute("navtest2", typeof(NavigationTestPage2)); - Routing.RegisterRoute("navtest3", typeof(NavigationTestPage3)); - - // Pass data demo routes - Routing.RegisterRoute("querysender", typeof(QuerySenderPage)); - Routing.RegisterRoute("querydetail", typeof(QueryDataDetailPage)); - Routing.RegisterRoute("queryintermediate", typeof(QueryIntermediatePage)); - - this.Navigating += OnShellNavigating; - this.Navigated += OnShellNavigated; - - } - public ShellViewModel ViewModel => _viewModel; - void UpdateCurrentState() - { - var shell = Shell.Current; - if (shell != null) - { - _viewModel.CurrentState = shell.CurrentState?.Location?.ToString() ?? "Not Set"; - _viewModel.CurrentPage = shell.CurrentPage?.Title ?? "Not Set"; - _viewModel.CurrentItem = shell.CurrentItem?.Title ?? "Not Set"; - _viewModel.ShellCurrent = shell.GetType().Name; - } - } - void UpdatePage2State() - { - UpdatePageLabels(Page2CurrentStateLabel, Page2CurrentPageLabel, Page2CurrentItemLabel, Page2ShellCurrentLabel, Page2ContentPage); - - // Update Tab.Stack info - var shell = Shell.Current; - var section = shell?.CurrentItem?.CurrentItem; - if (section != null) - { - var stack = section.Stack; - _viewModel.TabStackInfo = $"Count={stack.Count}: {string.Join(", ", stack.Select(p => p?.Title ?? "null"))}"; - } - } - void UpdatePage3C1State() - { - UpdatePageLabels(Page3C1CurrentStateLabel, Page3C1CurrentPageLabel, Page3C1CurrentItemLabel, Page3C1ShellCurrentLabel, Page3C1ContentPage); - } - void UpdatePage3C2State() - { - UpdatePageLabels(Page3C2CurrentStateLabel, Page3C2CurrentPageLabel, Page3C2CurrentItemLabel, Page3C2ShellCurrentLabel, Page3C2ContentPage); - } - void UpdatePage2TabBState() - { - UpdatePageLabels(Page2TabBCurrentStateLabel, Page2TabBCurrentPageLabel, Page2TabBCurrentItemLabel, Page2TabBShellCurrentLabel, Page2TabBPage); - } - void UpdatePageLabels(Label stateLabel, Label pageLabel, Label itemLabel, Label shellLabel, ContentPage page) - { - var shell = Shell.Current; - if (shell != null) - { - stateLabel.Text = shell.CurrentState?.Location?.ToString() ?? "Not Set"; - pageLabel.Text = shell.CurrentPage?.Title ?? "Not Set"; - itemLabel.Text = shell.CurrentItem?.Title ?? "Not Set"; - shellLabel.Text = shell.GetType().Name; - page.BindingContext = _viewModel; - } - } - public void OnIconOverrideClicked(object sender, EventArgs e) - { - if (sender is Button btn) - { - if (btn.Text == "None") - _viewModel.IconOverride = string.Empty; - else - _viewModel.IconOverride = btn.Text; - } - } - void OnToggleIsEnabled(object sender, EventArgs e) - { - _viewModel.IsEnabled = !_viewModel.IsEnabled; - } - void OnToggleIsVisible(object sender, EventArgs e) - { - _viewModel.IsVisible = !_viewModel.IsVisible; - } - async void OnNavigateToDetail1Clicked(object sender, EventArgs e) - { - await Shell.Current.GoToAsync("detail1"); - } - async void OnNavigateToDetail2Clicked(object sender, EventArgs e) - { - await Shell.Current.GoToAsync("detail2"); - } - async void OnGoToMainClicked(object sender, EventArgs e) - { - await Shell.Current.GoToAsync("//main/MainContent"); - } - async void NavigateToOptionsPage_Clicked(object sender, EventArgs e) - { - await Navigation.PushAsync(new ShellNavigationOptionsPage(_viewModel)); - } - void OnShellNavigating(object sender, ShellNavigatingEventArgs e) - { - _previousPageTitle = Shell.Current?.CurrentPage?.Title ?? "null"; - _viewModel.NavigatingCurrent = Shell.Current?.CurrentPage?.Title ?? "null"; - _viewModel.NavigatingSource = e.Source.ToString(); - _viewModel.NavigatingTarget = e.Target?.Location?.ToString() ?? "null"; - _viewModel.NavigatingCanCancel = e.CanCancel.ToString(); - _viewModel.NavigatingCancelled = e.Cancelled.ToString(); - - if (_viewModel.CancelNavigation && e.CanCancel) - { - e.Cancel(); - _viewModel.NavigatingCancelled = e.Cancelled.ToString(); - return; - } - - if (_viewModel.EnableDeferral && e.CanCancel) - { - var deferral = e.GetDeferral(); - _viewModel.DeferralStatus = "Deferring..."; - _ = HandleDeferralAsync(deferral); - } - } - - async Task HandleDeferralAsync(ShellNavigatingDeferral deferral) - { - try - { - await Task.Delay(2000); - _viewModel.DeferralStatus = "Deferral completed"; - deferral.Complete(); - } - catch (Exception ex) - { - _viewModel.DeferralStatus = $"Deferral error"; - System.Diagnostics.Debug.WriteLine($"Deferral failed: {ex.Message}"); - } - } - void OnShellNavigated(object sender, ShellNavigatedEventArgs e) - { - _viewModel.NavigatedCurrent = Shell.Current?.CurrentPage?.Title ?? "null"; - _viewModel.NavigatedPrevious = _previousPageTitle; - _viewModel.NavigatedSource = e.Source.ToString(); - UpdateCurrentState(); - UpdatePage2State(); - UpdatePage2TabBState(); - UpdatePage3C1State(); - UpdatePage3C2State(); - } - protected override void OnNavigating(ShellNavigatingEventArgs args) - { - base.OnNavigating(args); - _viewModel.OverrideNavigatingStatus = $"Source={args.Source}, Target={args.Target?.Location}"; - } - protected override void OnNavigated(ShellNavigatedEventArgs args) - { - base.OnNavigated(args); - _viewModel.OverrideNavigatedStatus = $"Source={args.Source}, Previous={args.Previous?.Location}"; - } - bool _routeRegistered = true; - async void OnToggleRouteClicked(object sender, EventArgs e) - { - var btn = (Button)sender; - if (_routeRegistered) - { - Routing.UnRegisterRoute("detail2"); - try - { await Shell.Current.GoToAsync("detail2"); _viewModel.RouteStatus = "Still works"; } - catch (Exception ex) { _viewModel.RouteStatus = "Unregistered"; System.Diagnostics.Debug.WriteLine($"Expected: {ex.Message}"); } - btn.Text = "Register Route"; - _routeRegistered = false; - } - else - { - Routing.RegisterRoute("detail2", typeof(DetailPage2)); - try - { await Shell.Current.GoToAsync("detail2"); _viewModel.RouteStatus = "Registered"; } - catch (Exception ex) { _viewModel.RouteStatus = $"{ex.Message}"; } - btn.Text = "Unregister Route"; - _routeRegistered = true; - } - } - void OnResetClicked(object sender, EventArgs e) - { - _viewModel.TextOverride = string.Empty; - _viewModel.IconOverride = string.Empty; - _viewModel.IsEnabled = true; - _viewModel.IsVisible = true; - _viewModel.CommandParameter = string.Empty; - _viewModel.CommandExecuted = string.Empty; - _viewModel.CurrentState = "Not Set"; - _viewModel.CurrentPage = "Not Set"; - _viewModel.CurrentItem = "Not Set"; - _viewModel.ShellCurrent = "Not Set"; - _viewModel.NavigatingCurrent = string.Empty; - _viewModel.NavigatingSource = string.Empty; - _viewModel.NavigatingTarget = string.Empty; - _viewModel.NavigatingCanCancel = string.Empty; - _viewModel.NavigatingCancelled = string.Empty; - _viewModel.NavigatedCurrent = string.Empty; - _viewModel.NavigatedPrevious = string.Empty; - _viewModel.NavigatedSource = string.Empty; - _viewModel.RouteStatus = string.Empty; - _viewModel.CancelNavigation = false; - _viewModel.EnableDeferral = false; - _viewModel.DeferralStatus = string.Empty; - _viewModel.OverrideNavigatingStatus = string.Empty; - _viewModel.OverrideNavigatedStatus = string.Empty; - _viewModel.TabStackInfo = string.Empty; - - // Restore Shell-level route state to initial state - if (!_routeRegistered) - { - Routing.RegisterRoute("detail2", typeof(DetailPage2)); - _routeRegistered = true; - } - ToggleRouteButton.Text = "Unregister Route"; - } - void OnToggleCancelNavigation(object sender, EventArgs e) - { - _viewModel.CancelNavigation = !_viewModel.CancelNavigation; - } - void OnToggleEnableDeferral(object sender, EventArgs e) - { - _viewModel.EnableDeferral = !_viewModel.EnableDeferral; - } - async void OnOpenPassDataDemoClicked(object sender, EventArgs e) - { - await Shell.Current.GoToAsync("querysender"); - } - } - public class ShellDetailBasePage : ContentPage - { - readonly string _prefix; - Label _currentStateLabel; - Label _currentPageLabel; - Label _currentItemLabel; - Label _shellCurrentLabel; - Label _commandExecutedLabel; - public ShellDetailBasePage(string title, string prefix) - { - Title = title; - AutomationId = $"{prefix}Page"; - _prefix = prefix; - var behavior = new BackButtonBehavior(); - behavior.SetBinding(BackButtonBehavior.TextOverrideProperty, "TextOverride"); - behavior.SetBinding(BackButtonBehavior.IconOverrideProperty, "IconOverride"); - behavior.SetBinding(BackButtonBehavior.IsEnabledProperty, "IsEnabled"); - behavior.SetBinding(BackButtonBehavior.IsVisibleProperty, "IsVisible"); - behavior.SetBinding(BackButtonBehavior.CommandProperty, "Command"); - behavior.SetBinding(BackButtonBehavior.CommandParameterProperty, "CommandParameter"); - Shell.SetBackButtonBehavior(this, behavior); - BuildUI(); - this.Appearing += OnPageAppearing; - } - void BuildUI() - { - _currentStateLabel = new Label { FontSize = 12, AutomationId = $"{_prefix}CurrentStateLabel" }; - _currentPageLabel = new Label { FontSize = 12, AutomationId = $"{_prefix}CurrentPageLabel" }; - _currentItemLabel = new Label { FontSize = 12, AutomationId = $"{_prefix}CurrentItemLabel" }; - _shellCurrentLabel = new Label { FontSize = 12, AutomationId = $"{_prefix}ShellCurrentLabel" }; - _commandExecutedLabel = new Label { FontSize = 12, AutomationId = $"{_prefix}CommandExecutedLabel" }; - var identityLabel = new Label - { - Text = Title, - FontSize = 14, - FontAttributes = FontAttributes.Bold, - Margin = new Thickness(10, 8, 10, 4), - AutomationId = $"{_prefix}PageIdentityLabel" - }; - var goBackButton = ShellNavHelper.CreateNavButton("Go Back", "..", $"{_prefix}GoBackButton"); - var contextualNavButton = ShellNavHelper.CreateNavButton("Navigate SubDetail", "subdetail", $"{_prefix}ContextualNavButton"); - var grid = new Grid - { - Padding = 10, - RowSpacing = 4, - ColumnSpacing = 10, - RowDefinitions = { new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto) }, - ColumnDefinitions = { new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Star) } - }; - AddRow(grid, 0, "CurrentState:", _currentStateLabel); - AddRow(grid, 1, "CurrentPage:", _currentPageLabel); - AddRow(grid, 2, "CurrentItem:", _currentItemLabel); - AddRow(grid, 3, "Shell.Current:", _shellCurrentLabel); - AddRow(grid, 4, "CommandExecuted:", _commandExecutedLabel); - Grid.SetRow(goBackButton, 5); - Grid.SetColumn(goBackButton, 0); - Grid.SetColumnSpan(goBackButton, 2); - grid.Children.Add(goBackButton); - Grid.SetRow(contextualNavButton, 6); - Grid.SetColumn(contextualNavButton, 0); - Grid.SetColumnSpan(contextualNavButton, 2); - grid.Children.Add(contextualNavButton); - Content = new ScrollView - { - Content = new VerticalStackLayout - { - Spacing = 4, - Children = { identityLabel, grid } - } - }; - } - static void AddRow(Grid grid, int row, string labelText, Label valueLabel) - { - var label = new Label { Text = labelText, FontSize = 12 }; - Grid.SetRow(label, row); - Grid.SetColumn(label, 0); - Grid.SetRow(valueLabel, row); - Grid.SetColumn(valueLabel, 1); - grid.Children.Add(label); - grid.Children.Add(valueLabel); - } - void OnPageAppearing(object sender, EventArgs e) - { - UpdateState(); - Shell.Current?.Navigated += OnShellNavigatedUpdateState; - } - - void OnShellNavigatedUpdateState(object sender, ShellNavigatedEventArgs e) - { - if (Shell.Current?.CurrentPage == this) - UpdateState(); - } - - void UpdateState() - { - var shell = Shell.Current; - if (shell != null) - { - _currentStateLabel.Text = shell.CurrentState?.Location?.ToString() ?? "Not Set"; - _currentPageLabel.Text = shell.CurrentPage?.Title ?? "Not Set"; - _currentItemLabel.Text = shell.CurrentItem?.Title ?? "Not Set"; - _shellCurrentLabel.Text = shell.GetType().Name; - if (shell is ShellNavigationControlPage controlPage) - { - var vm = controlPage.ViewModel; - _commandExecutedLabel.Text = vm.CommandExecuted; - BindingContext = vm; - } - } - } - - protected override void OnDisappearing() - { - base.OnDisappearing(); - Shell.Current?.Navigated -= OnShellNavigatedUpdateState; - } - } - public class DetailPage1 : ShellDetailBasePage - { - public DetailPage1() : base("DetailPage1", "Detail1") - { - var stackLayout = (Content as ScrollView)?.Content as VerticalStackLayout; - if (stackLayout == null) - return; - var absBtn = ShellNavHelper.CreateNavButton("Absolute to Page2", "//page2", "Detail1AbsoluteButton"); - var relBtn = ShellNavHelper.CreateNavButton("Relative to NavTest1", "navtest1", "Detail1RelativeButton"); - stackLayout.Children.Add(absBtn); - stackLayout.Children.Add(relBtn); - } - } - - public class DetailPage2 : ShellDetailBasePage - { - public DetailPage2() : base("DetailPage2", "Detail2") - { - var stackLayout = (Content as ScrollView)?.Content as VerticalStackLayout; - if (stackLayout == null) - return; - var absToPage2Btn = ShellNavHelper.CreateNavButton("Absolute to Page2", "//page2", "Detail2AbsoluteButton"); - var backBtn = ShellNavHelper.CreateNavButton("Go Back", "..", "Detail2BackButton"); - stackLayout.Children.Add(absToPage2Btn); - stackLayout.Children.Add(backBtn); - } - } - public class SubDetailPage : ContentPage - { - public SubDetailPage() - { - Title = "SubDetailPage"; - AutomationId = "SubDetailPage"; - var currentRouteLabel = new Label { FontSize = 12, AutomationId = "SubDetailCurrentRouteLabel" }; - var sourceContextLabel = new Label { FontSize = 12, AutomationId = "SubDetailSourceContextLabel" }; - var identityLabel = new Label - { - Text = "SubDetail Page", - FontSize = 14, - FontAttributes = FontAttributes.Bold, - Margin = new Thickness(10, 8, 10, 4), - AutomationId = "SubDetailPageIdentityLabel" - }; - var goBackButton = ShellNavHelper.CreateNavButton("Go Back", "..", "SubDetailGoBackButton"); - this.Appearing += (s, e) => - { - UpdateLabels(currentRouteLabel, sourceContextLabel); - Shell.Current?.Navigated += OnNavigated; - }; - this.Disappearing += (s, e) => - { - Shell.Current?.Navigated -= OnNavigated; - }; - - void OnNavigated(object sender, ShellNavigatedEventArgs e) - { - if (Shell.Current?.CurrentPage == this) - UpdateLabels(currentRouteLabel, sourceContextLabel); - } - - void UpdateLabels(Label routeLabel, Label contextLabel) - { - var shell = Shell.Current; - var location = shell?.CurrentState?.Location?.ToString() ?? "unknown"; - routeLabel.Text = location; - contextLabel.Text = location.Contains("detail1", StringComparison.Ordinal) ? "Contextual from: detail1" - : location.Contains("detail2", StringComparison.Ordinal) ? "Contextual from: detail2" - : "Unknown context"; - } - Content = new ScrollView - { - Content = new VerticalStackLayout - { - Spacing = 0, - Children = - { - identityLabel, - new Grid - { - Padding = 10, - RowSpacing = 4, - ColumnSpacing = 10, - RowDefinitions = { new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto) }, - ColumnDefinitions = { new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Star) }, - Children = - { - CreateLabel("Current Route:", 0, 0), - SetGrid(currentRouteLabel, 0, 1), - CreateLabel("Source Context:", 1, 0), - SetGrid(sourceContextLabel, 1, 1), - SetGrid(goBackButton, 2, 0, 2) - } - } - } - } - }; - } - static Label CreateLabel(string text, int row, int col, int colSpan = 1, bool bold = false) - { - var label = new Label { Text = text, FontSize = 12 }; - if (bold) - label.FontAttributes = FontAttributes.Bold; - Grid.SetRow(label, row); - Grid.SetColumn(label, col); - if (colSpan > 1) - Grid.SetColumnSpan(label, colSpan); - return label; - } - static View SetGrid(View view, int row, int col, int colSpan = 1) - { - Grid.SetRow(view, row); - Grid.SetColumn(view, col); - if (colSpan > 1) - Grid.SetColumnSpan(view, colSpan); - return view; - } - } - public class NavigationTestPage1 : ContentPage - { - public NavigationTestPage1() - { - Title = "NavTest1"; - AutomationId = "NavigationTestPage1"; - Content = new ScrollView - { - Content = new VerticalStackLayout - { - Padding = 10, - Spacing = 10, - Children = - { - new Label { Text = "NavTest 1", FontSize = 14, FontAttributes = FontAttributes.Bold, AutomationId = "NavTest1PageIdentityLabel" }, - ShellNavHelper.CreateNavButton("Go Back", "..", "NavTest1BackButton"), - ShellNavHelper.CreateNavButton("Navigate to NavTest2", "navtest2", "NavTest1ToNavTest2Button") - } - } - }; - } - } - public class NavigationTestPage2 : ContentPage - { - public NavigationTestPage2() - { - Title = "NavTest2"; - AutomationId = "NavigationTestPage2"; - Content = new ScrollView - { - Content = new VerticalStackLayout - { - Padding = 10, - Spacing = 10, - Children = - { - new Label { Text = "NavTest 2", FontSize = 14, FontAttributes = FontAttributes.Bold, AutomationId = "NavTest2PageIdentityLabel" }, - ShellNavHelper.CreateNavButton("Back and Forward", "../navtest3", "NavTest2BackForwardButton"), - ShellNavHelper.CreateNavButton("Simple Back", "..", "NavTest2BackButton") - } - } - }; - } - } - public class NavigationTestPage3 : ContentPage - { - public NavigationTestPage3() - { - Title = "NavTest3"; - AutomationId = "NavigationTestPage3"; - Content = new ScrollView - { - Content = new VerticalStackLayout - { - Padding = 10, - Spacing = 10, - Children = - { - new Label { Text = "NavTest 3", FontSize = 14, FontAttributes = FontAttributes.Bold, AutomationId = "NavTest3PageIdentityLabel" }, - ShellNavHelper.CreateNavButton("Back 2 Levels", "../..", "NavTest3MultiBackButton") - } - } - }; - } - } - // ── Pass Data: QuerySenderPage ──────────────────────────────────────────── - public class QuerySenderPage : ContentPage, IQueryAttributable - { - readonly Label _backValueLabel; - readonly Entry _nameEntry; - readonly Entry _locationEntry; - string _backValue; - - public string BackValue - { - get => _backValue; - set - { - _backValue = value; - _backValueLabel.Text = value ?? "(none)"; - } - } - - public void ApplyQueryAttributes(IDictionary query) - { - if (query.TryGetValue("backvalue", out var val)) - BackValue = val?.ToString(); - } - - public QuerySenderPage() - { - Title = "QuerySenderPage"; - AutomationId = "QuerySenderPage"; - - _nameEntry = new Entry { Text = "Hello World", FontSize = 12, HeightRequest = 35, AutomationId = "QuerySendNameEntry" }; - _locationEntry = new Entry { Text = "Savannah", FontSize = 12, HeightRequest = 35, AutomationId = "QuerySendLocationEntry" }; - _backValueLabel = new Label { FontSize = 12, Text = "(none)", AutomationId = "QueryBackValueLabel" }; - - var identityLabel = new Label - { - Text = "Query Sender", - FontSize = 14, - FontAttributes = FontAttributes.Bold, - Margin = new Thickness(10, 8, 10, 4), - AutomationId = "QuerySenderPageIdentityLabel" - }; - - var sendStringBtn = MakeButton("Send ?name= (string param)", "QuerySendStringButton"); - sendStringBtn.Clicked += async (s, e) => - await Shell.Current.GoToAsync($"querydetail?name={Uri.EscapeDataString(_nameEntry.Text ?? string.Empty)}"); - - var sendMultiBtn = MakeButton("Send ?name=&location= (multi param)", "QuerySendMultiParamButton"); - sendMultiBtn.Clicked += async (s, e) => - await Shell.Current.GoToAsync($"querydetail?name={Uri.EscapeDataString(_nameEntry.Text ?? string.Empty)}&location={Uri.EscapeDataString(_locationEntry.Text ?? string.Empty)}"); - - var sendDictBtn = MakeButton("Send Dictionary", "QuerySendDictButton"); - sendDictBtn.Clicked += async (s, e) => - await Shell.Current.GoToAsync("querydetail", new Dictionary { ["name"] = _nameEntry.Text ?? string.Empty }); - - var sendSingleUseBtn = MakeButton("Send SingleUse Params", "QuerySendSingleUseButton"); - sendSingleUseBtn.Clicked += async (s, e) => - await Shell.Current.GoToAsync("querydetail", new ShellNavigationQueryParameters { ["name"] = _nameEntry.Text ?? string.Empty }); - - var goBackBtn = ShellNavHelper.CreateNavButton("Go Back", "..", "QuerySenderGoBackButton"); - - var grid = new Grid - { - Padding = 10, - RowSpacing = 4, - ColumnSpacing = 10, - RowDefinitions = { new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto) }, - ColumnDefinitions = { new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Star) } - }; - - void AddLabelRow(int row, string labelText, View valueView) - { - var lbl = new Label { Text = labelText, FontSize = 12, VerticalOptions = LayoutOptions.Center }; - Grid.SetRow(lbl, row); - Grid.SetColumn(lbl, 0); - Grid.SetRow(valueView, row); - Grid.SetColumn(valueView, 1); - grid.Children.Add(lbl); - grid.Children.Add(valueView); - } - - AddLabelRow(0, "Name to Send:", _nameEntry); - AddLabelRow(1, "Location to Send:", _locationEntry); - AddLabelRow(2, "Back Value:", _backValueLabel); - foreach (var (btn, row) in new (Button, int)[] { (sendStringBtn, 3), (sendMultiBtn, 4), (sendDictBtn, 5), (sendSingleUseBtn, 6), (goBackBtn, 7) }) - { - Grid.SetRow(btn, row); - Grid.SetColumn(btn, 0); - Grid.SetColumnSpan(btn, 2); - grid.Children.Add(btn); - } - - Content = new ScrollView { Content = new VerticalStackLayout { Spacing = 4, Children = { identityLabel, grid } } }; - } - - static Button MakeButton(string text, string automationId) => new Button - { - Text = text, - FontSize = 11, - HeightRequest = 35, - Padding = new Thickness(8, 0), - Margin = new Thickness(10, 4), - HorizontalOptions = LayoutOptions.Fill, - AutomationId = automationId - }; - } - - // ── Pass Data: QueryDataDetailPage ──────────────────────────────────────── - public class QueryDataDetailPage : ContentPage, IQueryAttributable - { - readonly Label _attributeNameLabel; - readonly Label _attributeLocationLabel; - readonly Label _iqaNameLabel; - readonly Label _iqaCallCountLabel; - int _iqaCallCount; - - public QueryDataDetailPage() - { - Title = "QueryDataDetail"; - AutomationId = "QueryDataDetailPage"; - - _attributeNameLabel = new Label { FontSize = 12, Text = "(not set)", AutomationId = "QueryPropertyReceivedLabel" }; - _attributeLocationLabel = new Label { FontSize = 12, Text = "(not set)", AutomationId = "QueryPropertyLocationLabel" }; - _iqaNameLabel = new Label { FontSize = 12, Text = "(not set)", AutomationId = "IQueryAttributableReceivedLabel" }; - _iqaCallCountLabel = new Label { FontSize = 12, Text = "0", AutomationId = "DictAppliedCountLabel" }; - - var identityLabel = new Label - { - Text = "Query Data Detail", - FontSize = 14, - FontAttributes = FontAttributes.Bold, - Margin = new Thickness(10, 8, 10, 4), - AutomationId = "QueryDataDetailPageIdentityLabel" - }; - - var goBackBtn = ShellNavHelper.CreateNavButton("Go Back", "..", "QueryDetailGoBackButton"); - var goBackWithDataBtn = new Button - { - Text = "Go Back with Data", - FontSize = 11, - HeightRequest = 35, - Padding = new Thickness(8, 0), - Margin = new Thickness(10, 4), - HorizontalOptions = LayoutOptions.Fill, - AutomationId = "QueryDetailGoBackWithDataButton" - }; - goBackWithDataBtn.Clicked += async (s, e) => await Shell.Current.GoToAsync("..?backvalue=ReturnedData"); - - // Navigates forward without passing data — Dictionary data will re-apply on return (persistence demo) - var goToIntermediateBtn = new Button - { - Text = "Go to Intermediate (no data)", - FontSize = 11, - HeightRequest = 35, - Padding = new Thickness(8, 0), - Margin = new Thickness(10, 4), - HorizontalOptions = LayoutOptions.Fill, - AutomationId = "QueryDetailGoToIntermediateButton" - }; - goToIntermediateBtn.Clicked += async (s, e) => await Shell.Current.GoToAsync("queryintermediate"); - - var grid = new Grid - { - Padding = 10, - RowSpacing = 4, - ColumnSpacing = 10, - RowDefinitions = { new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto) }, - ColumnDefinitions = { new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Star) } - }; - - void AddRow(int row, string labelText, Label valueLabel) - { - var lbl = new Label { Text = labelText, FontSize = 12 }; - Grid.SetRow(lbl, row); - Grid.SetColumn(lbl, 0); - Grid.SetRow(valueLabel, row); - Grid.SetColumn(valueLabel, 1); - grid.Children.Add(lbl); - grid.Children.Add(valueLabel); - } - - AddRow(0, "[QueryProp] name:", _attributeNameLabel); - AddRow(1, "[QueryProp] location:", _attributeLocationLabel); - AddRow(2, "IQA name:", _iqaNameLabel); - AddRow(3, "IQA call count:", _iqaCallCountLabel); - foreach (var (btn, row) in new (Button, int)[] { (goBackBtn, 4), (goBackWithDataBtn, 5), (goToIntermediateBtn, 6) }) - { - Grid.SetRow(btn, row); - Grid.SetColumn(btn, 0); - Grid.SetColumnSpan(btn, 2); - grid.Children.Add(btn); - } - - Content = new ScrollView { Content = new VerticalStackLayout { Spacing = 4, Children = { identityLabel, grid } } }; - } - - public void ApplyQueryAttributes(IDictionary query) - { - if (query.TryGetValue("name", out var nameVal)) - { - _iqaCallCount++; - _iqaCallCountLabel.Text = _iqaCallCount.ToString(); - var name = nameVal?.ToString() ?? "(null)"; - _attributeNameLabel.Text = name; - _iqaNameLabel.Text = name; - } - - if (query.TryGetValue("location", out var locVal)) - _attributeLocationLabel.Text = locVal?.ToString() ?? "(null)"; - } - } - - // ── Pass Data: QueryIntermediatePage ───────────────────────────────────── - public class QueryIntermediatePage : ContentPage - { - public QueryIntermediatePage() - { - Title = "QueryIntermediate"; - AutomationId = "QueryIntermediatePage"; - Content = new ScrollView - { - Content = new VerticalStackLayout - { - Padding = 10, - Spacing = 8, - Children = - { - new Label { Text = "Intermediate Page", FontSize = 14, FontAttributes = FontAttributes.Bold, AutomationId = "QueryIntermediatePageIdentityLabel" }, - new Label { Text = "No data was passed here.\nGo Back to see Dictionary data re-applied on the detail page.", FontSize = 12, LineBreakMode = LineBreakMode.WordWrap }, - ShellNavHelper.CreateNavButton("Go Back", "..", "QueryIntermediateGoBackButton") - } - } - }; - } - } - - static class ShellNavHelper - { - public static Button CreateNavButton(string text, string route, string automationId) - { - var btn = new Button - { - Text = text, - FontSize = 12, - HeightRequest = 35, - Padding = new Thickness(8, 0), - Margin = new Thickness(10, 4), - HorizontalOptions = LayoutOptions.Fill, - AutomationId = automationId - }; - btn.Clicked += async (s, e) => - { - try - { await Shell.Current.GoToAsync(route); } - catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"Navigation failed: {ex.Message}"); } - }; - return btn; - } - } + public partial class ShellNavigationControlPage : Shell + { + readonly ShellViewModel _viewModel; + string _previousPageTitle = "null"; + public ShellNavigationControlPage() + { + _viewModel = new ShellViewModel(); + BindingContext = _viewModel; + InitializeComponent(); + Routing.RegisterRoute("detail1", typeof(DetailPage1)); + Routing.RegisterRoute("detail2", typeof(DetailPage2)); + Routing.RegisterRoute("detail1/subdetail", typeof(SubDetailPage)); // Contextual route from Detail1 + Routing.RegisterRoute("detail2/subdetail", typeof(SubDetailPage)); // Contextual route from Detail2 + + // Register navigation test pages + Routing.RegisterRoute("navtest1", typeof(NavigationTestPage1)); + Routing.RegisterRoute("navtest2", typeof(NavigationTestPage2)); + Routing.RegisterRoute("navtest3", typeof(NavigationTestPage3)); + + // Pass data demo routes + Routing.RegisterRoute("querysender", typeof(QuerySenderPage)); + Routing.RegisterRoute("querydetail", typeof(QueryDataDetailPage)); + Routing.RegisterRoute("queryintermediate", typeof(QueryIntermediatePage)); + + this.Navigating += OnShellNavigating; + this.Navigated += OnShellNavigated; + + } + public ShellViewModel ViewModel => _viewModel; + void UpdateCurrentState() + { + var shell = Shell.Current; + if (shell != null) + { + _viewModel.CurrentState = shell.CurrentState?.Location?.ToString() ?? "Not Set"; + _viewModel.CurrentPage = shell.CurrentPage?.Title ?? "Not Set"; + _viewModel.CurrentItem = shell.CurrentItem?.Title ?? "Not Set"; + _viewModel.ShellCurrent = shell.GetType().Name; + } + } + void UpdatePage2State() + { + UpdatePageLabels(Page2CurrentStateLabel, Page2CurrentPageLabel, Page2CurrentItemLabel, Page2ShellCurrentLabel, Page2ContentPage); + + // Update Tab.Stack info + var shell = Shell.Current; + var section = shell?.CurrentItem?.CurrentItem; + if (section != null) + { + var stack = section.Stack; + _viewModel.TabStackInfo = $"Count={stack.Count}: {string.Join(", ", stack.Select(p => p?.Title ?? "null"))}"; + } + } + void UpdatePage3C1State() + { + UpdatePageLabels(Page3C1CurrentStateLabel, Page3C1CurrentPageLabel, Page3C1CurrentItemLabel, Page3C1ShellCurrentLabel, Page3C1ContentPage); + } + void UpdatePage3C2State() + { + UpdatePageLabels(Page3C2CurrentStateLabel, Page3C2CurrentPageLabel, Page3C2CurrentItemLabel, Page3C2ShellCurrentLabel, Page3C2ContentPage); + } + void UpdatePage2TabBState() + { + UpdatePageLabels(Page2TabBCurrentStateLabel, Page2TabBCurrentPageLabel, Page2TabBCurrentItemLabel, Page2TabBShellCurrentLabel, Page2TabBPage); + } + void UpdatePageLabels(Label stateLabel, Label pageLabel, Label itemLabel, Label shellLabel, ContentPage page) + { + var shell = Shell.Current; + if (shell != null) + { + stateLabel.Text = shell.CurrentState?.Location?.ToString() ?? "Not Set"; + pageLabel.Text = shell.CurrentPage?.Title ?? "Not Set"; + itemLabel.Text = shell.CurrentItem?.Title ?? "Not Set"; + shellLabel.Text = shell.GetType().Name; + page.BindingContext = _viewModel; + } + } + public void OnIconOverrideClicked(object sender, EventArgs e) + { + if (sender is Button btn) + { + if (btn.Text == "None") + _viewModel.IconOverride = string.Empty; + else + _viewModel.IconOverride = btn.Text; + } + } + void OnToggleIsEnabled(object sender, EventArgs e) + { + _viewModel.IsEnabled = !_viewModel.IsEnabled; + } + void OnToggleIsVisible(object sender, EventArgs e) + { + _viewModel.IsVisible = !_viewModel.IsVisible; + } + async void OnNavigateToDetail1Clicked(object sender, EventArgs e) + { + await Shell.Current.GoToAsync("detail1"); + } + async void OnNavigateToDetail2Clicked(object sender, EventArgs e) + { + await Shell.Current.GoToAsync("detail2"); + } + async void OnGoToMainClicked(object sender, EventArgs e) + { + await Shell.Current.GoToAsync("//main/MainContent"); + } + async void NavigateToOptionsPage_Clicked(object sender, EventArgs e) + { + await Navigation.PushAsync(new ShellNavigationOptionsPage(_viewModel)); + } + void OnShellNavigating(object sender, ShellNavigatingEventArgs e) + { + _previousPageTitle = Shell.Current?.CurrentPage?.Title ?? "null"; + _viewModel.NavigatingCurrent = Shell.Current?.CurrentPage?.Title ?? "null"; + _viewModel.NavigatingSource = e.Source.ToString(); + _viewModel.NavigatingTarget = e.Target?.Location?.ToString() ?? "null"; + _viewModel.NavigatingCanCancel = e.CanCancel.ToString(); + _viewModel.NavigatingCancelled = e.Cancelled.ToString(); + + if (_viewModel.CancelNavigation && e.CanCancel) + { + e.Cancel(); + _viewModel.NavigatingCancelled = e.Cancelled.ToString(); + return; + } + + if (_viewModel.EnableDeferral && e.CanCancel) + { + var deferral = e.GetDeferral(); + _viewModel.DeferralStatus = "Deferring..."; + _ = HandleDeferralAsync(deferral); + } + } + + async Task HandleDeferralAsync(ShellNavigatingDeferral deferral) + { + try + { + await Task.Delay(2000); + _viewModel.DeferralStatus = "Deferral completed"; + deferral.Complete(); + } + catch (Exception ex) + { + _viewModel.DeferralStatus = $"Deferral error"; + System.Diagnostics.Debug.WriteLine($"Deferral failed: {ex.Message}"); + } + } + void OnShellNavigated(object sender, ShellNavigatedEventArgs e) + { + _viewModel.NavigatedCurrent = Shell.Current?.CurrentPage?.Title ?? "null"; + _viewModel.NavigatedPrevious = _previousPageTitle; + _viewModel.NavigatedSource = e.Source.ToString(); + UpdateCurrentState(); + UpdatePage2State(); + UpdatePage2TabBState(); + UpdatePage3C1State(); + UpdatePage3C2State(); + } + protected override void OnNavigating(ShellNavigatingEventArgs args) + { + base.OnNavigating(args); + _viewModel.OverrideNavigatingStatus = $"Source={args.Source}, Target={args.Target?.Location}"; + } + protected override void OnNavigated(ShellNavigatedEventArgs args) + { + base.OnNavigated(args); + _viewModel.OverrideNavigatedStatus = $"Source={args.Source}, Previous={args.Previous?.Location}"; + } + bool _routeRegistered = true; + async void OnToggleRouteClicked(object sender, EventArgs e) + { + var btn = (Button)sender; + if (_routeRegistered) + { + Routing.UnRegisterRoute("detail2"); + try + { await Shell.Current.GoToAsync("detail2"); _viewModel.RouteStatus = "Still works"; } + catch (Exception ex) { _viewModel.RouteStatus = "Unregistered"; System.Diagnostics.Debug.WriteLine($"Expected: {ex.Message}"); } + btn.Text = "Register Route"; + _routeRegistered = false; + } + else + { + Routing.RegisterRoute("detail2", typeof(DetailPage2)); + try + { await Shell.Current.GoToAsync("detail2"); _viewModel.RouteStatus = "Registered"; } + catch (Exception ex) { _viewModel.RouteStatus = $"{ex.Message}"; } + btn.Text = "Unregister Route"; + _routeRegistered = true; + } + } + void OnResetClicked(object sender, EventArgs e) + { + _viewModel.TextOverride = string.Empty; + _viewModel.IconOverride = string.Empty; + _viewModel.IsEnabled = true; + _viewModel.IsVisible = true; + _viewModel.CommandParameter = string.Empty; + _viewModel.CommandExecuted = string.Empty; + _viewModel.CurrentState = "Not Set"; + _viewModel.CurrentPage = "Not Set"; + _viewModel.CurrentItem = "Not Set"; + _viewModel.ShellCurrent = "Not Set"; + _viewModel.NavigatingCurrent = string.Empty; + _viewModel.NavigatingSource = string.Empty; + _viewModel.NavigatingTarget = string.Empty; + _viewModel.NavigatingCanCancel = string.Empty; + _viewModel.NavigatingCancelled = string.Empty; + _viewModel.NavigatedCurrent = string.Empty; + _viewModel.NavigatedPrevious = string.Empty; + _viewModel.NavigatedSource = string.Empty; + _viewModel.RouteStatus = string.Empty; + _viewModel.CancelNavigation = false; + _viewModel.EnableDeferral = false; + _viewModel.DeferralStatus = string.Empty; + _viewModel.OverrideNavigatingStatus = string.Empty; + _viewModel.OverrideNavigatedStatus = string.Empty; + _viewModel.TabStackInfo = string.Empty; + + // Restore Shell-level route state to initial state + if (!_routeRegistered) + { + Routing.RegisterRoute("detail2", typeof(DetailPage2)); + _routeRegistered = true; + } + ToggleRouteButton.Text = "Unregister Route"; + } + void OnToggleCancelNavigation(object sender, EventArgs e) + { + _viewModel.CancelNavigation = !_viewModel.CancelNavigation; + } + void OnToggleEnableDeferral(object sender, EventArgs e) + { + _viewModel.EnableDeferral = !_viewModel.EnableDeferral; + } + async void OnOpenPassDataDemoClicked(object sender, EventArgs e) + { + await Shell.Current.GoToAsync("querysender"); + } + } + public class ShellDetailBasePage : ContentPage + { + readonly string _prefix; + Label _currentStateLabel; + Label _currentPageLabel; + Label _currentItemLabel; + Label _shellCurrentLabel; + Label _commandExecutedLabel; + public ShellDetailBasePage(string title, string prefix) + { + Title = title; + AutomationId = $"{prefix}Page"; + _prefix = prefix; + var behavior = new BackButtonBehavior(); + behavior.SetBinding(BackButtonBehavior.TextOverrideProperty, "TextOverride"); + behavior.SetBinding(BackButtonBehavior.IconOverrideProperty, "IconOverride"); + behavior.SetBinding(BackButtonBehavior.IsEnabledProperty, "IsEnabled"); + behavior.SetBinding(BackButtonBehavior.IsVisibleProperty, "IsVisible"); + behavior.SetBinding(BackButtonBehavior.CommandProperty, "Command"); + behavior.SetBinding(BackButtonBehavior.CommandParameterProperty, "CommandParameter"); + Shell.SetBackButtonBehavior(this, behavior); + BuildUI(); + this.Appearing += OnPageAppearing; + } + void BuildUI() + { + _currentStateLabel = new Label { FontSize = 12, AutomationId = $"{_prefix}CurrentStateLabel" }; + _currentPageLabel = new Label { FontSize = 12, AutomationId = $"{_prefix}CurrentPageLabel" }; + _currentItemLabel = new Label { FontSize = 12, AutomationId = $"{_prefix}CurrentItemLabel" }; + _shellCurrentLabel = new Label { FontSize = 12, AutomationId = $"{_prefix}ShellCurrentLabel" }; + _commandExecutedLabel = new Label { FontSize = 12, AutomationId = $"{_prefix}CommandExecutedLabel" }; + var identityLabel = new Label + { + Text = Title, + FontSize = 14, + FontAttributes = FontAttributes.Bold, + Margin = new Thickness(10, 8, 10, 4), + AutomationId = $"{_prefix}PageIdentityLabel" + }; + var goBackButton = ShellNavHelper.CreateNavButton("Go Back", "..", $"{_prefix}GoBackButton"); + var contextualNavButton = ShellNavHelper.CreateNavButton("Navigate SubDetail", "subdetail", $"{_prefix}ContextualNavButton"); + var grid = new Grid + { + Padding = 10, + RowSpacing = 4, + ColumnSpacing = 10, + RowDefinitions = { new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto) }, + ColumnDefinitions = { new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Star) } + }; + AddRow(grid, 0, "CurrentState:", _currentStateLabel); + AddRow(grid, 1, "CurrentPage:", _currentPageLabel); + AddRow(grid, 2, "CurrentItem:", _currentItemLabel); + AddRow(grid, 3, "Shell.Current:", _shellCurrentLabel); + AddRow(grid, 4, "CommandExecuted:", _commandExecutedLabel); + Grid.SetRow(goBackButton, 5); + Grid.SetColumn(goBackButton, 0); + Grid.SetColumnSpan(goBackButton, 2); + grid.Children.Add(goBackButton); + Grid.SetRow(contextualNavButton, 6); + Grid.SetColumn(contextualNavButton, 0); + Grid.SetColumnSpan(contextualNavButton, 2); + grid.Children.Add(contextualNavButton); + Content = new ScrollView + { + Content = new VerticalStackLayout + { + Spacing = 4, + Children = { identityLabel, grid } + } + }; + } + static void AddRow(Grid grid, int row, string labelText, Label valueLabel) + { + var label = new Label { Text = labelText, FontSize = 12 }; + Grid.SetRow(label, row); + Grid.SetColumn(label, 0); + Grid.SetRow(valueLabel, row); + Grid.SetColumn(valueLabel, 1); + grid.Children.Add(label); + grid.Children.Add(valueLabel); + } + void OnPageAppearing(object sender, EventArgs e) + { + UpdateState(); + Shell.Current?.Navigated += OnShellNavigatedUpdateState; + } + + void OnShellNavigatedUpdateState(object sender, ShellNavigatedEventArgs e) + { + if (Shell.Current?.CurrentPage == this) + UpdateState(); + } + + void UpdateState() + { + var shell = Shell.Current; + if (shell != null) + { + _currentStateLabel.Text = shell.CurrentState?.Location?.ToString() ?? "Not Set"; + _currentPageLabel.Text = shell.CurrentPage?.Title ?? "Not Set"; + _currentItemLabel.Text = shell.CurrentItem?.Title ?? "Not Set"; + _shellCurrentLabel.Text = shell.GetType().Name; + if (shell is ShellNavigationControlPage controlPage) + { + var vm = controlPage.ViewModel; + _commandExecutedLabel.Text = vm.CommandExecuted; + BindingContext = vm; + } + } + } + + protected override void OnDisappearing() + { + base.OnDisappearing(); + Shell.Current?.Navigated -= OnShellNavigatedUpdateState; + } + } + public class DetailPage1 : ShellDetailBasePage + { + public DetailPage1() : base("DetailPage1", "Detail1") + { + var stackLayout = (Content as ScrollView)?.Content as VerticalStackLayout; + if (stackLayout == null) + return; + var absBtn = ShellNavHelper.CreateNavButton("Absolute to Page2", "//page2", "Detail1AbsoluteButton"); + var relBtn = ShellNavHelper.CreateNavButton("Relative to NavTest1", "navtest1", "Detail1RelativeButton"); + stackLayout.Children.Add(absBtn); + stackLayout.Children.Add(relBtn); + } + } + + public class DetailPage2 : ShellDetailBasePage + { + public DetailPage2() : base("DetailPage2", "Detail2") + { + var stackLayout = (Content as ScrollView)?.Content as VerticalStackLayout; + if (stackLayout == null) + return; + var absToPage2Btn = ShellNavHelper.CreateNavButton("Absolute to Page2", "//page2", "Detail2AbsoluteButton"); + var backBtn = ShellNavHelper.CreateNavButton("Go Back", "..", "Detail2BackButton"); + stackLayout.Children.Add(absToPage2Btn); + stackLayout.Children.Add(backBtn); + } + } + public class SubDetailPage : ContentPage + { + public SubDetailPage() + { + Title = "SubDetailPage"; + AutomationId = "SubDetailPage"; + var currentRouteLabel = new Label { FontSize = 12, AutomationId = "SubDetailCurrentRouteLabel" }; + var sourceContextLabel = new Label { FontSize = 12, AutomationId = "SubDetailSourceContextLabel" }; + var identityLabel = new Label + { + Text = "SubDetail Page", + FontSize = 14, + FontAttributes = FontAttributes.Bold, + Margin = new Thickness(10, 8, 10, 4), + AutomationId = "SubDetailPageIdentityLabel" + }; + var goBackButton = ShellNavHelper.CreateNavButton("Go Back", "..", "SubDetailGoBackButton"); + this.Appearing += (s, e) => + { + UpdateLabels(currentRouteLabel, sourceContextLabel); + Shell.Current?.Navigated += OnNavigated; + }; + this.Disappearing += (s, e) => + { + Shell.Current?.Navigated -= OnNavigated; + }; + + void OnNavigated(object sender, ShellNavigatedEventArgs e) + { + if (Shell.Current?.CurrentPage == this) + UpdateLabels(currentRouteLabel, sourceContextLabel); + } + + void UpdateLabels(Label routeLabel, Label contextLabel) + { + var shell = Shell.Current; + var location = shell?.CurrentState?.Location?.ToString() ?? "unknown"; + routeLabel.Text = location; + contextLabel.Text = location.Contains("detail1", StringComparison.Ordinal) ? "Contextual from: detail1" + : location.Contains("detail2", StringComparison.Ordinal) ? "Contextual from: detail2" + : "Unknown context"; + } + Content = new ScrollView + { + Content = new VerticalStackLayout + { + Spacing = 0, + Children = + { + identityLabel, + new Grid + { + Padding = 10, + RowSpacing = 4, + ColumnSpacing = 10, + RowDefinitions = { new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto) }, + ColumnDefinitions = { new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Star) }, + Children = + { + CreateLabel("Current Route:", 0, 0), + SetGrid(currentRouteLabel, 0, 1), + CreateLabel("Source Context:", 1, 0), + SetGrid(sourceContextLabel, 1, 1), + SetGrid(goBackButton, 2, 0, 2) + } + } + } + } + }; + } + static Label CreateLabel(string text, int row, int col, int colSpan = 1, bool bold = false) + { + var label = new Label { Text = text, FontSize = 12 }; + if (bold) + label.FontAttributes = FontAttributes.Bold; + Grid.SetRow(label, row); + Grid.SetColumn(label, col); + if (colSpan > 1) + Grid.SetColumnSpan(label, colSpan); + return label; + } + static View SetGrid(View view, int row, int col, int colSpan = 1) + { + Grid.SetRow(view, row); + Grid.SetColumn(view, col); + if (colSpan > 1) + Grid.SetColumnSpan(view, colSpan); + return view; + } + } + public class NavigationTestPage1 : ContentPage + { + public NavigationTestPage1() + { + Title = "NavTest1"; + AutomationId = "NavigationTestPage1"; + Content = new ScrollView + { + Content = new VerticalStackLayout + { + Padding = 10, + Spacing = 10, + Children = + { + new Label { Text = "NavTest 1", FontSize = 14, FontAttributes = FontAttributes.Bold, AutomationId = "NavTest1PageIdentityLabel" }, + ShellNavHelper.CreateNavButton("Go Back", "..", "NavTest1BackButton"), + ShellNavHelper.CreateNavButton("Navigate to NavTest2", "navtest2", "NavTest1ToNavTest2Button") + } + } + }; + } + } + public class NavigationTestPage2 : ContentPage + { + public NavigationTestPage2() + { + Title = "NavTest2"; + AutomationId = "NavigationTestPage2"; + Content = new ScrollView + { + Content = new VerticalStackLayout + { + Padding = 10, + Spacing = 10, + Children = + { + new Label { Text = "NavTest 2", FontSize = 14, FontAttributes = FontAttributes.Bold, AutomationId = "NavTest2PageIdentityLabel" }, + ShellNavHelper.CreateNavButton("Back and Forward", "../navtest3", "NavTest2BackForwardButton"), + ShellNavHelper.CreateNavButton("Simple Back", "..", "NavTest2BackButton") + } + } + }; + } + } + public class NavigationTestPage3 : ContentPage + { + public NavigationTestPage3() + { + Title = "NavTest3"; + AutomationId = "NavigationTestPage3"; + Content = new ScrollView + { + Content = new VerticalStackLayout + { + Padding = 10, + Spacing = 10, + Children = + { + new Label { Text = "NavTest 3", FontSize = 14, FontAttributes = FontAttributes.Bold, AutomationId = "NavTest3PageIdentityLabel" }, + ShellNavHelper.CreateNavButton("Back 2 Levels", "../..", "NavTest3MultiBackButton") + } + } + }; + } + } + // ── Pass Data: QuerySenderPage ──────────────────────────────────────────── + public class QuerySenderPage : ContentPage, IQueryAttributable + { + readonly Label _backValueLabel; + readonly Entry _nameEntry; + readonly Entry _locationEntry; + string _backValue; + + public string BackValue + { + get => _backValue; + set + { + _backValue = value; + _backValueLabel.Text = value ?? "(none)"; + } + } + + public void ApplyQueryAttributes(IDictionary query) + { + if (query.TryGetValue("backvalue", out var val)) + BackValue = val?.ToString(); + } + + public QuerySenderPage() + { + Title = "QuerySenderPage"; + AutomationId = "QuerySenderPage"; + + _nameEntry = new Entry { Text = "Hello World", FontSize = 12, HeightRequest = 35, AutomationId = "QuerySendNameEntry" }; + _locationEntry = new Entry { Text = "Savannah", FontSize = 12, HeightRequest = 35, AutomationId = "QuerySendLocationEntry" }; + _backValueLabel = new Label { FontSize = 12, Text = "(none)", AutomationId = "QueryBackValueLabel" }; + + var identityLabel = new Label + { + Text = "Query Sender", + FontSize = 14, + FontAttributes = FontAttributes.Bold, + Margin = new Thickness(10, 8, 10, 4), + AutomationId = "QuerySenderPageIdentityLabel" + }; + + var sendStringBtn = MakeButton("Send ?name= (string param)", "QuerySendStringButton"); + sendStringBtn.Clicked += async (s, e) => + await Shell.Current.GoToAsync($"querydetail?name={Uri.EscapeDataString(_nameEntry.Text ?? string.Empty)}"); + + var sendMultiBtn = MakeButton("Send ?name=&location= (multi param)", "QuerySendMultiParamButton"); + sendMultiBtn.Clicked += async (s, e) => + await Shell.Current.GoToAsync($"querydetail?name={Uri.EscapeDataString(_nameEntry.Text ?? string.Empty)}&location={Uri.EscapeDataString(_locationEntry.Text ?? string.Empty)}"); + + var sendDictBtn = MakeButton("Send Dictionary", "QuerySendDictButton"); + sendDictBtn.Clicked += async (s, e) => + await Shell.Current.GoToAsync("querydetail", new Dictionary { ["name"] = _nameEntry.Text ?? string.Empty }); + + var sendSingleUseBtn = MakeButton("Send SingleUse Params", "QuerySendSingleUseButton"); + sendSingleUseBtn.Clicked += async (s, e) => + await Shell.Current.GoToAsync("querydetail", new ShellNavigationQueryParameters { ["name"] = _nameEntry.Text ?? string.Empty }); + + var sendMultiPageBtn = MakeButton("Send Multi-Page Prefixed", "QuerySendMultiPagePrefixedButton"); + sendMultiPageBtn.Clicked += async (s, e) => + { + var name = Uri.EscapeDataString(_nameEntry.Text ?? string.Empty); + var location = Uri.EscapeDataString(_locationEntry.Text ?? string.Empty); + // Prefixed params → intermediate page; unprefixed → detail (last) page + // "name" key overlaps — each page should get its own value + await Shell.Current.GoToAsync( + $"queryintermediate/querydetail?queryintermediate.name=ForIntermediate&name={name}&location={location}"); + }; + + var goBackBtn = ShellNavHelper.CreateNavButton("Go Back", "..", "QuerySenderGoBackButton"); + + var grid = new Grid + { + Padding = 10, + RowSpacing = 4, + ColumnSpacing = 10, + RowDefinitions = { new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto) }, + ColumnDefinitions = { new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Star) } + }; + + void AddLabelRow(int row, string labelText, View valueView) + { + var lbl = new Label { Text = labelText, FontSize = 12, VerticalOptions = LayoutOptions.Center }; + Grid.SetRow(lbl, row); + Grid.SetColumn(lbl, 0); + Grid.SetRow(valueView, row); + Grid.SetColumn(valueView, 1); + grid.Children.Add(lbl); + grid.Children.Add(valueView); + } + + AddLabelRow(0, "Name to Send:", _nameEntry); + AddLabelRow(1, "Location to Send:", _locationEntry); + AddLabelRow(2, "Back Value:", _backValueLabel); + foreach (var (btn, row) in new (Button, int)[] { (sendStringBtn, 3), (sendMultiBtn, 4), (sendDictBtn, 5), (sendSingleUseBtn, 6), (sendMultiPageBtn, 7), (goBackBtn, 8) }) + { + Grid.SetRow(btn, row); + Grid.SetColumn(btn, 0); + Grid.SetColumnSpan(btn, 2); + grid.Children.Add(btn); + } + + Content = new ScrollView { Content = new VerticalStackLayout { Spacing = 4, Children = { identityLabel, grid } } }; + } + + static Button MakeButton(string text, string automationId) => new Button + { + Text = text, + FontSize = 11, + HeightRequest = 35, + Padding = new Thickness(8, 0), + Margin = new Thickness(10, 4), + HorizontalOptions = LayoutOptions.Fill, + AutomationId = automationId + }; + } + + // ── Pass Data: QueryDataDetailPage ──────────────────────────────────────── + public class QueryDataDetailPage : ContentPage, IQueryAttributable + { + readonly Label _attributeNameLabel; + readonly Label _attributeLocationLabel; + readonly Label _iqaNameLabel; + readonly Label _iqaCallCountLabel; + int _iqaCallCount; + + public QueryDataDetailPage() + { + Title = "QueryDataDetail"; + AutomationId = "QueryDataDetailPage"; + + _attributeNameLabel = new Label { FontSize = 12, Text = "(not set)", AutomationId = "QueryPropertyReceivedLabel" }; + _attributeLocationLabel = new Label { FontSize = 12, Text = "(not set)", AutomationId = "QueryPropertyLocationLabel" }; + _iqaNameLabel = new Label { FontSize = 12, Text = "(not set)", AutomationId = "IQueryAttributableReceivedLabel" }; + _iqaCallCountLabel = new Label { FontSize = 12, Text = "0", AutomationId = "DictAppliedCountLabel" }; + + var identityLabel = new Label + { + Text = "Query Data Detail", + FontSize = 14, + FontAttributes = FontAttributes.Bold, + Margin = new Thickness(10, 8, 10, 4), + AutomationId = "QueryDataDetailPageIdentityLabel" + }; + + var goBackBtn = ShellNavHelper.CreateNavButton("Go Back", "..", "QueryDetailGoBackButton"); + var goBackWithDataBtn = new Button + { + Text = "Go Back with Data", + FontSize = 11, + HeightRequest = 35, + Padding = new Thickness(8, 0), + Margin = new Thickness(10, 4), + HorizontalOptions = LayoutOptions.Fill, + AutomationId = "QueryDetailGoBackWithDataButton" + }; + goBackWithDataBtn.Clicked += async (s, e) => await Shell.Current.GoToAsync("..?backvalue=ReturnedData"); + + // Navigates forward without passing data — Dictionary data will re-apply on return (persistence demo) + var goToIntermediateBtn = new Button + { + Text = "Go to Intermediate (no data)", + FontSize = 11, + HeightRequest = 35, + Padding = new Thickness(8, 0), + Margin = new Thickness(10, 4), + HorizontalOptions = LayoutOptions.Fill, + AutomationId = "QueryDetailGoToIntermediateButton" + }; + goToIntermediateBtn.Clicked += async (s, e) => await Shell.Current.GoToAsync("queryintermediate"); + + var grid = new Grid + { + Padding = 10, + RowSpacing = 4, + ColumnSpacing = 10, + RowDefinitions = { new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto) }, + ColumnDefinitions = { new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Star) } + }; + + void AddRow(int row, string labelText, Label valueLabel) + { + var lbl = new Label { Text = labelText, FontSize = 12 }; + Grid.SetRow(lbl, row); + Grid.SetColumn(lbl, 0); + Grid.SetRow(valueLabel, row); + Grid.SetColumn(valueLabel, 1); + grid.Children.Add(lbl); + grid.Children.Add(valueLabel); + } + + AddRow(0, "[QueryProp] name:", _attributeNameLabel); + AddRow(1, "[QueryProp] location:", _attributeLocationLabel); + AddRow(2, "IQA name:", _iqaNameLabel); + AddRow(3, "IQA call count:", _iqaCallCountLabel); + foreach (var (btn, row) in new (Button, int)[] { (goBackBtn, 4), (goBackWithDataBtn, 5), (goToIntermediateBtn, 6) }) + { + Grid.SetRow(btn, row); + Grid.SetColumn(btn, 0); + Grid.SetColumnSpan(btn, 2); + grid.Children.Add(btn); + } + + Content = new ScrollView { Content = new VerticalStackLayout { Spacing = 4, Children = { identityLabel, grid } } }; + } + + public void ApplyQueryAttributes(IDictionary query) + { + if (query.TryGetValue("name", out var nameVal)) + { + _iqaCallCount++; + _iqaCallCountLabel.Text = _iqaCallCount.ToString(); + var name = nameVal?.ToString() ?? "(null)"; + _attributeNameLabel.Text = name; + _iqaNameLabel.Text = name; + } + + if (query.TryGetValue("location", out var locVal)) + _attributeLocationLabel.Text = locVal?.ToString() ?? "(null)"; + } + } + + // ── Pass Data: QueryIntermediatePage ───────────────────────────────────── + public class QueryIntermediatePage : ContentPage, IQueryAttributable + { + readonly Label _receivedNameLabel; + readonly Label _receivedLocationLabel; + readonly Label _callCountLabel; + int _callCount; + + public QueryIntermediatePage() + { + Title = "QueryIntermediate"; + AutomationId = "QueryIntermediatePage"; + + _receivedNameLabel = new Label { FontSize = 12, Text = "(none)", AutomationId = "QueryIntermediateReceivedNameLabel" }; + _receivedLocationLabel = new Label { FontSize = 12, Text = "(none)", AutomationId = "QueryIntermediateReceivedLocationLabel" }; + _callCountLabel = new Label { FontSize = 12, Text = "0", AutomationId = "QueryIntermediateCallCountLabel" }; + + var grid = new Grid + { + Padding = 10, + RowSpacing = 4, + ColumnSpacing = 10, + RowDefinitions = { new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto) }, + ColumnDefinitions = { new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Star) } + }; + + void AddRow(int row, string labelText, Label valueLabel) + { + var lbl = new Label { Text = labelText, FontSize = 12 }; + Grid.SetRow(lbl, row); + Grid.SetColumn(lbl, 0); + Grid.SetRow(valueLabel, row); + Grid.SetColumn(valueLabel, 1); + grid.Children.Add(lbl); + grid.Children.Add(valueLabel); + } + + AddRow(0, "Received name:", _receivedNameLabel); + AddRow(1, "Received location:", _receivedLocationLabel); + AddRow(2, "IQA call count:", _callCountLabel); + + Content = new ScrollView + { + Content = new VerticalStackLayout + { + Padding = 10, + Spacing = 8, + Children = + { + new Label { Text = "Intermediate Page", FontSize = 14, FontAttributes = FontAttributes.Bold, AutomationId = "QueryIntermediatePageIdentityLabel" }, + grid, + ShellNavHelper.CreateNavButton("Go Back", "..", "QueryIntermediateGoBackButton") + } + } + }; + } + + public void ApplyQueryAttributes(IDictionary query) + { + _callCount++; + _callCountLabel.Text = _callCount.ToString(); + + if (query.TryGetValue("name", out var nameVal)) + _receivedNameLabel.Text = nameVal?.ToString() ?? "(null)"; + + if (query.TryGetValue("location", out var locVal)) + _receivedLocationLabel.Text = locVal?.ToString() ?? "(null)"; + } + } + + static class ShellNavHelper + { + public static Button CreateNavButton(string text, string route, string automationId) + { + var btn = new Button + { + Text = text, + FontSize = 12, + HeightRequest = 35, + Padding = new Thickness(8, 0), + Margin = new Thickness(10, 4), + HorizontalOptions = LayoutOptions.Fill, + AutomationId = automationId + }; + btn.Clicked += async (s, e) => + { + try + { await Shell.Current.GoToAsync(route); } + catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"Navigation failed: {ex.Message}"); } + }; + return btn; + } + } } diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/FeatureMatrix/ShellNavigationFeatureTests.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/FeatureMatrix/ShellNavigationFeatureTests.cs index 01f9bb79a603..1ba37a07e9df 100644 --- a/src/Controls/tests/TestCases.Shared.Tests/Tests/FeatureMatrix/ShellNavigationFeatureTests.cs +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/FeatureMatrix/ShellNavigationFeatureTests.cs @@ -87,7 +87,7 @@ void TapContent1() App.WaitForElement("Content1"); App.Tap("Content1"); #else - App.TapTab("Content1"); + App.TapTab("Content1"); #endif } @@ -98,7 +98,7 @@ void TapContent2() App.WaitForElement("Content2"); App.Tap("Content2"); #else - App.TapTab("Content2"); + App.TapTab("Content2"); #endif } @@ -845,6 +845,78 @@ public void PassData_Dictionary_PersistsWhenNavigatingToIntermediatePageAndBack( App.WaitForElement("MainPageIdentityLabel"); } + // ── Multi-Page Navigation: Intermediate Page Parameters ───────────────── + + // Multi-page GoToAsync with prefixed params: intermediate page receives its own params, + // last page receives unprefixed params. Overlapping "name" key is scoped correctly. + [Test, Order(148)] + public void PassData_MultiPageNavigation_IntermediateAndLastPageReceiveCorrectParams() + { + App.WaitForElement("MainPageIdentityLabel"); + NavigateToQuerySenderAndWait(); + + App.ClearText("QuerySendNameEntry"); + App.EnterText("QuerySendNameEntry", "DetailName"); + App.ClearText("QuerySendLocationEntry"); + App.EnterText("QuerySendLocationEntry", "DetailLoc"); + App.Tap("QuerySendMultiPagePrefixedButton"); + + // Lands on the last page (QueryDataDetail) + App.WaitForElement("QueryDataDetailPageIdentityLabel"); + + // Last page should have received unprefixed params + Assert.That(App.FindElement("QueryPropertyReceivedLabel").GetText(), Is.EqualTo("DetailName")); + Assert.That(App.FindElement("QueryPropertyLocationLabel").GetText(), Is.EqualTo("DetailLoc")); + + // Navigate back to the intermediate page and verify it received its prefixed param + App.Tap("QueryDetailGoBackButton"); + App.WaitForElement("QueryIntermediatePageIdentityLabel"); + + // Intermediate page should have received "name=ForIntermediate" (from queryintermediate.name=ForIntermediate) + Assert.That(App.FindElement("QueryIntermediateReceivedNameLabel").GetText(), Is.EqualTo("ForIntermediate")); + // Intermediate page should NOT have received "location" (unprefixed, meant for last page only) + Assert.That(App.FindElement("QueryIntermediateReceivedLocationLabel").GetText(), Is.EqualTo("(none)")); + // IQA should have been called exactly once + Assert.That(App.FindElement("QueryIntermediateCallCountLabel").GetText(), Is.EqualTo("1")); + + // cleanup + App.Tap("QueryIntermediateGoBackButton"); + App.WaitForElement("QuerySenderPageIdentityLabel"); + App.Tap("QuerySenderGoBackButton"); + App.WaitForElement("MainPageIdentityLabel"); + } + + // Verifies that overlapping param names are correctly scoped: intermediate gets + // "name=ForIntermediate" while detail gets "name=DetailValue" — they don't cross-contaminate. + [Test, Order(149)] + public void PassData_MultiPageNavigation_OverlappingParamNamesAreScopedCorrectly() + { + App.WaitForElement("MainPageIdentityLabel"); + NavigateToQuerySenderAndWait(); + + App.ClearText("QuerySendNameEntry"); + App.EnterText("QuerySendNameEntry", "DetailValue"); + App.Tap("QuerySendMultiPagePrefixedButton"); + + // Lands on detail page + App.WaitForElement("QueryDataDetailPageIdentityLabel"); + + // Detail page should have "DetailValue", NOT "ForIntermediate" + Assert.That(App.FindElement("QueryPropertyReceivedLabel").GetText(), Is.EqualTo("DetailValue")); + Assert.That(App.FindElement("IQueryAttributableReceivedLabel").GetText(), Is.EqualTo("DetailValue")); + + // Go back to intermediate — should have "ForIntermediate", NOT "DetailValue" + App.Tap("QueryDetailGoBackButton"); + App.WaitForElement("QueryIntermediatePageIdentityLabel"); + Assert.That(App.FindElement("QueryIntermediateReceivedNameLabel").GetText(), Is.EqualTo("ForIntermediate")); + + // cleanup + App.Tap("QueryIntermediateGoBackButton"); + App.WaitForElement("QuerySenderPageIdentityLabel"); + App.Tap("QuerySenderGoBackButton"); + App.WaitForElement("MainPageIdentityLabel"); + } + // ── BackButtonBehavior Properties ───────────────────────────────────────── #if TEST_FAILS_ON_WINDOWS // Issue Link: https://github.com/dotnet/maui/issues/1625 // BackButtonBehavior.Text replaces the back button label with a custom string. @@ -884,9 +956,9 @@ public void BackButtonBehavior_CommandParameter_CommandFiresWithCorrectParameter public void BackButtonBehavior_IsEnabled_False_BackButtonDoesNotNavigate() { if (iOS26OrHigher) - { - Assert.Ignore("Fails on iOS 26 due to bug issue: https://github.com/dotnet/maui/issues/34771"); - } + { + Assert.Ignore("Fails on iOS 26 due to bug issue: https://github.com/dotnet/maui/issues/34771"); + } App.WaitForElement("MainPageIdentityLabel"); App.WaitForElement("IsEnabledButton"); App.Tap("IsEnabledButton"); @@ -910,9 +982,9 @@ public void BackButtonBehavior_IsEnabled_False_BackButtonDoesNotNavigate() public void BackButtonBehavior_IsVisible_False_ProgrammaticNavStillWorks() { if (iOS26OrHigher) - { - Assert.Ignore("Fails on iOS 26 due to bug issue: https://github.com/dotnet/maui/issues/34771"); - } + { + Assert.Ignore("Fails on iOS 26 due to bug issue: https://github.com/dotnet/maui/issues/34771"); + } App.WaitForElement("MainPageIdentityLabel"); App.WaitForElement("IsVisibleButton"); App.Tap("IsVisibleButton"); @@ -926,9 +998,9 @@ public void BackButtonBehavior_IsVisible_False_ProgrammaticNavStillWorks() public void BackButtonBehavior_IconOverride_CustomIconShownOnBackButton() { if (iOS26OrHigher) - { - NavigateToDetail1AndWait(); - } + { + NavigateToDetail1AndWait(); + } App.WaitForElement("Detail1GoBackButton"); App.Tap("Detail1GoBackButton"); App.WaitForElement("MainPageIdentityLabel"); From 95e0ab79d03a95a753a8e91395c12e9de33317cb Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Wed, 13 May 2026 22:59:40 +0200 Subject: [PATCH 3/3] Address PR review: empty-query guard, fix comment, add tests - Add ShellContent-style guard: skip empty filteredQuery on intermediate pages if QueryAttributesProperty was never previously set. Prevents spurious empty ApplyQueryAttributes calls during multi-page navigation when no prefixed params target the intermediate page. - Fix misleading comment: remove 'Parent is null' claim since the branch also fires for in-stack pages from PrepareCurrentStackForBeingReplaced. - Add test: IntermediatePageWithNoPrefixedParamsDoesNotReceiveEmptyQuery verifies the empty-query guard works correctly. - Add test: IntermediatePageReceivesParamsOnReNavigation verifies params are delivered on subsequent navigations to the same route. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/Core/Shell/ShellNavigationManager.cs | 5 +- .../ShellParameterPassingTests.cs | 49 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/Controls/src/Core/Shell/ShellNavigationManager.cs b/src/Controls/src/Core/Shell/ShellNavigationManager.cs index 8de16a83f67d..8816ce9fa3cb 100644 --- a/src/Controls/src/Core/Shell/ShellNavigationManager.cs +++ b/src/Controls/src/Core/Shell/ShellNavigationManager.cs @@ -334,10 +334,13 @@ public static void ApplyQueryAttributes(Element element, ShellRouteParameters qu } else if (element is Page) { - // Intermediate page not yet in visual tree (Parent is null). + // Intermediate page (not the last item, not wrapped in ShellContent). // Apply prefix-filtered query parameters directly via the attached property, // which triggers OnQueryAttributesPropertyChanged to handle IQueryAttributable, // BindingContext propagation, and [QueryProperty] attributes. + if (filteredQuery.Count == 0 && !element.IsSet(ShellContent.QueryAttributesProperty)) + return; + var mergedData = MergeData(element, filteredQuery, isPopping); if (mergedData.Count > 0 || !isPopping) { diff --git a/src/Controls/tests/Core.UnitTests/ShellParameterPassingTests.cs b/src/Controls/tests/Core.UnitTests/ShellParameterPassingTests.cs index b4b62e265a38..c946fabe6800 100644 --- a/src/Controls/tests/Core.UnitTests/ShellParameterPassingTests.cs +++ b/src/Controls/tests/Core.UnitTests/ShellParameterPassingTests.cs @@ -931,5 +931,54 @@ public async Task IntermediatePageDoesNotReceiveUnprefixedParams() Assert.True(lastPage.AppliedQueryAttributes[0].ContainsKey("name")); Assert.Equal("Alice", lastPage.AppliedQueryAttributes[0]["name"]); } + + [Fact] + public async Task IntermediatePageWithNoPrefixedParamsDoesNotReceiveEmptyQuery() + { + var shell = new TestShell(); + var item = CreateShellItem(shellSectionRoute: "section", shellContentRoute: "content"); + shell.Items.Add(item); + + var intermediatePage = new ShellTestPage(); + shell.RegisterPage("product", intermediatePage); + Routing.RegisterRoute("review", typeof(ShellTestPage)); + + // No prefixed params for "product" — only unprefixed params for last page + await shell.GoToAsync("product/review?name=Alice"); + + // Intermediate page should NOT receive ApplyQueryAttributes at all + Assert.Empty(intermediatePage.AppliedQueryAttributes); + + // Last page should still get the param + var lastPage = shell.CurrentPage as ShellTestPage; + Assert.NotNull(lastPage); + Assert.Single(lastPage.AppliedQueryAttributes); + Assert.Equal("Alice", lastPage.AppliedQueryAttributes[0]["name"]); + } + + [Fact] + public async Task IntermediatePageReceivesParamsOnReNavigation() + { + var shell = new TestShell(); + var item = CreateShellItem(shellSectionRoute: "section", shellContentRoute: "content"); + shell.Items.Add(item); + + var intermediatePage = new ShellTestPage(); + shell.RegisterPage("product", intermediatePage); + Routing.RegisterRoute("review", typeof(ShellTestPage)); + + // First navigation: push product + review + await shell.GoToAsync("product/review?product.sku=tomato&stars=5"); + Assert.Single(intermediatePage.AppliedQueryAttributes); + Assert.Equal("tomato", intermediatePage.AppliedQueryAttributes[0]["sku"]); + + // Navigate back to root + await shell.GoToAsync("//section/content"); + + // Second navigation with different params + await shell.GoToAsync("product/review?product.sku=pepper&stars=3"); + Assert.Equal(2, intermediatePage.AppliedQueryAttributes.Count); + Assert.Equal("pepper", intermediatePage.AppliedQueryAttributes[1]["sku"]); + } } }