From de4d0bc56e4ac53f9aac9983542dc1b3c8a82c3c Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Fri, 15 Jan 2021 12:27:46 -0600 Subject: [PATCH] Fix Shell Navigation for Hierarchally registered Global Routes (#13330) fixes #13328 fixes #11237 * Fix Shell Navigation for Hierarchally registered Global Routes * - fix added paths * - fix relative hierarchical routing * - remove extra comment * - remove comments * - generalize shell setter * - additional test * Update BaseShellItem.cs * - fix routes pushed with longer uris * - improve matching * - clean up comments * - fix multiple back navigation with hierarchies * - fix root routes * - fix absolute routes --- Xamarin.Forms.Core.UnitTests/ShellTestBase.cs | 8 + .../ShellUriHandlerTests.cs | 181 ++++++ Xamarin.Forms.Core/Shell/NavigationRequest.cs | 27 + Xamarin.Forms.Core/Shell/RequestDefinition.cs | 50 ++ .../Shell/RouteRequestBuilder.cs | 279 ++++++++++ .../Shell/ShellNavigationManager.cs | 59 +- Xamarin.Forms.Core/Shell/ShellSection.cs | 26 +- Xamarin.Forms.Core/Shell/ShellUriHandler.cs | 526 ++++++++++-------- 8 files changed, 867 insertions(+), 289 deletions(-) create mode 100644 Xamarin.Forms.Core/Shell/NavigationRequest.cs create mode 100644 Xamarin.Forms.Core/Shell/RequestDefinition.cs create mode 100644 Xamarin.Forms.Core/Shell/RouteRequestBuilder.cs diff --git a/Xamarin.Forms.Core.UnitTests/ShellTestBase.cs b/Xamarin.Forms.Core.UnitTests/ShellTestBase.cs index 2d4e00206b5..1da123fa246 100644 --- a/Xamarin.Forms.Core.UnitTests/ShellTestBase.cs +++ b/Xamarin.Forms.Core.UnitTests/ShellTestBase.cs @@ -315,6 +315,10 @@ public List> GenerateTestFlyoutItems() public TestShell() { + Routing.RegisterRoute(nameof(TestPage1), typeof(TestPage1)); + Routing.RegisterRoute(nameof(TestPage2), typeof(TestPage2)); + Routing.RegisterRoute(nameof(TestPage3), typeof(TestPage3)); + this.Navigated += (_, __) => NavigatedCount++; this.Navigating += (_, __) => NavigatingCount++; } @@ -458,5 +462,9 @@ public string Text } } } + + public class TestPage1 : ContentPage { } + public class TestPage2 : ContentPage { } + public class TestPage3 : ContentPage { } } } diff --git a/Xamarin.Forms.Core.UnitTests/ShellUriHandlerTests.cs b/Xamarin.Forms.Core.UnitTests/ShellUriHandlerTests.cs index 9cab8707c0b..c36789b503a 100644 --- a/Xamarin.Forms.Core.UnitTests/ShellUriHandlerTests.cs +++ b/Xamarin.Forms.Core.UnitTests/ShellUriHandlerTests.cs @@ -79,6 +79,186 @@ public async Task GlobalNavigateTwice() Assert.AreEqual("//rootlevelcontent1/details", shell.CurrentState.Location.ToString()); } + [Test] + public async Task GlobalRoutesRegisteredHierarchicallyNavigateCorrectly() + { + Routing.RegisterRoute("first", typeof(TestPage1)); + Routing.RegisterRoute("first/second", typeof(TestPage2)); + Routing.RegisterRoute("first/second/third", typeof(TestPage3)); + var shell = new TestShell( + CreateShellItem(shellContentRoute: "MainPage") + ); + + await shell.GoToAsync("//MainPage/first/second"); + + Assert.AreEqual(typeof(TestPage1), shell.Navigation.NavigationStack[1].GetType()); + Assert.AreEqual(typeof(TestPage2), shell.Navigation.NavigationStack[2].GetType()); + + await shell.GoToAsync("//MainPage/first/second/third"); + + Assert.AreEqual(typeof(TestPage1), shell.Navigation.NavigationStack[1].GetType()); + Assert.AreEqual(typeof(TestPage2), shell.Navigation.NavigationStack[2].GetType()); + Assert.AreEqual(typeof(TestPage3), shell.Navigation.NavigationStack[3].GetType()); + } + + [Test] + public async Task GlobalRoutesRegisteredHierarchicallyNavigateCorrectlyVariation() + { + Routing.RegisterRoute("monkeys/monkeyDetails", typeof(TestPage1)); + Routing.RegisterRoute("monkeyDetails/monkeygenome", typeof(TestPage2)); + var shell = new TestShell( + CreateShellItem(shellContentRoute: "monkeys", shellItemRoute: "animals2"), + CreateShellItem(shellContentRoute: "monkeys", shellItemRoute: "animals") + ); + + await shell.GoToAsync("//animals/monkeys/monkeyDetails?id=123"); + await shell.GoToAsync("monkeygenome"); + Assert.AreEqual("//animals/monkeys/monkeyDetails/monkeygenome", shell.CurrentState.Location.ToString()); + } + + [Test] + public async Task GlobalRoutesRegisteredHierarchicallyWithDoublePop() + { + Routing.RegisterRoute("monkeys/monkeyDetails", typeof(TestPage1)); + Routing.RegisterRoute("monkeyDetails/monkeygenome", typeof(TestPage2)); + var shell = new TestShell( + CreateShellItem(shellContentRoute: "monkeys", shellItemRoute: "animals2"), + CreateShellItem(shellContentRoute: "monkeys", shellItemRoute: "animals") + ); + + await shell.GoToAsync("//animals/monkeys/monkeyDetails?id=123"); + await shell.GoToAsync("monkeygenome"); + await shell.GoToAsync("../.."); + Assert.AreEqual("//animals/monkeys", shell.CurrentState.Location.ToString()); + } + + [Test] + public async Task GlobalRoutesRegisteredHierarchicallyWithDoubleSplash() + { + Routing.RegisterRoute("//animals/monkeys/monkeyDetails", typeof(TestPage1)); + var shell = new TestShell( + CreateShellItem(shellContentRoute: "monkeys", shellItemRoute: "animals") + ); + + await shell.GoToAsync("//animals/monkeys/monkeyDetails?id=123"); + Assert.AreEqual("//animals/monkeys/monkeyDetails", shell.CurrentState.Location.ToString()); + } + + + [Test] + public async Task RemovePageWithNestedRoutes() + { + Routing.RegisterRoute("monkeys/monkeyDetails", typeof(TestPage1)); + Routing.RegisterRoute("monkeyDetails/monkeygenome", typeof(TestPage2)); + var shell = new TestShell( + CreateShellItem(shellContentRoute: "monkeys", shellItemRoute: "animals") + ); + + await shell.GoToAsync("//animals/monkeys/monkeyDetails"); + await shell.GoToAsync("monkeygenome"); + shell.Navigation.RemovePage(shell.Navigation.NavigationStack[1]); + await shell.Navigation.PopAsync(); + } + + [Test] + public async Task GlobalRoutesRegisteredHierarchicallyNavigateCorrectlyWithAdditionalItems() + { + Routing.RegisterRoute("monkeys/monkeyDetails", typeof(TestPage1)); + Routing.RegisterRoute("monkeyDetails/monkeygenome", typeof(TestPage2)); + var shell = new TestShell( + CreateShellItem(shellContentRoute: "cats", shellSectionRoute:"domestic", shellItemRoute: "animals") + ); + + shell.Items[0].Items.Add(CreateShellContent(shellContentRoute: "monkeys")); + shell.Items[0].Items.Add(CreateShellContent(shellContentRoute: "elephants")); + shell.Items[0].Items.Add(CreateShellContent(shellContentRoute: "bears")); + shell.Items[0].Items[0].Items.Add(CreateShellContent(shellContentRoute: "dogs")); + shell.Items.Add(CreateShellContent(shellContentRoute: "about")); + await shell.GoToAsync("//animals/monkeys/monkeyDetails?id=123"); + await shell.GoToAsync("monkeygenome"); + Assert.AreEqual("//animals/monkeys/monkeyDetails/monkeygenome", shell.CurrentState.Location.ToString()); + } + + [Test] + public async Task GoBackFromRouteWithMultiplePaths() + { + Routing.RegisterRoute("monkeys/monkeyDetails", typeof(TestPage1)); + + var shell = new TestShell( + CreateShellItem() + ); + + await shell.GoToAsync("monkeys/monkeyDetails"); + await shell.GoToAsync("monkeys/monkeyDetails"); + await shell.Navigation.PopAsync(); + await shell.Navigation.PopAsync(); + } + + + [Test] + public async Task GoBackFromRouteWithMultiplePathsHierarchical() + { + Routing.RegisterRoute("monkeys/monkeyDetails", typeof(TestPage1)); + Routing.RegisterRoute("monkeyDetails/monkeygenome", typeof(TestPage2)); + + var shell = new TestShell( + CreateShellItem() + ); + + await shell.GoToAsync("monkeys/monkeyDetails"); + await shell.GoToAsync("monkeygenome"); + await shell.Navigation.PopAsync(); + await shell.Navigation.PopAsync(); + } + + [Test] + public void NodeWalkingBasic() + { + var shell = new TestShell( + CreateShellItem(shellContentRoute: "monkeys", shellItemRoute: "animals2"), + CreateShellItem(shellContentRoute: "monkeys", shellItemRoute: "animals") + ); + + ShellUriHandler.NodeLocation nodeLocation = new ShellUriHandler.NodeLocation(); + nodeLocation.SetNode(shell); + + nodeLocation = nodeLocation.WalkToNextNode(); + Assert.AreEqual(nodeLocation.Content, shell.Items[0].Items[0].Items[0]); + + nodeLocation = nodeLocation.WalkToNextNode(); + Assert.AreEqual(nodeLocation.Content, shell.Items[1].Items[0].Items[0]); + } + + + [Test] + public void NodeWalkingMultipleContent() + { + var shell = new TestShell( + CreateShellItem(shellContentRoute: "monkeys", shellItemRoute: "animals1"), + CreateShellItem(shellContentRoute: "monkeys", shellItemRoute: "animals2"), + CreateShellItem(shellContentRoute: "monkeys", shellItemRoute: "animals3"), + CreateShellItem(shellContentRoute: "monkeys", shellItemRoute: "animals4") + ); + + var content = CreateShellContent(); + shell.Items[1].Items[0].Items.Add(content); + shell.Items[2].Items[0].Items.Add(CreateShellContent()); + + // add a section with now content + shell.Items[0].Items.Add(new ShellSection()); + + ShellUriHandler.NodeLocation nodeLocation = new ShellUriHandler.NodeLocation(); + nodeLocation.SetNode(content); + + nodeLocation = nodeLocation.WalkToNextNode(); + Assert.AreEqual(shell.Items[2].Items[0].Items[0], nodeLocation.Content); + + nodeLocation = nodeLocation.WalkToNextNode(); + Assert.AreEqual(shell.Items[2].Items[0].Items[1], nodeLocation.Content); + + nodeLocation = nodeLocation.WalkToNextNode(); + Assert.AreEqual(shell.Items[3].Items[0].Items[0], nodeLocation.Content); + } [Test] public async Task GlobalRegisterAbsoluteMatching() @@ -356,6 +536,7 @@ public async Task RelativeNavigationToShellElementThrows() Assert.That(async () => await shell.GoToAsync($"domestic"), Throws.Exception); } + [Test] public async Task RelativeNavigationWithRoute() { diff --git a/Xamarin.Forms.Core/Shell/NavigationRequest.cs b/Xamarin.Forms.Core/Shell/NavigationRequest.cs new file mode 100644 index 00000000000..18353a3fbb5 --- /dev/null +++ b/Xamarin.Forms.Core/Shell/NavigationRequest.cs @@ -0,0 +1,27 @@ +using System.Diagnostics; + +namespace Xamarin.Forms +{ + [DebuggerDisplay("RequestDefinition = {Request}, StackRequest = {StackRequest}")] + internal class NavigationRequest + { + public enum WhatToDoWithTheStack + { + ReplaceIt, + PushToIt + } + + public NavigationRequest(RequestDefinition definition, WhatToDoWithTheStack stackRequest, string query, string fragment) + { + StackRequest = stackRequest; + Query = query; + Fragment = fragment; + Request = definition; + } + + public WhatToDoWithTheStack StackRequest { get; } + public string Query { get; } + public string Fragment { get; } + public RequestDefinition Request { get; } + } +} diff --git a/Xamarin.Forms.Core/Shell/RequestDefinition.cs b/Xamarin.Forms.Core/Shell/RequestDefinition.cs new file mode 100644 index 00000000000..9fbf4386099 --- /dev/null +++ b/Xamarin.Forms.Core/Shell/RequestDefinition.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; + +namespace Xamarin.Forms +{ + [DebuggerDisplay("Full = {FullUri}, Short = {ShortUri}")] + internal class RequestDefinition + { + public RequestDefinition(RouteRequestBuilder theWinningRoute, Shell shell) + { + Item = theWinningRoute.Item; + Section = theWinningRoute.Section ?? Item?.CurrentItem; + Content = theWinningRoute.Content ?? Section?.CurrentItem; + GlobalRoutes = theWinningRoute.GlobalRouteMatches; + + List builder = new List(); + if (Item?.Route != null) + builder.Add(Item.Route); + + if (Section?.Route != null) + builder.Add(Section?.Route); + + if (Content?.Route != null) + builder.Add(Content?.Route); + + if (GlobalRoutes != null) + builder.AddRange(GlobalRoutes); + + var uriPath = MakeUriString(builder); + var uri = ShellUriHandler.CreateUri(uriPath); + FullUri = ShellUriHandler.ConvertToStandardFormat(shell, uri); + + } + + string MakeUriString(List segments) + { + if (segments[0].StartsWith("/", StringComparison.Ordinal) || segments[0].StartsWith("\\", StringComparison.Ordinal)) + return String.Join("/", segments); + + return $"//{String.Join("/", segments)}"; + } + + public Uri FullUri { get; } + public ShellItem Item { get; } + public ShellSection Section { get; } + public ShellContent Content { get; } + public List GlobalRoutes { get; } + } +} diff --git a/Xamarin.Forms.Core/Shell/RouteRequestBuilder.cs b/Xamarin.Forms.Core/Shell/RouteRequestBuilder.cs new file mode 100644 index 00000000000..45fdd2f123d --- /dev/null +++ b/Xamarin.Forms.Core/Shell/RouteRequestBuilder.cs @@ -0,0 +1,279 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Xamarin.Forms +{ + /// + /// This attempts to locate the intended route trying to be navigated to + /// + internal class RouteRequestBuilder + { + readonly List _globalRouteMatches = new List(); + readonly List _matchedSegments = new List(); + readonly List _fullSegments = new List(); + readonly string[] _allSegments = null; + readonly static string _uriSeparator = "/"; + + public Shell Shell { get; private set; } + public ShellItem Item { get; private set; } + public ShellSection Section { get; private set; } + public ShellContent Content { get; private set; } + public object LowestChild => + (object)Content ?? (object)Section ?? (object)Item ?? (object)Shell; + + public RouteRequestBuilder(string[] allSegments) + { + _allSegments = allSegments; + } + + + public RouteRequestBuilder(string shellSegment, string userSegment, object node, string[] allSegments) : this(allSegments) + { + if (node != null) + AddMatch(shellSegment, userSegment, node); + else + AddGlobalRoute(userSegment, shellSegment); + } + + public RouteRequestBuilder(RouteRequestBuilder builder) : this(builder._allSegments) + { + _matchedSegments.AddRange(builder._matchedSegments); + _fullSegments.AddRange(builder._fullSegments); + _globalRouteMatches.AddRange(builder._globalRouteMatches); + Shell = builder.Shell; + Item = builder.Item; + Section = builder.Section; + Content = builder.Content; + } + + public void AddGlobalRoute(string routeName, string segment) + { + _globalRouteMatches.Add(routeName); + + foreach (string path in ShellUriHandler.RetrievePaths(segment)) + { + _fullSegments.Add(path); + _matchedSegments.Add(path); + } + } + + + public bool AddMatch(ShellUriHandler.NodeLocation nodeLocation) + { + if (Item == null && !AddNode(nodeLocation.Item, NextSegment)) + return false; + + if (Section == null && !AddNode(nodeLocation.Section, NextSegment)) + return false; + + if (Content == null && !AddNode(nodeLocation.Content, NextSegment)) + return false; + + return true; + + bool AddNode(BaseShellItem baseShellItem, string nextSegment) + { + if (Routing.IsUserDefined(baseShellItem.Route) && baseShellItem.Route != nextSegment) + { + return false; + } + + AddMatch(baseShellItem.Route, GetUserSegment(baseShellItem), baseShellItem); + return true; + } + + string GetUserSegment(BaseShellItem baseShellItem) + { + if (Routing.IsUserDefined(baseShellItem)) + return baseShellItem.Route; + + return String.Empty; + } + } + + public void AddMatch(string shellSegment, string userSegment, object node) + { + if (node == null) + throw new ArgumentNullException(nameof(node)); + + switch (node) + { + case ShellUriHandler.GlobalRouteItem globalRoute: + if (globalRoute.IsFinished) + _globalRouteMatches.Add(globalRoute.SourceRoute); + break; + case Shell shell: + if (shell == Shell) + return; + + Shell = shell; + break; + case ShellItem item: + if (Item == item) + return; + + Item = item; + break; + case ShellSection section: + if (Section == section) + return; + + Section = section; + + if (Item == null) + { + Item = Section.Parent as ShellItem; + _fullSegments.Add(Item.Route); + } + + break; + case ShellContent content: + if (Content == content) + return; + + Content = content; + if (Section == null) + { + Section = Content.Parent as ShellSection; + _fullSegments.Add(Section.Route); + } + + if (Item == null) + { + Item = Section.Parent as ShellItem; + _fullSegments.Insert(0, Item.Route); + } + + break; + } + + if (Item?.Parent is Shell s) + Shell = s; + + // if shellSegment == userSegment it means the implicit route is part of the request + if (Routing.IsUserDefined(shellSegment) || shellSegment == userSegment || shellSegment == NextSegment) + _matchedSegments.Add(shellSegment); + + _fullSegments.Add(shellSegment); + } + + public string GetNextSegmentMatch(string matchMe) + { + var segmentsToMatch = ShellUriHandler.RetrievePaths(matchMe).ToList(); + // if matchMe is an absolute route then we only match + // if there are no routes already present + if (matchMe.StartsWith("/", StringComparison.Ordinal) || + matchMe.StartsWith("\\", StringComparison.Ordinal)) + { + for (var i = 0; i < _matchedSegments.Count; i++) + { + var seg = _matchedSegments[i]; + if (segmentsToMatch.Count <= i || segmentsToMatch[i] != seg) + return String.Empty; + + segmentsToMatch.Remove(seg); + } + } + + List matches = new List(); + List currentSet = new List(_matchedSegments); + + foreach(var split in segmentsToMatch) + { + string next = GetNextSegment(currentSet); + if(next == split) + { + currentSet.Add(split); + matches.Add(split); + } + else + { + return String.Empty; + } + } + + return String.Join(_uriSeparator, matches); + } + + string GetNextSegment(IReadOnlyList matchedSegments) + { + var nextMatch = matchedSegments.Count; + if (nextMatch >= _allSegments.Length) + return null; + + return _allSegments[nextMatch]; + + } + + public string NextSegment + { + get => GetNextSegment(_matchedSegments); + } + + public string RemainingPath + { + get + { + var nextMatch = _matchedSegments.Count; + if (nextMatch >= _allSegments.Length) + return null; + + return Routing.FormatRoute(String.Join(_uriSeparator, _allSegments.Skip(nextMatch))); + } + } + + public string[] RemainingSegments + { + get + { + var nextMatch = _matchedSegments.Count; + if (nextMatch >= _allSegments.Length) + return null; + + return _allSegments.Skip(nextMatch).ToArray(); + } + } + + string MakeUriString(List segments) + { + if (segments[0].StartsWith(_uriSeparator, StringComparison.Ordinal) || segments[0].StartsWith("\\", StringComparison.Ordinal)) + return String.Join(_uriSeparator, segments); + + return $"//{String.Join(_uriSeparator, segments)}"; + } + + public int MatchedParts + { + get + { + int count = GlobalRouteMatches.Count; + + if (Item != null) + count++; + if (Content != null) + count++; + if (Section != null) + count++; + + return count; + } + } + + public string PathNoImplicit => MakeUriString(_matchedSegments); + public string PathFull => MakeUriString(_fullSegments); + + public bool IsFullMatch => _matchedSegments.Count == _allSegments.Length; + public List GlobalRouteMatches => _globalRouteMatches; + public List SegmentsMatched => _matchedSegments; + public IReadOnlyList FullSegments => _fullSegments; + public ShellUriHandler.NodeLocation GetNodeLocation() + { + ShellUriHandler.NodeLocation nodeLocation = new ShellUriHandler.NodeLocation(); + nodeLocation.SetNode(Item); + nodeLocation.SetNode(Section); + nodeLocation.SetNode(Content); + return nodeLocation; + } + } +} diff --git a/Xamarin.Forms.Core/Shell/ShellNavigationManager.cs b/Xamarin.Forms.Core/Shell/ShellNavigationManager.cs index b4309a60ad1..d84f544fe10 100644 --- a/Xamarin.Forms.Core/Shell/ShellNavigationManager.cs +++ b/Xamarin.Forms.Core/Shell/ShellNavigationManager.cs @@ -465,7 +465,7 @@ public static ShellNavigationState GetNavigationState(ShellItem shellItem, Shell for (int i = 1; i < sectionStack.Count; i++) { var page = sectionStack[i]; - routeStack.AddRange(CollapsePath(Routing.GetRoute(page), routeStack, hasUserDefinedRoute)); + routeStack.AddRange(ShellUriHandler.CollapsePath(Routing.GetRoute(page), routeStack, hasUserDefinedRoute)); } } @@ -475,11 +475,11 @@ public static ShellNavigationState GetNavigationState(ShellItem shellItem, Shell { var topPage = modalStack[i]; - routeStack.AddRange(CollapsePath(Routing.GetRoute(topPage), routeStack, hasUserDefinedRoute)); + routeStack.AddRange(ShellUriHandler.CollapsePath(Routing.GetRoute(topPage), routeStack, hasUserDefinedRoute)); for (int j = 1; j < topPage.Navigation.NavigationStack.Count; j++) { - routeStack.AddRange(CollapsePath(Routing.GetRoute(topPage.Navigation.NavigationStack[j]), routeStack, hasUserDefinedRoute)); + routeStack.AddRange(ShellUriHandler.CollapsePath(Routing.GetRoute(topPage.Navigation.NavigationStack[j]), routeStack, hasUserDefinedRoute)); } } } @@ -490,46 +490,31 @@ public static ShellNavigationState GetNavigationState(ShellItem shellItem, Shell routeStack.Insert(0, "/"); return new ShellNavigationState(String.Join("/", routeStack), true); + + } + public static List BuildFlattenedNavigationStack(Shell shell) + { + var section = shell.CurrentItem.CurrentItem; + return BuildFlattenedNavigationStack(section.Stack, section.Navigation.ModalStack); + } + + public static List BuildFlattenedNavigationStack(IReadOnlyList startingList, IReadOnlyList modalStack) + { + var returnValue = startingList.ToList(); + if (modalStack == null) + return returnValue; - List CollapsePath( - string myRoute, - IEnumerable currentRouteStack, - bool userDefinedRoute) + for (int i = 0; i < modalStack.Count; i++) { - var localRouteStack = currentRouteStack.ToList(); - for (var i = localRouteStack.Count - 1; i >= 0; i--) - { - var route = localRouteStack[i]; - if (Routing.IsImplicit(route) || - (Routing.IsDefault(route) && userDefinedRoute)) - { - localRouteStack.RemoveAt(i); - } - } - - var paths = myRoute.Split('/').ToList(); - - // collapse similar leaves - int walkBackCurrentStackIndex = localRouteStack.Count - (paths.Count - 1); - - while (paths.Count > 1 && walkBackCurrentStackIndex >= 0) + returnValue.Add(modalStack[i]); + for (int j = 1; j < modalStack[i].Navigation.NavigationStack.Count; j++) { - if (paths[0] == localRouteStack[walkBackCurrentStackIndex]) - { - paths.RemoveAt(0); - } - else - { - break; - } - - walkBackCurrentStackIndex++; + returnValue.Add(modalStack[i].Navigation.NavigationStack[j]); } - - return paths; } - } + return returnValue; + } } } diff --git a/Xamarin.Forms.Core/Shell/ShellSection.cs b/Xamarin.Forms.Core/Shell/ShellSection.cs index 800dbb50344..a1d158b3d5a 100644 --- a/Xamarin.Forms.Core/Shell/ShellSection.cs +++ b/Xamarin.Forms.Core/Shell/ShellSection.cs @@ -382,7 +382,7 @@ async Task PrepareCurrentStackForBeingReplaced(NavigationRequest request, IDicti bool isLast = i == globalRoutes.Count - 1; route = globalRoutes[i]; - navStack = BuildFlattenedNavigationStack(_navStack, Navigation?.ModalStack); + navStack = ShellNavigationManager.BuildFlattenedNavigationStack(_navStack, Navigation?.ModalStack); // if the navStack count is one that means there is nothing pushed if (navStack.Count == 1) @@ -422,7 +422,7 @@ async Task PrepareCurrentStackForBeingReplaced(NavigationRequest request, IDicti await Navigation.ModalStack[Navigation.ModalStack.Count - 1].Navigation.PopAsync(isAnimated); } - navStack = BuildFlattenedNavigationStack(_navStack, Navigation?.ModalStack); + navStack = ShellNavigationManager.BuildFlattenedNavigationStack(_navStack, Navigation?.ModalStack); } while (_navStack.Count > popCount) @@ -440,7 +440,7 @@ async Task PrepareCurrentStackForBeingReplaced(NavigationRequest request, IDicti } } - navStack = BuildFlattenedNavigationStack(_navStack, Navigation?.ModalStack); + navStack = ShellNavigationManager.BuildFlattenedNavigationStack(_navStack, Navigation?.ModalStack); IsPoppingModalStack = false; @@ -475,24 +475,6 @@ void RemoveExcessPathsWithinTheRoute() } } - List BuildFlattenedNavigationStack(List startingList, IReadOnlyList modalStack) - { - startingList = startingList.ToList(); - if (modalStack == null) - return startingList; - - for (int i = 0; i < modalStack.Count; i++) - { - startingList.Add(modalStack[i]); - for (int j = 1; j < modalStack[i].Navigation.NavigationStack.Count; j++) - { - startingList.Add(modalStack[i].Navigation.NavigationStack[j]); - } - } - - return startingList; - } - Page GetOrCreateFromRoute(string route, IDictionary queryData, bool isLast, bool isPopping) { var content = Routing.GetOrCreateContent(route) as Page; @@ -522,7 +504,7 @@ internal async Task GoToAsync(NavigationRequest request, IDictionary modalPageStacks = new List(); List nonModalPageStacks = new List(); - var currentNavStack = BuildFlattenedNavigationStack(_navStack, Navigation?.ModalStack); + var currentNavStack = ShellNavigationManager.BuildFlattenedNavigationStack(_navStack, Navigation?.ModalStack); // populate global routes and build modal stacks diff --git a/Xamarin.Forms.Core/Shell/ShellUriHandler.cs b/Xamarin.Forms.Core/Shell/ShellUriHandler.cs index b3b90a3eeaf..ba5be4aea5f 100644 --- a/Xamarin.Forms.Core/Shell/ShellUriHandler.cs +++ b/Xamarin.Forms.Core/Shell/ShellUriHandler.cs @@ -8,7 +8,6 @@ namespace Xamarin.Forms { - internal class ShellUriHandler { static readonly char[] _pathSeparators = { '/', '\\' }; @@ -18,7 +17,44 @@ internal static Uri FormatUri(Uri path, Shell shell) { if (path.OriginalString.StartsWith("..") && shell?.CurrentState != null) { - var result = IOPath.Combine(shell.CurrentState.FullLocation.OriginalString, path.OriginalString); + var pages = ShellNavigationManager.BuildFlattenedNavigationStack(shell); + var currentState = shell.CurrentState.FullLocation.OriginalString; + + List restOfPath = new List(); + bool dotsAllParsed = false; + foreach(var p in path.OriginalString.Split(_pathSeparators)) + { + if (p != ".." || dotsAllParsed) + { + dotsAllParsed = true; + restOfPath.Add(p); + continue; + } + + var lastPage = pages[pages.Count - 1]; + if (lastPage == null) + break; + + pages.Remove(lastPage); + + List buildUpPages = new List(); + + foreach(var page in pages) + { + if (page == null) + continue; + + var route = Routing.GetRoute(page); + buildUpPages.AddRange(CollapsePath(route, buildUpPages, false)); + } + + restOfPath = buildUpPages; + } + + restOfPath.Insert(0, shell.CurrentItem.CurrentItem.CurrentItem.Route); + restOfPath.Insert(0, shell.CurrentItem.CurrentItem.Route); + restOfPath.Insert(0, shell.CurrentItem.Route); + var result = String.Join(_pathSeparator, restOfPath); var returnValue = ConvertToStandardFormat("scheme", "host", null, new Uri(result, UriKind.Relative)); return new Uri(FormatUri(returnValue.PathAndQuery), UriKind.Relative); } @@ -136,7 +172,6 @@ internal static NavigationRequest GetNavigationRequest(Shell shell, Uri uri, boo var possibleRouteMatches = GenerateRoutePaths(shell, request, uri, enableRelativeShellRoutes); - if (possibleRouteMatches.Count == 0) { if (throwNavigationErrorAsException) @@ -259,6 +294,42 @@ internal static List GenerateRoutePaths(Shell shell, Uri re SearchPath(currentLocation.LowestChild, null, segments, pureGlobalRoutesMatch, 0, ignoreGlobalRoutes: false); ExpandOutGlobalRoutes(pureGlobalRoutesMatch, routeKeys); + + + if(currentLocation.Content != null && pureGlobalRoutesMatch.Count == 0) + { + string newPath = $"{shell.CurrentState.Location.OriginalString}{_pathSeparator}{String.Join(_pathSeparator, segments)}"; + var currentSegments = RetrievePaths(shell.CurrentState.FullLocation.OriginalString); + var newSegments = RetrievePaths(newPath); + + RouteRequestBuilder routeRequestBuilder = new RouteRequestBuilder(newSegments); + RouteRequestBuilder requestBuilderWithNewSegments = new RouteRequestBuilder(segments); + + // add shell element routes + routeRequestBuilder.AddMatch(currentLocation); + + // add routes that are contributed by global routes + for (var i = 0; i < currentSegments.Length; i++) + { + var currentSeg = currentSegments[i]; + if (routeRequestBuilder.FullSegments.Count <= i || currentSeg != routeRequestBuilder.FullSegments[i]) + { + routeRequestBuilder.AddGlobalRoute(currentSeg, currentSeg); + } + } + + var existingGlobalRoutes = routeRequestBuilder.GlobalRouteMatches.ToList(); + ExpandOutGlobalRoutes(new List { routeRequestBuilder }, routeKeys); + if (routeRequestBuilder.IsFullMatch) + { + var additionalRouteMatches = routeRequestBuilder.GlobalRouteMatches; + for (int i = existingGlobalRoutes.Count; i < additionalRouteMatches.Count; i++) + requestBuilderWithNewSegments.AddGlobalRoute(additionalRouteMatches[i], segments[i - existingGlobalRoutes.Count]); + + pureGlobalRoutesMatch.Add(requestBuilderWithNewSegments); + } + } + pureGlobalRoutesMatch = GetBestMatches(pureGlobalRoutesMatch); if (pureGlobalRoutesMatch.Count > 0) { @@ -320,18 +391,127 @@ internal static List GenerateRoutePaths(Shell shell, Uri re return possibleRoutePaths; } - internal static void ExpandOutGlobalRoutes(List possibleRoutePaths, string[] routeKeys) + // The purpose of this method is to give an accurate representation of what a target URI means based + // on the current location in the Shell. + // If a user is registering full route paths Route.Register("path1/path2/path3") + // and Shell is currently at "path1/path2" this will just return "path3". + // This way if the user navigates with GotoAsync("path3") then that navigation will succeed + // This also removes implicit routes that might be in the middle of a global route and the shell elements + // "//MyShellSection/ShellContent_IMPL/Page1" + internal static List CollapsePath( + string myRoute, + IEnumerable currentRouteStack, + bool userDefinedRoute) { - foreach (var possibleRoutePath in possibleRoutePaths) + var localRouteStack = currentRouteStack.ToList(); + for (var i = localRouteStack.Count - 1; i >= 0; i--) + { + var route = localRouteStack[i]; + if (Routing.IsImplicit(route) || + (Routing.IsDefault(route) && userDefinedRoute)) + { + localRouteStack.RemoveAt(i); + } + } + + var paths = RetrievePaths(myRoute).ToList(); + + // collapse similar leaves + int walkBackCurrentStackIndex = localRouteStack.Count - (paths.Count - 1); + + while (paths.Count > 1 && walkBackCurrentStackIndex >= 0) { - while (routeKeys.Contains(possibleRoutePath.NextSegment) || routeKeys.Contains(possibleRoutePath.RemainingPath)) + if (paths[0] == localRouteStack[walkBackCurrentStackIndex]) + { + paths.RemoveAt(0); + } + else + { + break; + } + + walkBackCurrentStackIndex++; + } + + return paths; + } + + static bool FindAndAddSegmentMatch(RouteRequestBuilder possibleRoutePath, string[] routeKeys) + { + // First search by collapsing global routes if user is registering routes like "route1/route2/route3" + foreach (var routeKey in routeKeys) + { + var collapsedRoute = String.Join(_pathSeparator, CollapsePath(routeKey, possibleRoutePath.SegmentsMatched, true)); + + if (routeKey.StartsWith("//")) + collapsedRoute = "//" + collapsedRoute; + + string collapsedMatch = possibleRoutePath.GetNextSegmentMatch(collapsedRoute); + if (!String.IsNullOrWhiteSpace(collapsedMatch)) { - if (routeKeys.Contains(possibleRoutePath.NextSegment)) - possibleRoutePath.AddGlobalRoute(possibleRoutePath.NextSegment, possibleRoutePath.NextSegment); - else - possibleRoutePath.AddGlobalRoute(possibleRoutePath.RemainingPath, possibleRoutePath.RemainingPath); + possibleRoutePath.AddGlobalRoute(routeKey, collapsedMatch); + return true; } + // If the registered route is a combination of shell items and global routes then we might end up here + // without the previous tree search finding the correct path + if ((possibleRoutePath.Shell != null) && + (possibleRoutePath.Item == null || possibleRoutePath.Section == null || possibleRoutePath.Content == null)) + { + var nextNode = possibleRoutePath.GetNodeLocation().WalkToNextNode(); + + while (nextNode != null) + { + // This means we've jumped to a branch that no longer corresponds with the route path we are searching + if((possibleRoutePath.Item != null && nextNode.Item != possibleRoutePath.Item) || + (possibleRoutePath.Section != null && nextNode.Section != possibleRoutePath.Section) || + (possibleRoutePath.Content != null && nextNode.Content != possibleRoutePath.Content)) + { + nextNode = nextNode.WalkToNextNode(); + continue; + } + + var leafSearch = new RouteRequestBuilder(possibleRoutePath); + if (!leafSearch.AddMatch(nextNode)) + { + nextNode = nextNode.WalkToNextNode(); + continue; + } + + var collapsedLeafRoute = String.Join(_pathSeparator, CollapsePath(routeKey, leafSearch.SegmentsMatched, true)); + + if (routeKey.StartsWith("//")) + collapsedLeafRoute = "//" + collapsedLeafRoute; + + string segmentMatch = leafSearch.GetNextSegmentMatch(collapsedLeafRoute); + if (!String.IsNullOrWhiteSpace(segmentMatch)) + { + possibleRoutePath.AddMatch(nextNode); + possibleRoutePath.AddGlobalRoute(routeKey, segmentMatch); + return true; + } + + nextNode = nextNode.WalkToNextNode(); + } + } + } + + // check for exact matches + if(routeKeys.Contains(possibleRoutePath.NextSegment)) + { + possibleRoutePath.AddGlobalRoute(possibleRoutePath.NextSegment, possibleRoutePath.NextSegment); + return true; + } + + return false; + } + + internal static void ExpandOutGlobalRoutes(List possibleRoutePaths, string[] routeKeys) + { + foreach (var possibleRoutePath in possibleRoutePaths) + { + while (FindAndAddSegmentMatch(possibleRoutePath, routeKeys)); + while (!possibleRoutePath.IsFullMatch) { NodeLocation nodeLocation = new NodeLocation(); @@ -349,7 +529,6 @@ internal static void ExpandOutGlobalRoutes(List possibleRou break; } - for (var i = 0; i < pureGlobalRoutesMatch[0].GlobalRouteMatches.Count; i++) { var match = pureGlobalRoutesMatch[0]; @@ -365,7 +544,69 @@ internal static List GetBestMatches(List 1) + { + List betterMatches = new List(); + for(int i = bestMatches.Count - 1; i >= 0; i--) + { + for (int j = i - 1; j >= 0; j--) + { + RouteRequestBuilder betterMatch = null; + + if (bestMatches[j].MatchedParts > bestMatches[i].MatchedParts) + betterMatch = bestMatches[j]; + else if (bestMatches[j].MatchedParts < bestMatches[i].MatchedParts) + betterMatch = bestMatches[i]; + + // nobody wins + if(betterMatch == null) + { + if (!betterMatches.Contains(bestMatches[i])) + betterMatches.Add(bestMatches[i]); + + if (!betterMatches.Contains(bestMatches[j])) + betterMatches.Add(bestMatches[j]); + } + else if (betterMatch != null && !betterMatches.Contains(betterMatch)) + betterMatches.Add(betterMatch); + } + } + + // Nothing was trimmed on last pass + if (bestMatches.Count == betterMatches.Count) + return betterMatches; + + bestMatches = betterMatches; } return bestMatches; @@ -465,6 +706,49 @@ public void Pop() else if (Shell != null) Shell = null; } + + public NodeLocation WalkToNextNode() + { + int itemIndex = 0; + int sectionIndex = 0; + int contentIndex = 0; + + if(Item != null) + { + itemIndex = Shell.Items.IndexOf(Item); + } + + if (Section != null) + { + sectionIndex = Item.Items.IndexOf(Section); + } + + if (Content != null) + { + contentIndex = Section.Items.IndexOf(Content) + 1; + } + + for (int i = itemIndex; i < Shell.Items.Count; i++) + { + for (int j = sectionIndex; j < Shell.Items[i].Items.Count; j++) + { + for (int k = contentIndex; k < Shell.Items[i].Items[j].Items.Count;) + { + var nodeLocation = new NodeLocation(); + nodeLocation.SetNode(Shell.Items[i]); + nodeLocation.SetNode(Shell.Items[i].Items[j]); + nodeLocation.SetNode(Shell.Items[i].Items[j].Items[k]); + return nodeLocation; + } + + contentIndex = 0; + } + + sectionIndex = 0; + } + + return null; + } } static void SearchPath( @@ -660,222 +944,4 @@ public bool IsFinished public string SourceRoute { get; } } } - - /// - /// This attempts to locate the intended route trying to be navigated to - /// - internal class RouteRequestBuilder - { - readonly List _globalRouteMatches = new List(); - readonly List _matchedSegments = new List(); - readonly List _fullSegments = new List(); - readonly string[] _allSegments = null; - readonly static string _uriSeparator = "/"; - - public Shell Shell { get; private set; } - public ShellItem Item { get; private set; } - public ShellSection Section { get; private set; } - public ShellContent Content { get; private set; } - public object LowestChild => - (object)Content ?? (object)Section ?? (object)Item ?? (object)Shell; - - public RouteRequestBuilder(string shellSegment, string userSegment, object node, string[] allSegments) - { - _allSegments = allSegments; - if (node != null) - AddMatch(shellSegment, userSegment, node); - else - AddGlobalRoute(userSegment, shellSegment); - } - public RouteRequestBuilder(RouteRequestBuilder builder) - { - _allSegments = builder._allSegments; - _matchedSegments.AddRange(builder._matchedSegments); - _fullSegments.AddRange(builder._fullSegments); - _globalRouteMatches.AddRange(builder._globalRouteMatches); - Shell = builder.Shell; - Item = builder.Item; - Section = builder.Section; - Content = builder.Content; - } - - public void AddGlobalRoute(string routeName, string segment) - { - _globalRouteMatches.Add(routeName); - _fullSegments.Add(segment); - _matchedSegments.Add(segment); - } - - public void AddMatch(string shellSegment, string userSegment, object node) - { - if (node == null) - throw new ArgumentNullException(nameof(node)); - - switch (node) - { - case ShellUriHandler.GlobalRouteItem globalRoute: - if (globalRoute.IsFinished) - _globalRouteMatches.Add(globalRoute.SourceRoute); - break; - case Shell shell: - Shell = shell; - break; - case ShellItem item: - Item = item; - break; - case ShellSection section: - Section = section; - - if (Item == null) - { - Item = Section.Parent as ShellItem; - _fullSegments.Add(Item.Route); - } - - break; - case ShellContent content: - Content = content; - if (Section == null) - { - Section = Content.Parent as ShellSection; - _fullSegments.Add(Section.Route); - } - - if (Item == null) - { - Item = Section.Parent as ShellItem; - _fullSegments.Insert(0, Item.Route); - } - - break; - } - - // if shellSegment == userSegment it means the implicit route is part of the request - if (!Routing.IsImplicit(shellSegment) || shellSegment == userSegment) - _matchedSegments.Add(shellSegment); - - _fullSegments.Add(shellSegment); - } - - public string NextSegment - { - get - { - var nextMatch = _matchedSegments.Count; - if (nextMatch >= _allSegments.Length) - return null; - - return _allSegments[nextMatch]; - } - } - - public string RemainingPath - { - get - { - var nextMatch = _matchedSegments.Count; - if (nextMatch >= _allSegments.Length) - return null; - - return Routing.FormatRoute(String.Join(_uriSeparator, _allSegments.Skip(nextMatch))); - } - } - - public string[] RemainingSegments - { - get - { - var nextMatch = _matchedSegments.Count; - if (nextMatch >= _allSegments.Length) - return null; - - return _allSegments.Skip(nextMatch).ToArray(); - } - } - - string MakeUriString(List segments) - { - if (segments[0].StartsWith(_uriSeparator, StringComparison.Ordinal) || segments[0].StartsWith("\\", StringComparison.Ordinal)) - return String.Join(_uriSeparator, segments); - - return $"//{String.Join(_uriSeparator, segments)}"; - } - - public string PathNoImplicit => MakeUriString(_matchedSegments); - public string PathFull => MakeUriString(_fullSegments); - - public bool IsFullMatch => _matchedSegments.Count == _allSegments.Length; - public List GlobalRouteMatches => _globalRouteMatches; - public List SegmentsMatched => _matchedSegments; - } - - - - [DebuggerDisplay("RequestDefinition = {Request}, StackRequest = {StackRequest}")] - internal class NavigationRequest - { - public enum WhatToDoWithTheStack - { - ReplaceIt, - PushToIt - } - - public NavigationRequest(RequestDefinition definition, WhatToDoWithTheStack stackRequest, string query, string fragment) - { - StackRequest = stackRequest; - Query = query; - Fragment = fragment; - Request = definition; - } - - public WhatToDoWithTheStack StackRequest { get; } - public string Query { get; } - public string Fragment { get; } - public RequestDefinition Request { get; } - } - - - [DebuggerDisplay("Full = {FullUri}, Short = {ShortUri}")] - internal class RequestDefinition - { - public RequestDefinition(RouteRequestBuilder theWinningRoute, Shell shell) - { - Item = theWinningRoute.Item; - Section = theWinningRoute.Section ?? Item?.CurrentItem; - Content = theWinningRoute.Content ?? Section?.CurrentItem; - GlobalRoutes = theWinningRoute.GlobalRouteMatches; - - List builder = new List(); - if (Item?.Route != null) - builder.Add(Item.Route); - - if (Section?.Route != null) - builder.Add(Section?.Route); - - if (Content?.Route != null) - builder.Add(Content?.Route); - - if (GlobalRoutes != null) - builder.AddRange(GlobalRoutes); - - var uriPath = MakeUriString(builder); - var uri = ShellUriHandler.CreateUri(uriPath); - FullUri = ShellUriHandler.ConvertToStandardFormat(shell, uri); - - } - - string MakeUriString(List segments) - { - if (segments[0].StartsWith("/", StringComparison.Ordinal) || segments[0].StartsWith("\\", StringComparison.Ordinal)) - return String.Join("/", segments); - - return $"//{String.Join("/", segments)}"; - } - - public Uri FullUri { get; } - public ShellItem Item { get; } - public ShellSection Section { get; } - public ShellContent Content { get; } - public List GlobalRoutes { get; } - } }