From 58662a10b4569a9f8fc753fc6364b87d1bd29829 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Wed, 10 Apr 2019 22:54:50 -0600 Subject: [PATCH] [Shell] refactor of processing uris (#5852) fixes #5790 --- Xamarin.Forms.Core.UnitTests/ShellTestBase.cs | 115 ++++ Xamarin.Forms.Core.UnitTests/ShellTests.cs | 113 ++- .../ShellUriHandlerTests.cs | 257 +++++++ .../Xamarin.Forms.Core.UnitTests.csproj | 2 + Xamarin.Forms.Core/Routing.cs | 88 ++- Xamarin.Forms.Core/Shell/BaseShellItem.cs | 2 + Xamarin.Forms.Core/Shell/IShellController.cs | 3 + Xamarin.Forms.Core/Shell/IShellItemController.cs | 2 +- .../Shell/IShellSectionController.cs | 2 +- Xamarin.Forms.Core/Shell/Shell.cs | 263 ++++--- Xamarin.Forms.Core/Shell/ShellItem.cs | 47 +- Xamarin.Forms.Core/Shell/ShellNavigationState.cs | 3 + Xamarin.Forms.Core/Shell/ShellSection.cs | 52 +- Xamarin.Forms.Core/Shell/ShellUriHandler.cs | 757 +++++++++++++++++++++ .../Properties/AndroidManifest.xml | 4 +- .../Xamarin.Forms.Sandbox.Android.csproj | 4 +- Xamarin.Forms.Sandbox/MainPage.xaml | 2 +- 17 files changed, 1500 insertions(+), 216 deletions(-) create mode 100644 Xamarin.Forms.Core.UnitTests/ShellTestBase.cs create mode 100644 Xamarin.Forms.Core.UnitTests/ShellUriHandlerTests.cs create mode 100644 Xamarin.Forms.Core/Shell/ShellUriHandler.cs diff --git a/Xamarin.Forms.Core.UnitTests/ShellTestBase.cs b/Xamarin.Forms.Core.UnitTests/ShellTestBase.cs new file mode 100644 index 0000000..c8cb062 --- /dev/null +++ b/Xamarin.Forms.Core.UnitTests/ShellTestBase.cs @@ -0,0 +1,115 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using NUnit.Framework; +using Xamarin.Forms.Internals; + +namespace Xamarin.Forms.Core.UnitTests +{ + [TestFixture] + public class ShellTestBase : BaseTestFixture + { + [SetUp] + public override void Setup() + { + Device.SetFlags(new[] { Shell.ShellExperimental }); + base.Setup(); + + } + + [TearDown] + public override void TearDown() + { + base.TearDown(); + + } + + protected Uri CreateUri(string uri) => new Uri(uri, UriKind.RelativeOrAbsolute); + + protected ShellSection MakeSimpleShellSection(string route, string contentRoute) + { + return MakeSimpleShellSection(route, contentRoute, new ShellTestPage()); + } + + protected ShellSection MakeSimpleShellSection(string route, string contentRoute, ContentPage contentPage) + { + var shellSection = new ShellSection(); + shellSection.Route = route; + var shellContent = new ShellContent { Content = contentPage, Route = contentRoute }; + shellSection.Items.Add(shellContent); + return shellSection; + } + + [QueryProperty("SomeQueryParameter", "SomeQueryParameter")] + public class ShellTestPage : ContentPage + { + public string SomeQueryParameter { get; set; } + } + + protected ShellItem CreateShellItem(TemplatedPage page = null, bool asImplicit = false, string shellContentRoute = null, string shellSectionRoute = null, string shellItemRoute = null) + { + page = page ?? new ContentPage(); + ShellItem item = null; + var section = CreateShellSection(page, asImplicit, shellContentRoute, shellSectionRoute); + + if (!String.IsNullOrWhiteSpace(shellItemRoute)) + { + item = new ShellItem(); + item.Route = shellItemRoute; + item.Items.Add(section); + } + else if (asImplicit) + item = ShellItem.CreateFromShellSection(section); + else + { + item = new ShellItem(); + item.Items.Add(section); + } + + return item; + } + + protected ShellSection CreateShellSection(TemplatedPage page = null, bool asImplicit = false, string shellContentRoute = null, string shellSectionRoute = null) + { + var content = CreateShellContent(page, asImplicit, shellContentRoute); + + ShellSection section = null; + + if (!String.IsNullOrWhiteSpace(shellSectionRoute)) + { + section = new ShellSection(); + section.Route = shellSectionRoute; + section.Items.Add(content); + } + else if (asImplicit) + section = ShellSection.CreateFromShellContent(content); + else + { + section = new ShellSection(); + section.Items.Add(content); + } + + return section; + } + + protected ShellContent CreateShellContent(TemplatedPage page = null, bool asImplicit = false, string shellContentRoute = null) + { + page = page ?? new ContentPage(); + ShellContent content = null; + + if(!String.IsNullOrWhiteSpace(shellContentRoute)) + { + content = new ShellContent() { Content = page }; + content.Route = shellContentRoute; + } + else if (asImplicit) + content = (ShellContent)page; + else + content = new ShellContent() { Content = page }; + + + return content; + } + + } +} diff --git a/Xamarin.Forms.Core.UnitTests/ShellTests.cs b/Xamarin.Forms.Core.UnitTests/ShellTests.cs index e4ab03c..84cfcb1 100644 --- a/Xamarin.Forms.Core.UnitTests/ShellTests.cs +++ b/Xamarin.Forms.Core.UnitTests/ShellTests.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Threading.Tasks; using NUnit.Framework; using Xamarin.Forms.Internals; @@ -6,15 +7,8 @@ using Xamarin.Forms.Internals; namespace Xamarin.Forms.Core.UnitTests { [TestFixture] - public class ShellTests : BaseTestFixture + public class ShellTests : ShellTestBase { - [SetUp] - public override void Setup() - { - Device.SetFlags(new[] { Shell.ShellExperimental }); - base.Setup(); - - } [Test] public void DefaultState() @@ -77,26 +71,6 @@ namespace Xamarin.Forms.Core.UnitTests Assert.AreEqual(shellItem, shell.CurrentItem); } - ShellSection MakeSimpleShellSection(string route, string contentRoute) - { - return MakeSimpleShellSection(route, contentRoute, new ShellTestPage()); - } - - ShellSection MakeSimpleShellSection (string route, string contentRoute, ContentPage contentPage) - { - var shellSection = new ShellSection(); - shellSection.Route = route; - var shellContent = new ShellContent { Content = contentPage, Route = contentRoute }; - shellSection.Items.Add(shellContent); - return shellSection; - } - - [QueryProperty("SomeQueryParameter", "SomeQueryParameter")] - public class ShellTestPage : ContentPage - { - public string SomeQueryParameter { get; set; } - } - [Test] public void SimpleGoTo() { @@ -128,6 +102,37 @@ namespace Xamarin.Forms.Core.UnitTests } [Test] + public async Task CaseIgnoreRouting() + { + var routes = new[] { "Tab1", "TAB2", "@-_-@", "+:~", "=%", "Super_Simple+-Route.doc", "1/2", @"1\2/3", "app://tab" }; + + foreach (var route in routes) + { + var formattedRoute = Routing.FormatRoute(route); + Routing.RegisterRoute(formattedRoute, typeof(ShellItem)); + + var content1 = Routing.GetOrCreateContent(formattedRoute); + Assert.IsNotNull(content1); + Assert.AreEqual(Routing.GetRoute(content1), formattedRoute); + } + + Assert.Catch(typeof(ArgumentException), () => Routing.RegisterRoute("app://IMPL_tab21", typeof(ShellItem))); + + Assert.Catch(typeof(ArgumentException), () => Routing.RegisterRoute(@"app:\\IMPL_tab21", typeof(ShellItem))); + + Assert.Catch(typeof(ArgumentException), () => Routing.RegisterRoute(string.Empty, typeof(ShellItem))); + + Assert.Catch(typeof(ArgumentNullException), () => Routing.RegisterRoute(null, typeof(ShellItem))); + + Assert.Catch(typeof(ArgumentException), () => Routing.RegisterRoute("tab1/IMPL_tab11", typeof(ShellItem))); + + Assert.Catch(typeof(ArgumentException), () => Routing.RegisterRoute("IMPL_shell", typeof(ShellItem))); + + Assert.Catch(typeof(ArgumentException), () => Routing.RegisterRoute("app://tab2/IMPL_tab21", typeof(ShellItem))); + } + + + [Test] public async Task RelativeGoTo() { var shell = new Shell @@ -165,6 +170,8 @@ namespace Xamarin.Forms.Core.UnitTests await shell.GoToAsync("/tab23"); Assert.That(shell.CurrentState.Location.ToString(), Is.EqualTo("app:///s/two/tab23/content/")); + /* + * removing support for .. notation for now await shell.GoToAsync("../one/tab11"); Assert.That(shell.CurrentState.Location.ToString(), Is.EqualTo("app:///s/one/tab11/content/")); @@ -180,6 +187,7 @@ namespace Xamarin.Forms.Core.UnitTests await shell.GoToAsync(new ShellNavigationState($"../one/tab11#fragment")); Assert.That(shell.CurrentState.Location.ToString(), Is.EqualTo("app:///s/one/tab11/content/")); + */ } [Test] @@ -313,5 +321,54 @@ namespace Xamarin.Forms.Core.UnitTests Assert.AreEqual(((IShellController)shell).FlyoutHeader, label); } + + [Test] + public async Task FlyoutNavigateToImplicitContentPage() + { + var shell = new Shell(); + var shellITem = new ShellItem() { FlyoutDisplayOptions = FlyoutDisplayOptions.AsMultipleItems, }; + var shellSection = new ShellSection() { Title = "can navigate to" }; + shellSection.Items.Add(new ContentPage()); + + var shellSection2 = new ShellSection() { Title = "can navigate to" }; + shellSection2.Items.Add(new ContentPage()); + + var implicitSection = CreateShellSection(new ContentPage(), asImplicit: true); + + shellITem.Items.Add(shellSection); + shellITem.Items.Add(shellSection2); + shellITem.Items.Add(implicitSection); + + shell.Items.Add(shellITem); + IShellController shellController = (IShellController)shell; + + await shellController.OnFlyoutItemSelectedAsync(shellSection2); + Assert.AreEqual(shellSection2, shell.CurrentItem.CurrentItem); + + await shellController.OnFlyoutItemSelectedAsync(shellSection); + Assert.AreEqual(shellSection, shell.CurrentItem.CurrentItem); + + await shellController.OnFlyoutItemSelectedAsync(implicitSection); + Assert.AreEqual(implicitSection, shell.CurrentItem.CurrentItem); + + } + + + [Test] + public async Task UriNavigationTests() + { + var shell = new Shell(); + var item1 = CreateShellItem(asImplicit: true, shellContentRoute: "rootlevelcontent1"); + var item2 = CreateShellItem(asImplicit: true, shellContentRoute: "rootlevelcontent2"); + + shell.Items.Add(item1); + shell.Items.Add(item2); + + shell.GoToAsync("//rootlevelcontent2"); + Assert.AreEqual(shell.CurrentItem, item2); + + shell.GoToAsync("//rootlevelcontent1"); + Assert.AreEqual(shell.CurrentItem, item1); + } } } diff --git a/Xamarin.Forms.Core.UnitTests/ShellUriHandlerTests.cs b/Xamarin.Forms.Core.UnitTests/ShellUriHandlerTests.cs new file mode 100644 index 0000000..c6fc643 --- /dev/null +++ b/Xamarin.Forms.Core.UnitTests/ShellUriHandlerTests.cs @@ -0,0 +1,257 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using NUnit.Framework; +using Xamarin.Forms.Internals; + +namespace Xamarin.Forms.Core.UnitTests +{ + [TestFixture] + public class ShellUriHandlerTests : ShellTestBase + { + [TearDown] + public override void TearDown() + { + base.TearDown(); + Routing.Clear(); + } + + [Test] + public async Task GlobalRegisterAbsoluteMatching() + { + var shell = new Shell() { RouteScheme = "app", Route = "shellroute" }; + Routing.RegisterRoute("/seg1/seg2/seg3", typeof(object)); + var request = ShellUriHandler.GetNavigationRequest(shell, CreateUri("app://seg1/seg2/seg3")); + + Assert.AreEqual("/seg1/seg2/seg3", request.Request.ShortUri.ToString()); + } + + [Test] + public async Task ShellContentOnlyWithGlobalEdit() + { + var shell = new Shell(); + var item1 = CreateShellItem(asImplicit: true, shellContentRoute: "rootlevelcontent1"); + var item2 = CreateShellItem(asImplicit: true, shellContentRoute: "rootlevelcontent2"); + + shell.Items.Add(item1); + shell.Items.Add(item2); + + Routing.RegisterRoute("//rootlevelcontent1/edit", typeof(ContentPage)); + await shell.GoToAsync("//rootlevelcontent1/edit"); + } + + [Test] + public async Task ShellRelativeGlobalRegistration() + { + var shell = new Shell(); + var item1 = CreateShellItem(asImplicit: true, shellItemRoute: "item1", shellContentRoute: "rootlevelcontent1", shellSectionRoute: "section1"); + var item2 = CreateShellItem(asImplicit: true, shellItemRoute: "item2", shellContentRoute: "rootlevelcontent1", shellSectionRoute: "section1"); + + Routing.RegisterRoute("section0/edit", typeof(ContentPage)); + Routing.RegisterRoute("item1/section1/edit", typeof(ContentPage)); + Routing.RegisterRoute("item2/section1/edit", typeof(ContentPage)); + Routing.RegisterRoute("//edit", typeof(ContentPage)); + shell.Items.Add(item1); + shell.Items.Add(item2); + await shell.GoToAsync("//item1/section1/rootlevelcontent1"); + var request = ShellUriHandler.GetNavigationRequest(shell, CreateUri("section1/edit")); + + Assert.AreEqual(1, request.Request.GlobalRoutes.Count); + Assert.AreEqual("item1/section1/edit", request.Request.GlobalRoutes.First()); + } + + [Test] + public async Task ShellSectionWithRelativeEditUpOneLevelMultiple() + { + var shell = new Shell(); + var item1 = CreateShellItem(asImplicit: true, shellContentRoute: "rootlevelcontent1", shellSectionRoute: "section1"); + + Routing.RegisterRoute("section1/edit", typeof(ContentPage)); + Routing.RegisterRoute("section1/add", typeof(ContentPage)); + + shell.Items.Add(item1); + + var request = ShellUriHandler.GetNavigationRequest(shell, CreateUri("//rootlevelcontent1/add/edit")); + + Assert.AreEqual(2, request.Request.GlobalRoutes.Count); + Assert.AreEqual("section1/add", request.Request.GlobalRoutes.First()); + Assert.AreEqual("section1/edit", request.Request.GlobalRoutes.Skip(1).First()); + } + + [Test] + public async Task ShellSectionWithGlobalRouteAbsolute() + { + var shell = new Shell(); + var item1 = CreateShellItem(asImplicit: true, shellContentRoute: "rootlevelcontent1", shellSectionRoute: "section1"); + + Routing.RegisterRoute("edit", typeof(ContentPage)); + + shell.Items.Add(item1); + + var request = ShellUriHandler.GetNavigationRequest(shell, CreateUri("//rootlevelcontent1/edit")); + + Assert.AreEqual(1, request.Request.GlobalRoutes.Count); + Assert.AreEqual("edit", request.Request.GlobalRoutes.First()); + } + + [Test] + public async Task ShellSectionWithGlobalRouteRelative() + { + var shell = new Shell(); + var item1 = CreateShellItem(asImplicit: true, shellContentRoute: "rootlevelcontent1", shellSectionRoute: "section1"); + + Routing.RegisterRoute("edit", typeof(ContentPage)); + + shell.Items.Add(item1); + + await shell.GoToAsync("//rootlevelcontent1"); + var request = ShellUriHandler.GetNavigationRequest(shell, CreateUri("edit")); + + Assert.AreEqual(1, request.Request.GlobalRoutes.Count); + Assert.AreEqual("edit", request.Request.GlobalRoutes.First()); + } + + + [Test] + public async Task ShellSectionWithRelativeEditUpOneLevel() + { + var shell = new Shell(); + var item1 = CreateShellItem(asImplicit: true, shellContentRoute: "rootlevelcontent1", shellSectionRoute: "section1"); + + Routing.RegisterRoute("section1/edit", typeof(ContentPage)); + + shell.Items.Add(item1); + + await shell.GoToAsync("//rootlevelcontent1"); + var request = ShellUriHandler.GetNavigationRequest(shell, CreateUri("edit")); + + Assert.AreEqual("section1/edit", request.Request.GlobalRoutes.First()); + } + + [Test] + public async Task ShellSectionWithRelativeEdit() + { + var shell = new Shell(); + var item1 = CreateShellItem(asImplicit: true, shellContentRoute: "rootlevelcontent1", shellSectionRoute:"section1"); + var editShellContent = CreateShellContent(shellContentRoute: "edit"); + + + item1.Items[0].Items.Add(editShellContent); + shell.Items.Add(item1); + + await shell.GoToAsync("//rootlevelcontent1"); + var location = shell.CurrentState.Location; + await shell.GoToAsync("edit"); + + Assert.AreEqual(editShellContent, shell.CurrentItem.CurrentItem.CurrentItem); + } + + + [Test] + public async Task ShellContentOnly() + { + var shell = new Shell(); + var item1 = CreateShellItem(asImplicit: true, shellContentRoute: "rootlevelcontent1"); + var item2 = CreateShellItem(asImplicit: true, shellContentRoute: "rootlevelcontent2"); + + shell.Items.Add(item1); + shell.Items.Add(item2); + + + var builders = ShellUriHandler.GenerateRoutePaths(shell, CreateUri("//rootlevelcontent1")); + + Assert.AreEqual(1, builders.Count); + Assert.AreEqual("//rootlevelcontent1", builders.First().PathNoImplicit); + + builders = ShellUriHandler.GenerateRoutePaths(shell, CreateUri("//rootlevelcontent2")); + Assert.AreEqual(1, builders.Count); + Assert.AreEqual("//rootlevelcontent2", builders.First().PathNoImplicit); + } + + + [Test] + public async Task ShellSectionAndContentOnly() + { + var shell = new Shell(); + var item1 = CreateShellItem(asImplicit: true, shellContentRoute: "rootlevelcontent", shellSectionRoute:"section1"); + var item2 = CreateShellItem(asImplicit: true, shellContentRoute: "rootlevelcontent", shellSectionRoute: "section2"); + + shell.Items.Add(item1); + shell.Items.Add(item2); + + + var builders = ShellUriHandler.GenerateRoutePaths(shell, CreateUri("//section1/rootlevelcontent")).Select(x=> x.PathNoImplicit).ToArray(); + + Assert.AreEqual(1, builders.Length); + Assert.IsTrue(builders.Contains("//section1/rootlevelcontent")); + + builders = ShellUriHandler.GenerateRoutePaths(shell, CreateUri("//section2/rootlevelcontent")).Select(x => x.PathNoImplicit).ToArray(); + Assert.AreEqual(1, builders.Length); + Assert.IsTrue(builders.Contains("//section2/rootlevelcontent")); + } + + [Test] + public async Task ShellItemAndContentOnly() + { + var shell = new Shell(); + var item1 = CreateShellItem(asImplicit: true, shellContentRoute: "rootlevelcontent", shellItemRoute: "item1"); + var item2 = CreateShellItem(asImplicit: true, shellContentRoute: "rootlevelcontent", shellItemRoute: "item2"); + + shell.Items.Add(item1); + shell.Items.Add(item2); + + + var builders = ShellUriHandler.GenerateRoutePaths(shell, CreateUri("//item1/rootlevelcontent")).Select(x => x.PathNoImplicit).ToArray(); + + Assert.AreEqual(1, builders.Length); + Assert.IsTrue(builders.Contains("//item1/rootlevelcontent")); + + builders = ShellUriHandler.GenerateRoutePaths(shell, CreateUri("//item2/rootlevelcontent")).Select(x => x.PathNoImplicit).ToArray(); + Assert.AreEqual(1, builders.Length); + Assert.IsTrue(builders.Contains("//item2/rootlevelcontent")); + } + + + [Test] + public async Task ConvertToStandardFormat() + { + var shell = new Shell() { RouteScheme = "app", Route = "shellroute", RouteHost = "host" }; + + Uri[] TestUris = new Uri[] { + CreateUri("path"), + CreateUri("//path"), + CreateUri("/path"), + CreateUri("host/path"), + CreateUri("//host/path"), + CreateUri("/host/path"), + CreateUri("shellroute/path"), + CreateUri("//shellroute/path"), + CreateUri("/shellroute/path"), + CreateUri("host/shellroute/path"), + CreateUri("//host/shellroute/path"), + CreateUri("/host/shellroute/path"), + CreateUri("app://path"), + CreateUri("app:/path"), + CreateUri("app://host/path"), + CreateUri("app:/host/path"), + CreateUri("app://shellroute/path"), + CreateUri("app:/shellroute/path"), + CreateUri("app://host/shellroute/path"), + CreateUri("app:/host/shellroute/path") + }; + + + foreach(var uri in TestUris) + { + Assert.AreEqual(new Uri("app://host/shellroute/path"), ShellUriHandler.ConvertToStandardFormat(shell, uri)); + + if(!uri.IsAbsoluteUri) + { + var reverse = new Uri(uri.OriginalString.Replace("/", "\\"), UriKind.Relative); + Assert.AreEqual(new Uri("app://host/shellroute/path"), ShellUriHandler.ConvertToStandardFormat(shell, reverse)); + } + + } + } + } +} diff --git a/Xamarin.Forms.Core.UnitTests/Xamarin.Forms.Core.UnitTests.csproj b/Xamarin.Forms.Core.UnitTests/Xamarin.Forms.Core.UnitTests.csproj index 048e5cf..f9ee573 100644 --- a/Xamarin.Forms.Core.UnitTests/Xamarin.Forms.Core.UnitTests.csproj +++ b/Xamarin.Forms.Core.UnitTests/Xamarin.Forms.Core.UnitTests.csproj @@ -74,6 +74,8 @@ + + diff --git a/Xamarin.Forms.Core/Routing.cs b/Xamarin.Forms.Core/Routing.cs index e0e84bc..6a54744 100644 --- a/Xamarin.Forms.Core/Routing.cs +++ b/Xamarin.Forms.Core/Routing.cs @@ -11,28 +11,30 @@ namespace Xamarin.Forms internal const string ImplicitPrefix = "IMPL_"; - internal static string GenerateImplicitRoute (string source) + internal static string GenerateImplicitRoute(string source) { - if (source.StartsWith(ImplicitPrefix, StringComparison.Ordinal)) + if (IsImplicit(source)) return source; - return ImplicitPrefix + source; + return String.Concat(ImplicitPrefix, source); + } + internal static bool IsImplicit(string source) + { + return source.StartsWith(ImplicitPrefix, StringComparison.Ordinal); + } + internal static bool IsImplicit(Element source) + { + return IsImplicit(GetRoute(source)); } internal static bool CompareWithRegisteredRoutes(string compare) => s_routes.ContainsKey(compare); - internal static bool CompareRoutes(string route, string compare, out bool isImplicit) + internal static void Clear() { - if (isImplicit = route.StartsWith(ImplicitPrefix, StringComparison.Ordinal)) - route = route.Substring(ImplicitPrefix.Length); - - if (compare.StartsWith(ImplicitPrefix, StringComparison.Ordinal)) - throw new Exception(); - - return route == compare; + s_routes.Clear(); } public static readonly BindableProperty RouteProperty = - BindableProperty.CreateAttached("Route", typeof(string), typeof(Routing), null, + BindableProperty.CreateAttached("Route", typeof(string), typeof(Routing), null, defaultValueCreator: CreateDefaultRoute); static object CreateDefaultRoute(BindableObject bindable) @@ -40,6 +42,13 @@ namespace Xamarin.Forms return bindable.GetType().Name + ++s_routeCount; } + public static string[] GetRouteKeys() + { + string[] keys = new string[s_routes.Count]; + s_routes.Keys.CopyTo(keys, 0); + return keys; + } + public static Element GetOrCreateContent(string route) { Element result = null; @@ -66,18 +75,47 @@ namespace Xamarin.Forms return (string)obj.GetValue(RouteProperty); } + internal static string GetRoutePathIfNotImplicit(Element obj) + { + var source = GetRoute(obj); + if (IsImplicit(source)) + return String.Empty; + + return $"{source}/"; + } + + public static string FormatRoute(List segments) + { + var route = FormatRoute(String.Join("/", segments)); + return route; + } + + public static string FormatRoute(string route) + { + return route; + } + public static void RegisterRoute(string route, RouteFactory factory) { - if (!ValidateRoute(route)) - throw new ArgumentException("Route must contain only lowercase letters"); + if (!String.IsNullOrWhiteSpace(route)) + route = FormatRoute(route); + ValidateRoute(route); s_routes[route] = factory; } + public static void UnRegisterRoute(string route) + { + if (s_routes.TryGetValue(route, out _)) + s_routes.Remove(route); + } + public static void RegisterRoute(string route, Type type) { - if (!ValidateRoute(route)) - throw new ArgumentException("Route must contain only lowercase letters"); + if(!String.IsNullOrWhiteSpace(route)) + route = FormatRoute(route); + + ValidateRoute(route); s_routes[route] = new TypeRouteFactory(type); } @@ -87,13 +125,19 @@ namespace Xamarin.Forms obj.SetValue(RouteProperty, value); } - static bool ValidateRoute(string route) + static void ValidateRoute(string route) { - // Honestly this could probably be expanded to allow any URI allowable character - // I just dont want to figure out what that validation looks like. - // It does however need to be lowercase since uri case sensitivity is a bit touchy - Regex r = new Regex(@"^[a-z|\/]*$"); - return r.IsMatch(route); + if (string.IsNullOrWhiteSpace(route)) + throw new ArgumentNullException("Route cannot be an empty string"); + + var uri = new Uri(route, UriKind.RelativeOrAbsolute); + + var parts = uri.OriginalString.Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var part in parts) + { + if (IsImplicit(part)) + throw new ArgumentException($"Route contains invalid characters in \"{part}\""); + } } class TypeRouteFactory : RouteFactory diff --git a/Xamarin.Forms.Core/Shell/BaseShellItem.cs b/Xamarin.Forms.Core/Shell/BaseShellItem.cs index 57691a4..8311d83 100644 --- a/Xamarin.Forms.Core/Shell/BaseShellItem.cs +++ b/Xamarin.Forms.Core/Shell/BaseShellItem.cs @@ -1,9 +1,11 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using Xamarin.Forms.Internals; namespace Xamarin.Forms { + [DebuggerDisplay("Title = {Title}, Route = {Route}")] public class BaseShellItem : NavigableElement, IPropertyPropagationController, IVisualController, IFlowDirectionController { #region PropertyKeys diff --git a/Xamarin.Forms.Core/Shell/IShellController.cs b/Xamarin.Forms.Core/Shell/IShellController.cs index 8179d84..9d62c05 100644 --- a/Xamarin.Forms.Core/Shell/IShellController.cs +++ b/Xamarin.Forms.Core/Shell/IShellController.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading.Tasks; namespace Xamarin.Forms { @@ -35,6 +36,8 @@ namespace Xamarin.Forms void OnFlyoutItemSelected(Element element); + Task OnFlyoutItemSelectedAsync(Element element); + bool ProposeNavigation(ShellNavigationSource source, ShellItem item, ShellSection shellSection, ShellContent shellContent, IReadOnlyList stack, bool canCancel); bool RemoveAppearanceObserver(IAppearanceObserver observer); diff --git a/Xamarin.Forms.Core/Shell/IShellItemController.cs b/Xamarin.Forms.Core/Shell/IShellItemController.cs index 6713cc3..0ca8839 100644 --- a/Xamarin.Forms.Core/Shell/IShellItemController.cs +++ b/Xamarin.Forms.Core/Shell/IShellItemController.cs @@ -6,7 +6,7 @@ namespace Xamarin.Forms { public interface IShellItemController : IElementController { - Task GoToPart(List parts, Dictionary queryData); + Task GoToPart(NavigationRequest navigationRequest, Dictionary queryData); bool ProposeSection(ShellSection shellSection, bool setValue = true); } diff --git a/Xamarin.Forms.Core/Shell/IShellSectionController.cs b/Xamarin.Forms.Core/Shell/IShellSectionController.cs index 9889e17..6e51040 100644 --- a/Xamarin.Forms.Core/Shell/IShellSectionController.cs +++ b/Xamarin.Forms.Core/Shell/IShellSectionController.cs @@ -15,7 +15,7 @@ namespace Xamarin.Forms void AddDisplayedPageObserver(object observer, Action callback); - Task GoToPart(List parts, Dictionary queryData); + Task GoToPart(NavigationRequest request, Dictionary queryData); bool RemoveContentInsetObserver(IShellContentInsetObserver observer); diff --git a/Xamarin.Forms.Core/Shell/Shell.cs b/Xamarin.Forms.Core/Shell/Shell.cs index 52c128d..f24daaf 100644 --- a/Xamarin.Forms.Core/Shell/Shell.cs +++ b/Xamarin.Forms.Core/Shell/Shell.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Collections.Specialized; using System.ComponentModel; +using System.Diagnostics; using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; @@ -80,13 +81,14 @@ namespace Xamarin.Forms { var element = (Element)bindable; - while (!Application.IsApplicationOrNull(element)) { + while (!Application.IsApplicationOrNull(element)) + { if (element is Shell shell) shell.NotifyFlyoutBehaviorObservers(); element = element.Parent; } } - + public static readonly BindableProperty ShellBackgroundColorProperty = BindableProperty.CreateAttached("ShellBackgroundColor", typeof(Color), typeof(Shell), Color.Default, propertyChanged: OnShellColorValueChanged); @@ -266,34 +268,40 @@ namespace Xamarin.Forms async void IShellController.OnFlyoutItemSelected(Element element) { + await (this as IShellController).OnFlyoutItemSelectedAsync(element); + } + + async Task IShellController.OnFlyoutItemSelectedAsync(Element element) + { ShellItem shellItem = null; ShellSection shellSection = null; ShellContent shellContent = null; - switch (element) { - case MenuShellItem menuShellItem: - ((IMenuItemController)menuShellItem.MenuItem).Activate(); - break; - case ShellItem i: - shellItem = i; - break; - case ShellSection s: - shellItem = s.Parent as ShellItem; - shellSection = s; - break; - case ShellContent c: - shellItem = c.Parent.Parent as ShellItem; - shellSection = c.Parent as ShellSection; - shellContent = c; - break; - case MenuItem m: - ((IMenuItemController)m).Activate(); - break; + switch (element) + { + case MenuShellItem menuShellItem: + ((IMenuItemController)menuShellItem.MenuItem).Activate(); + break; + case ShellItem i: + shellItem = i; + break; + case ShellSection s: + shellItem = s.Parent as ShellItem; + shellSection = s; + break; + case ShellContent c: + shellItem = c.Parent.Parent as ShellItem; + shellSection = c.Parent as ShellSection; + shellContent = c; + break; + case MenuItem m: + ((IMenuItemController)m).Activate(); + break; } if (shellItem == null || !shellItem.IsEnabled) return; - + shellSection = shellSection ?? shellItem.CurrentItem; shellContent = shellContent ?? shellSection?.CurrentItem; @@ -343,40 +351,36 @@ namespace Xamarin.Forms public static Shell Current => Application.Current?.MainPage as Shell; - Uri GetAbsoluteUri(Uri relativeUri) - { - if (CurrentItem == null) - throw new InvalidOperationException("Relative path is used after selecting Current item."); - var parseUri = Regex.Match(relativeUri.OriginalString, @"(?.+?)(\?(?.+?))?(#(?.+))?$").Groups; - var url = parseUri["u"].Value; - var query = parseUri["q"].Value; - var fragment = parseUri["f"].Value; + List BuildAllTheRoutes() + { + List routes = new List(); + // todo make better maybe - Element item = CurrentItem; - var list = new List(); - while (item != null && !(item is IApplicationController)) + for (var i = 0; i < Items.Count; i++) { - var route = Routing.GetRoute(item)?.Trim('/'); - if (string.IsNullOrEmpty(route)) - break; - list.Insert(0, route); - item = item.Parent; - } + var item = Items[i]; - var isGlobalRegisteredRoute = Routing.CompareWithRegisteredRoutes(url); - if (isGlobalRegisteredRoute) - list.RemoveRange(1, list.Count - 1); + for (var j = 0; j < item.Items.Count; j++) + { + var section = item.Items[j]; - list.Add(url.Trim('/')); + for (var k = 0; k < section.Items.Count; k++) + { + var content = section.Items[k]; - var parentUriBuilder = new UriBuilder(RouteScheme) - { - Path = string.Join("/", list), - Query = query, - Fragment = fragment - }; - return parentUriBuilder.Uri; + string longUri = $"{RouteScheme}://{RouteHost}/{Routing.GetRoute(this)}/{Routing.GetRoute(item)}/{Routing.GetRoute(section)}/{Routing.GetRoute(content)}"; + string shortUri = $"{RouteScheme}://{RouteHost}/{Routing.GetRoutePathIfNotImplicit(this)}{Routing.GetRoutePathIfNotImplicit(item)}{Routing.GetRoutePathIfNotImplicit(section)}{Routing.GetRoutePathIfNotImplicit(content)}"; + + longUri = longUri.TrimEnd('/'); + shortUri = shortUri.TrimEnd('/'); + + routes.Add(new RequestDefinition(longUri, shortUri, item, section, content, new List())); + } + } + } + + return routes; } public async Task GoToAsync(ShellNavigationState state, bool animate = true) @@ -388,9 +392,9 @@ namespace Xamarin.Forms _accumulateNavigatedEvents = true; - var uri = state.Location.IsAbsoluteUri ? state.Location : GetAbsoluteUri(state.Location); - - var queryString = uri.Query; + var navigationRequest = ShellUriHandler.GetNavigationRequest(this, state.Location); + var uri = navigationRequest.Request.FullUri; + var queryString = navigationRequest.Query; var queryData = ParseQueryString(queryString); var path = uri.AbsolutePath; @@ -409,42 +413,39 @@ namespace Xamarin.Forms else parts.RemoveAt(0); - var shellItemRoute = parts[0]; ApplyQueryAttributes(this, queryData, false); - var items = Items; - for (int i = 0; i < items.Count; i++) + var shellItem = navigationRequest.Request.Item; + if (shellItem != null) { - var shellItem = items[i]; - if (Routing.CompareRoutes(shellItem.Route, shellItemRoute, out var isImplicit)) - { - ApplyQueryAttributes(shellItem, queryData, parts.Count == 1); + ApplyQueryAttributes(shellItem, queryData, navigationRequest.Request.Section == null); - if (CurrentItem != shellItem) - SetValueFromRenderer(CurrentItemProperty, shellItem); - - if (!isImplicit) - parts.RemoveAt(0); + if (CurrentItem != shellItem) + SetValueFromRenderer(CurrentItemProperty, shellItem); - if (parts.Count > 0) - await ((IShellItemController)shellItem).GoToPart(parts, queryData); + parts.RemoveAt(0); - break; - } + if (parts.Count > 0) + await ((IShellItemController)shellItem).GoToPart(navigationRequest, queryData); } - - if (Routing.CompareWithRegisteredRoutes(shellItemRoute)) + else { - var shellItem = ShellItem.GetShellItemFromRouteName(shellItemRoute); + await CurrentItem.CurrentItem.GoToAsync(navigationRequest.Request.GlobalRoutes, queryData, animate); + } + + //if (Routing.CompareWithRegisteredRoutes(shellItemRoute)) + //{ + // var shellItem = ShellItem.GetShellItemFromRouteName(shellItemRoute); - ApplyQueryAttributes(shellItem, queryData, parts.Count == 1); + // ApplyQueryAttributes(shellItem, queryData, parts.Count == 1); - if (CurrentItem != shellItem) - SetValueFromRenderer(CurrentItemProperty, shellItem); + // if (CurrentItem != shellItem) + // SetValueFromRenderer(CurrentItemProperty, shellItem); + + // if (parts.Count > 0) + // await ((IShellItemController)shellItem).GoToPart(parts, queryData); + //} - if (parts.Count > 0) - await ((IShellItemController)shellItem).GoToPart(parts, queryData); - } _accumulateNavigatedEvents = false; // this can be null in the event that no navigation actually took place! @@ -467,7 +468,8 @@ namespace Xamarin.Forms } //if the lastItem is implicitly wrapped, get the actual ShellContent - if (isLastItem) { + if (isLastItem) + { if (element is ShellItem shellitem && shellitem.Items.FirstOrDefault() is ShellSection section) element = section; if (element is ShellSection shellsection && shellsection.Items.FirstOrDefault() is ShellContent content) @@ -481,7 +483,8 @@ namespace Xamarin.Forms //filter the query to only apply the keys with matching prefix var filteredQuery = new Dictionary(query.Count); - foreach (var q in query) { + foreach (var q in query) + { if (!q.Key.StartsWith(prefix, StringComparison.Ordinal)) continue; var key = q.Key.Substring(prefix.Length); @@ -506,7 +509,7 @@ namespace Xamarin.Forms if (shellItem != null) { var shellItemRoute = shellItem.Route; - if (!shellItemRoute.StartsWith(Routing.ImplicitPrefix, StringComparison.Ordinal)) + //if (!shellItemRoute.StartsWith(Routing.ImplicitPrefix, StringComparison.Ordinal)) { stateBuilder.Append(shellItemRoute); stateBuilder.Append("/"); @@ -515,7 +518,7 @@ namespace Xamarin.Forms if (shellSection != null) { var shellSectionRoute = shellSection.Route; - if (!shellSectionRoute.StartsWith(Routing.ImplicitPrefix, StringComparison.Ordinal)) + //if (!shellSectionRoute.StartsWith(Routing.ImplicitPrefix, StringComparison.Ordinal)) { stateBuilder.Append(shellSectionRoute); stateBuilder.Append("/"); @@ -524,7 +527,7 @@ namespace Xamarin.Forms if (shellContent != null) { var shellContentRoute = shellContent.Route; - if (!shellContentRoute.StartsWith(Routing.ImplicitPrefix, StringComparison.Ordinal)) + //if (!shellContentRoute.StartsWith(Routing.ImplicitPrefix, StringComparison.Ordinal)) { stateBuilder.Append(shellContentRoute); stateBuilder.Append("/"); @@ -598,9 +601,11 @@ namespace Xamarin.Forms internal Shell(bool checkFlag) { + Navigation = new NavigationImpl(this); _checkExperimentalFlag = checkFlag; VerifyShellFlagEnabled(constructorHint: nameof(Shell)); ((INotifyCollectionChanged)Items).CollectionChanged += (s, e) => SendStructureChanged(); + Route = Routing.GenerateImplicitRoute("shell"); } internal const string ShellExperimental = ExperimentalFlags.ShellExperimental; @@ -608,7 +613,7 @@ namespace Xamarin.Forms [EditorBrowsable(EditorBrowsableState.Never)] internal void VerifyShellFlagEnabled(string constructorHint = null, [CallerMemberName] string memberName = "") { - if(_checkExperimentalFlag) + if (_checkExperimentalFlag) ExperimentalFlags.VerifyFlagEnabled("Shell", ShellExperimental, constructorHint, memberName); } @@ -622,44 +627,52 @@ namespace Xamarin.Forms set => SetValue(FlyoutIconProperty, value); } - public ShellItem CurrentItem { + public ShellItem CurrentItem + { get => (ShellItem)GetValue(CurrentItemProperty); set => SetValue(CurrentItemProperty, value); } public ShellNavigationState CurrentState => (ShellNavigationState)GetValue(CurrentStateProperty); - public Color FlyoutBackgroundColor { + public Color FlyoutBackgroundColor + { get => (Color)GetValue(FlyoutBackgroundColorProperty); set => SetValue(FlyoutBackgroundColorProperty, value); } - public FlyoutBehavior FlyoutBehavior { + public FlyoutBehavior FlyoutBehavior + { get => (FlyoutBehavior)GetValue(FlyoutBehaviorProperty); set => SetValue(FlyoutBehaviorProperty, value); } - public object FlyoutHeader { + public object FlyoutHeader + { get => GetValue(FlyoutHeaderProperty); set => SetValue(FlyoutHeaderProperty, value); } - public FlyoutHeaderBehavior FlyoutHeaderBehavior { + public FlyoutHeaderBehavior FlyoutHeaderBehavior + { get => (FlyoutHeaderBehavior)GetValue(FlyoutHeaderBehaviorProperty); set => SetValue(FlyoutHeaderBehaviorProperty, value); } - public DataTemplate FlyoutHeaderTemplate { + public DataTemplate FlyoutHeaderTemplate + { get => (DataTemplate)GetValue(FlyoutHeaderTemplateProperty); set => SetValue(FlyoutHeaderTemplateProperty, value); } - public bool FlyoutIsPresented { + public bool FlyoutIsPresented + { get => (bool)GetValue(FlyoutIsPresentedProperty); set => SetValue(FlyoutIsPresentedProperty, value); } - public DataTemplate GroupHeaderTemplate { + public DataTemplate GroupHeaderTemplate + { get => (DataTemplate)GetValue(GroupHeaderTemplateProperty); set => SetValue(GroupHeaderTemplateProperty, value); } @@ -667,19 +680,22 @@ namespace Xamarin.Forms public ShellItemCollection Items => (ShellItemCollection)GetValue(ItemsProperty); public ShellItemCollection Flyout => Items; - public DataTemplate ItemTemplate { + public DataTemplate ItemTemplate + { get => (DataTemplate)GetValue(ItemTemplateProperty); set => SetValue(ItemTemplateProperty, value); } public MenuItemCollection MenuItems => (MenuItemCollection)GetValue(MenuItemsProperty); - public DataTemplate MenuItemTemplate { + public DataTemplate MenuItemTemplate + { get => (DataTemplate)GetValue(MenuItemTemplateProperty); set => SetValue(MenuItemTemplateProperty, value); } - public string Route { + public string Route + { get => Routing.GetRoute(this); set => Routing.SetRoute(this, value); } @@ -688,9 +704,11 @@ namespace Xamarin.Forms public string RouteScheme { get; set; } = "app"; - View FlyoutHeaderView { + View FlyoutHeaderView + { get => _flyoutHeaderView; - set { + set + { if (_flyoutHeaderView == value) return; @@ -816,7 +834,8 @@ namespace Xamarin.Forms { if (_accumulateNavigatedEvents) _accumulatedEvent = args; - else { + else + { /* Removing this check for now as it doesn't properly cover all implicit scenarios * if (args.Current.Location.AbsolutePath.TrimEnd('/') != _lastNavigating.Location.AbsolutePath.TrimEnd('/')) throw new InvalidOperationException($"Navigation: Current location doesn't match navigation uri {args.Current.Location.AbsolutePath} != {_lastNavigating.Location.AbsolutePath}"); @@ -1060,19 +1079,20 @@ namespace Xamarin.Forms Element WalkToPage(Element element) { - switch (element) { - case Shell shell: - element = shell.CurrentItem; - break; - case ShellItem shellItem: - element = shellItem.CurrentItem; - break; - case ShellSection shellSection: - var controller = (IShellSectionController)element; - // this is the same as .Last but easier and will add in the root if not null - // it generally wont be null but this is just in case - element = controller.PresentedPage ?? element; - break; + switch (element) + { + case Shell shell: + element = shell.CurrentItem; + break; + case ShellItem shellItem: + element = shellItem.CurrentItem; + break; + case ShellSection shellSection: + var controller = (IShellSectionController)element; + // this is the same as .Last but easier and will add in the root if not null + // it generally wont be null but this is just in case + element = controller.PresentedPage ?? element; + break; } return element; @@ -1084,5 +1104,26 @@ namespace Xamarin.Forms if (FlyoutHeaderView != null) PropertyPropagationExtensions.PropagatePropertyChanged(propertyName, this, new[] { FlyoutHeaderView }); } + + public class NavigationImpl : NavigationProxy + { + readonly Shell _shell; + + NavigationProxy SectionProxy => _shell.CurrentItem.CurrentItem.NavigationProxy; + + public NavigationImpl(Shell shell) => _shell = shell; + + protected override IReadOnlyList GetNavigationStack() => SectionProxy.NavigationStack; + + protected override void OnInsertPageBefore(Page page, Page before) => SectionProxy.InsertPageBefore(page, before); + + protected override Task OnPopAsync(bool animated) => SectionProxy.PopAsync(animated); + + protected override Task OnPopToRootAsync(bool animated) => SectionProxy.PopToRootAsync(animated); + + protected override Task OnPushAsync(Page page, bool animated) => SectionProxy.PushAsync(page, animated); + + protected override void OnRemovePage(Page page) => SectionProxy.RemovePage(page); + } } } diff --git a/Xamarin.Forms.Core/Shell/ShellItem.cs b/Xamarin.Forms.Core/Shell/ShellItem.cs index a8d0a7c..955b2fc 100644 --- a/Xamarin.Forms.Core/Shell/ShellItem.cs +++ b/Xamarin.Forms.Core/Shell/ShellItem.cs @@ -24,31 +24,19 @@ namespace Xamarin.Forms #region IShellItemController - Task IShellItemController.GoToPart(List parts, Dictionary queryData) + Task IShellItemController.GoToPart(NavigationRequest request, Dictionary queryData) { - var shellSectionRoute = parts[0]; + var shellSection = request.Request.Section; - var items = Items; - for (int i = 0; i < items.Count; i++) - { - var shellSection = items[i]; - if (Routing.CompareRoutes(shellSection.Route, shellSectionRoute, out var isImplicit)) - { - Shell.ApplyQueryAttributes(shellSection, queryData, parts.Count == 1); - - if (CurrentItem != shellSection) - SetValueFromRenderer(CurrentItemProperty, shellSection); - - if (!isImplicit) - parts.RemoveAt(0); - if (parts.Count > 0) - { - return ((IShellSectionController)shellSection).GoToPart(parts, queryData); - } - break; - } - } - return Task.FromResult(true); + if (shellSection == null) + return Task.FromResult(true); + + Shell.ApplyQueryAttributes(shellSection, queryData, request.Request.Content == null); + + if (CurrentItem != shellSection) + SetValueFromRenderer(CurrentItemProperty, shellSection); + + return ((IShellSectionController)shellSection).GoToPart(request, queryData); } bool IShellItemController.ProposeSection(ShellSection shellSection, bool setValue) @@ -116,10 +104,7 @@ namespace Xamarin.Forms } } -#if DEBUG - [Obsolete ("Please dont use this in core code... its SUPER hard to debug when this happens", true)] -#endif - public static implicit operator ShellItem(ShellSection shellSection) + internal static ShellItem CreateFromShellSection(ShellSection shellSection) { var result = new ShellItem(); @@ -132,6 +117,14 @@ namespace Xamarin.Forms return result; } +#if DEBUG + [Obsolete ("Please dont use this in core code... its SUPER hard to debug when this happens", true)] +#endif + public static implicit operator ShellItem(ShellSection shellSection) + { + return CreateFromShellSection(shellSection); + } + internal static ShellItem GetShellItemFromRouteName(string route) { var shellContent = new ShellContent { Route = route, Content = Routing.GetOrCreateContent(route) }; diff --git a/Xamarin.Forms.Core/Shell/ShellNavigationState.cs b/Xamarin.Forms.Core/Shell/ShellNavigationState.cs index bd9388f..93e80b2 100644 --- a/Xamarin.Forms.Core/Shell/ShellNavigationState.cs +++ b/Xamarin.Forms.Core/Shell/ShellNavigationState.cs @@ -1,7 +1,10 @@ using System; +using System.Diagnostics; namespace Xamarin.Forms { + + [DebuggerDisplay("Location = {Location}")] public class ShellNavigationState { public Uri Location { get; set; } diff --git a/Xamarin.Forms.Core/Shell/ShellSection.cs b/Xamarin.Forms.Core/Shell/ShellSection.cs index 0d05786..16ac997 100644 --- a/Xamarin.Forms.Core/Shell/ShellSection.cs +++ b/Xamarin.Forms.Core/Shell/ShellSection.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Collections.Specialized; +using System.Diagnostics; using System.Linq; using System.Threading.Tasks; using Xamarin.Forms.Internals; @@ -64,30 +65,27 @@ namespace Xamarin.Forms callback(DisplayedPage); } - Task IShellSectionController.GoToPart(List parts, Dictionary queryData) + Task IShellSectionController.GoToPart(NavigationRequest request, Dictionary queryData) { - var shellContentRoute = parts[0]; + ShellContent shellContent = request.Request.Content; - var items = Items; - for (int i = 0; i < items.Count; i++) + if (shellContent == null) + return Task.FromResult(true); + + + if(request.Request.GlobalRoutes.Count > 0) { - ShellContent shellContent = items[i]; - if (Routing.CompareRoutes(shellContent.Route, shellContentRoute, out var isImplicit)) + // TODO get rid of this hack and fix so if there's a stack the current page doesn't display + Device.BeginInvokeOnMainThread(async () => { - Shell.ApplyQueryAttributes(shellContent, queryData, parts.Count == 1); + await GoToAsync(request.Request.GlobalRoutes, queryData, false); + }); + } - if (CurrentItem != shellContent) - SetValueFromRenderer(CurrentItemProperty, shellContent); + Shell.ApplyQueryAttributes(shellContent, queryData, request.Request.GlobalRoutes.Count == 0); - if (!isImplicit) - parts.RemoveAt(0); - if (parts.Count > 0) - { - return GoToAsync(parts, queryData, false); - } - break; - } - } + if (CurrentItem != shellContent) + SetValueFromRenderer(CurrentItemProperty, shellContent); return Task.FromResult(true); } @@ -190,10 +188,7 @@ namespace Xamarin.Forms ShellItem ShellItem => Parent as ShellItem; -#if DEBUG - [Obsolete("Please dont use this in core code... its SUPER hard to debug when this happens", true)] -#endif - public static implicit operator ShellSection(ShellContent shellContent) + internal static ShellSection CreateFromShellContent(ShellContent shellContent) { var shellSection = new ShellSection(); @@ -207,6 +202,19 @@ namespace Xamarin.Forms return shellSection; } + internal static ShellSection CreateFromTemplatedPage(TemplatedPage page) + { + return CreateFromShellContent((ShellContent)page); + } + +#if DEBUG + [Obsolete("Please dont use this in core code... its SUPER hard to debug when this happens", true)] +#endif + public static implicit operator ShellSection(ShellContent shellContent) + { + return CreateFromShellContent(shellContent); + } + #if DEBUG [Obsolete("Please dont use this in core code... its SUPER hard to debug when this happens", true)] #endif diff --git a/Xamarin.Forms.Core/Shell/ShellUriHandler.cs b/Xamarin.Forms.Core/Shell/ShellUriHandler.cs new file mode 100644 index 0000000..d9be121 --- /dev/null +++ b/Xamarin.Forms.Core/Shell/ShellUriHandler.cs @@ -0,0 +1,757 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; + +namespace Xamarin.Forms +{ + + internal class ShellUriHandler + { + static readonly char[] _pathSeparator = { '/', '\\' }; + + static Uri FormatUri(Uri path) + { + if (path.IsAbsoluteUri) + return path; + + return new Uri(FormatUri(path.OriginalString), UriKind.Relative); + } + + static string FormatUri(string path) + { + return path.Replace("\\", "/"); + } + + public static Uri ConvertToStandardFormat(Shell shell, Uri request) + { + request = FormatUri(request); + string pathAndQuery = null; + if (request.IsAbsoluteUri) + pathAndQuery = $"{request.Host}/{request.PathAndQuery}"; + else + pathAndQuery = request.OriginalString; + + var segments = new List(pathAndQuery.Split(_pathSeparator, StringSplitOptions.RemoveEmptyEntries)); + + + if (segments[0] != shell.RouteHost) + segments.Insert(0, shell.RouteHost); + + if (segments[1] != shell.Route) + segments.Insert(1, shell.Route); + + var path = String.Join("/", segments.ToArray()); + string uri = $"{shell.RouteScheme}://{path}"; + + return new Uri(uri); + } + + public static NavigationRequest GetNavigationRequest(Shell shell, Uri uri) + { + uri = FormatUri(uri); + // figure out the intent of the Uri + NavigationRequest.WhatToDoWithTheStack whatDoIDo = NavigationRequest.WhatToDoWithTheStack.PushToIt; + if (uri.IsAbsoluteUri) + whatDoIDo = NavigationRequest.WhatToDoWithTheStack.ReplaceIt; + else if (uri.OriginalString.StartsWith("//") || uri.OriginalString.StartsWith("\\\\")) + whatDoIDo = NavigationRequest.WhatToDoWithTheStack.ReplaceIt; + else + whatDoIDo = NavigationRequest.WhatToDoWithTheStack.PushToIt; + + Uri request = ConvertToStandardFormat(shell, uri); + + var possibleRouteMatches = GenerateRoutePaths(shell, request, uri); + + + if (possibleRouteMatches.Count == 0) + throw new ArgumentException($"unable to figure out route for: {uri}", nameof(uri)); + else if (possibleRouteMatches.Count > 1) + { + string[] matches = new string[possibleRouteMatches.Count]; + int i = 0; + foreach (var match in possibleRouteMatches) + { + matches[i] = match.PathFull; + i++; + } + + string matchesFound = String.Join(",", matches); + throw new ArgumentException($"Ambiguous routes matched for: {uri} matches found: {matchesFound}", nameof(uri)); + + } + + var theWinningRoute = possibleRouteMatches[0]; + RequestDefinition definition = + new RequestDefinition( + ConvertToStandardFormat(shell, new Uri(theWinningRoute.PathFull, UriKind.RelativeOrAbsolute)), + new Uri(theWinningRoute.PathNoImplicit, UriKind.RelativeOrAbsolute), + theWinningRoute.Item, + theWinningRoute.Section, + theWinningRoute.Content, + theWinningRoute.GlobalRouteMatches); + + NavigationRequest navigationRequest = new NavigationRequest(definition, whatDoIDo, request.Query, request.Fragment); + + return navigationRequest; + } + + internal static List GenerateRoutePaths(Shell shell, Uri request) + { + request = FormatUri(request); + return GenerateRoutePaths(shell, request, request); + } + + internal static List GenerateRoutePaths(Shell shell, Uri request, Uri originalRequest) + { + request = FormatUri(request); + originalRequest = FormatUri(originalRequest); + + var routeKeys = Routing.GetRouteKeys(); + for (int i = 0; i < routeKeys.Length; i++) + { + routeKeys[i] = FormatUri(routeKeys[i]); + } + + List possibleRoutePaths = new List(); + if (!request.IsAbsoluteUri) + request = ConvertToStandardFormat(shell, request); + + string localPath = request.LocalPath; + + bool relativeMatch = false; + if (!originalRequest.IsAbsoluteUri && !originalRequest.OriginalString.StartsWith("/") && !originalRequest.OriginalString.StartsWith("\\")) + relativeMatch = true; + + var segments = localPath.Split(_pathSeparator, StringSplitOptions.RemoveEmptyEntries); + + if (!relativeMatch) + { + for (int i = 0; i < routeKeys.Length; i++) + { + var route = routeKeys[i]; + var uri = ConvertToStandardFormat(shell, new Uri(route, UriKind.RelativeOrAbsolute)); + // Todo is this supported? + if (uri.Equals(request)) + { + var builder = new RouteRequestBuilder(route, route, null, segments); + return new List { builder }; + } + } + } + + var depthStart = 0; + + if (segments[0] == shell.Route) + { + segments = segments.Skip(1).ToArray(); + depthStart = 1; + } + else + { + depthStart = 0; + } + + if(relativeMatch && shell?.CurrentItem != null) + { + // retrieve current location + var currentLocation = NodeLocation.Create(shell); + + while (currentLocation.Shell != null) + { + List pureRoutesMatch = new List(); + List pureGlobalRoutesMatch = new List(); + + SearchPath(currentLocation.LowestChild, null, segments, pureRoutesMatch, 0); + SearchPath(currentLocation.LowestChild, null, segments, pureGlobalRoutesMatch, 0, ignoreGlobalRoutes: false); + pureRoutesMatch = GetBestMatches(pureRoutesMatch); + pureGlobalRoutesMatch = GetBestMatches(pureGlobalRoutesMatch); + + if (pureRoutesMatch.Count > 0) + return pureRoutesMatch; + + if (pureGlobalRoutesMatch.Count > 0) + return pureGlobalRoutesMatch; + + currentLocation.Pop(); + } + + string searchPath = String.Join("/", segments); + + if (routeKeys.Contains(searchPath)) + { + return new List { new RouteRequestBuilder(searchPath, searchPath, null, segments) }; + } + + RouteRequestBuilder builder = null; + foreach (var segment in segments) + { + if(routeKeys.Contains(segment)) + { + if (builder == null) + builder = new RouteRequestBuilder(segment, segment, null, segments); + else + builder.AddGlobalRoute(segment, segment); + } + } + + if(builder != null && builder.IsFullMatch) + return new List { builder }; + } + else + { + possibleRoutePaths.Clear(); + SearchPath(shell, null, segments, possibleRoutePaths, depthStart); + + var bestMatches = GetBestMatches(possibleRoutePaths); + if (bestMatches.Count > 0) + return bestMatches; + + bestMatches.Clear(); + foreach (var possibleRoutePath in possibleRoutePaths) + { + while (routeKeys.Contains(possibleRoutePath.NextSegment) || routeKeys.Contains(possibleRoutePath.RemainingPath)) + { + if(routeKeys.Contains(possibleRoutePath.NextSegment)) + possibleRoutePath.AddGlobalRoute(possibleRoutePath.NextSegment, possibleRoutePath.NextSegment); + else + possibleRoutePath.AddGlobalRoute(possibleRoutePath.RemainingPath, possibleRoutePath.RemainingPath); + } + + while (!possibleRoutePath.IsFullMatch) + { + NodeLocation nodeLocation = new NodeLocation(); + nodeLocation.SetNode(possibleRoutePath.LowestChild); + List pureGlobalRoutesMatch = new List(); + while (nodeLocation.Shell != null && pureGlobalRoutesMatch.Count == 0) + { + SearchPath(nodeLocation.LowestChild, null, possibleRoutePath.RemainingSegments, pureGlobalRoutesMatch, 0, ignoreGlobalRoutes: false); + nodeLocation.Pop(); + } + + // nothing found or too many things found + if (pureGlobalRoutesMatch.Count != 1) + { + break; + } + + + for (var i = 0; i < pureGlobalRoutesMatch[0].GlobalRouteMatches.Count; i++) + { + var match = pureGlobalRoutesMatch[0]; + possibleRoutePath.AddGlobalRoute(match.GlobalRouteMatches[i], match.SegmentsMatched[i]); + } + } + } + } + + possibleRoutePaths = GetBestMatches(possibleRoutePaths); + return possibleRoutePaths; + } + + internal static List GetBestMatches(List possibleRoutePaths) + { + List bestMatches = new List(); + foreach (var match in possibleRoutePaths) + { + if (match.IsFullMatch) + bestMatches.Add(match); + } + + return bestMatches; + } + + internal class NodeLocation + { + 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 static NodeLocation Create(Shell shell) + { + NodeLocation location = new NodeLocation(); + location.SetNode( + (object)shell.CurrentItem?.CurrentItem?.CurrentItem ?? + (object)shell.CurrentItem?.CurrentItem ?? + (object)shell.CurrentItem ?? + (object)shell); + + return location; + } + + public void SetNode(object node) + { + switch (node) + { + case Shell shell: + Shell = shell; + Item = null; + Section = null; + Content = null; + break; + case ShellItem item: + Item = item; + Section = null; + Content = null; + if (Shell == null) + Shell = (Shell)Item.Parent; + break; + case ShellSection section: + Section = section; + + if (Item == null) + Item = Section.Parent as ShellItem; + + if (Shell == null) + Shell = (Shell)Item.Parent; + + Content = null; + + break; + case ShellContent content: + Content = content; + if (Section == null) + Section = Content.Parent as ShellSection; + + if (Item == null) + Item = Section.Parent as ShellItem; + + if (Shell == null) + Shell = (Shell)Item.Parent; + + break; + + } + } + + public Uri GetUri() + { + List paths = new List(); + paths.Add(Shell.RouteHost); + paths.Add(Shell.Route); + if (Item != null && !Routing.IsImplicit(Item)) + paths.Add(Item.Route); + if (Section != null && !Routing.IsImplicit(Section)) + paths.Add(Section.Route); + if (Content != null && !Routing.IsImplicit(Content)) + paths.Add(Content.Route); + + string uri = String.Join("/", paths); + return new Uri($"{Shell.RouteScheme}://{uri}"); + } + + public void Pop() + { + if (Content != null) + Content = null; + else if (Section != null) + Section = null; + else if (Item != null) + Item = null; + else if (Shell != null) + Shell = null; + } + } + + static void SearchPath( + object node, + RouteRequestBuilder currentMatchedPath, + string[] segments, + List possibleRoutePaths, + int depthToStart, + int myDepth = -1, + NodeLocation currentLocation = null, + bool ignoreGlobalRoutes = true) + { + if (node is GlobalRouteItem && ignoreGlobalRoutes) + return; + + ++myDepth; + currentLocation = currentLocation ?? new NodeLocation(); + currentLocation.SetNode(node); + + IEnumerable items = null; + if (depthToStart > myDepth) + { + items = GetItems(node); + if (items == null) + return; + + foreach (var nextNode in items) + { + SearchPath(nextNode, null, segments, possibleRoutePaths, depthToStart, myDepth, currentLocation, ignoreGlobalRoutes); + } + return; + } + + string shellSegment = GetRoute(node); + string userSegment = null; + + if (currentMatchedPath == null) + { + userSegment = segments[0]; + } + else + { + userSegment = currentMatchedPath.NextSegment; + } + + if (userSegment == null) + return; + + RouteRequestBuilder builder = null; + if (shellSegment == userSegment || Routing.IsImplicit(shellSegment)) + { + if (currentMatchedPath == null) + builder = new RouteRequestBuilder(shellSegment, userSegment, node, segments); + else + { + builder = new RouteRequestBuilder(currentMatchedPath); + builder.AddMatch(shellSegment, userSegment, node); + } + + if (!Routing.IsImplicit(shellSegment) || shellSegment == userSegment) + possibleRoutePaths.Add(builder); + } + + items = GetItems(node); + if (items == null) + return; + + foreach (var nextNode in items) + { + SearchPath(nextNode, builder, segments, possibleRoutePaths, depthToStart, myDepth, currentLocation, ignoreGlobalRoutes); + } + } + + static string GetRoute(object node) + { + switch (node) + { + case Shell shell: + return shell.Route; + case ShellItem item: + return item.Route; + case ShellSection section: + return section.Route; + case ShellContent content: + return content.Route; + case GlobalRouteItem routeItem: + return routeItem.Route; + + } + + throw new ArgumentException($"{node}", nameof(node)); + } + + static IEnumerable GetItems(object node) + { + IEnumerable results = null; + switch (node) + { + case Shell shell: + results = shell.Items; + break; + case ShellItem item: + results = item.Items; + break; + case ShellSection section: + results = section.Items; + break; + case ShellContent content: + results = new object[0]; + break; + case GlobalRouteItem routeITem: + results = routeITem.Items; + break; + } + + if (results == null) + throw new ArgumentException($"{node}", nameof(node)); + + foreach (var result in results) + yield return result; + + if (node is GlobalRouteItem) + yield break; + + var keys = Routing.GetRouteKeys(); + string route = GetRoute(node); + for (var i = 0; i < keys.Length; i++) + { + var key = FormatUri(keys[i]); + if (key.StartsWith("/") && !(node is Shell)) + continue; + + var segments = key.Split(_pathSeparator, StringSplitOptions.RemoveEmptyEntries); + + if (segments[0] == route) + { + yield return new GlobalRouteItem(key, key); + } + } + } + + + internal class GlobalRouteItem + { + readonly string _path; + public GlobalRouteItem(string path, string sourceRoute) + { + _path = path; + SourceRoute = sourceRoute; + } + + public IEnumerable Items + { + get + { + var segments = _path.Split(_pathSeparator, StringSplitOptions.RemoveEmptyEntries).ToList().Skip(1).ToList(); + + if (segments.Count == 0) + return new object[0]; + + var route = Routing.FormatRoute(segments); + + return new[] { new GlobalRouteItem(route, SourceRoute) }; + } + } + + public string Route + { + get + { + var segments = _path.Split(_pathSeparator, StringSplitOptions.RemoveEmptyEntries); + + if (segments.Length == 0) + return string.Empty; + + return segments[0]; + } + } + + public bool IsFinished + { + get + { + var segments = _path.Split(_pathSeparator, StringSplitOptions.RemoveEmptyEntries).ToList().Skip(1).ToList(); + + if (segments.Count == 0) + return true; + + return false; + } + } + + 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("/", _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("/") || segments[0].StartsWith("\\")) + 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}")] + public 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}")] + public class RequestDefinition + { + public RequestDefinition(Uri fullUri, Uri shortUri, ShellItem item, ShellSection section, ShellContent content, List globalRoutes) + { + FullUri = fullUri; + ShortUri = shortUri; + Item = item; + Section = section; + Content = content; + GlobalRoutes = globalRoutes; + } + + public RequestDefinition(string fullUri, string shortUri, ShellItem item, ShellSection section, ShellContent content, List globalRoutes) : + this(new Uri(fullUri, UriKind.Absolute), new Uri(shortUri, UriKind.Absolute), item, section, content, globalRoutes) + { + } + + public Uri FullUri { get; } + public Uri ShortUri { get; } + public ShellItem Item { get; } + public ShellSection Section { get; } + public ShellContent Content { get; } + public List GlobalRoutes { get; } + } + + +} diff --git a/Xamarin.Forms.Sandbox.Android/Properties/AndroidManifest.xml b/Xamarin.Forms.Sandbox.Android/Properties/AndroidManifest.xml index 78cf49b..9bd3d8b 100644 --- a/Xamarin.Forms.Sandbox.Android/Properties/AndroidManifest.xml +++ b/Xamarin.Forms.Sandbox.Android/Properties/AndroidManifest.xml @@ -1,5 +1,7 @@  - + + + \ No newline at end of file diff --git a/Xamarin.Forms.Sandbox.Android/Xamarin.Forms.Sandbox.Android.csproj b/Xamarin.Forms.Sandbox.Android/Xamarin.Forms.Sandbox.Android.csproj index 545e4e8..bcae908 100644 --- a/Xamarin.Forms.Sandbox.Android/Xamarin.Forms.Sandbox.Android.csproj +++ b/Xamarin.Forms.Sandbox.Android/Xamarin.Forms.Sandbox.Android.csproj @@ -30,7 +30,6 @@ 4 None d8 - true true @@ -42,8 +41,9 @@ 4 true false - true Full + d8 + r8 diff --git a/Xamarin.Forms.Sandbox/MainPage.xaml b/Xamarin.Forms.Sandbox/MainPage.xaml index c105aa2..c610023 100644 --- a/Xamarin.Forms.Sandbox/MainPage.xaml +++ b/Xamarin.Forms.Sandbox/MainPage.xaml @@ -2,7 +2,7 @@ -- 2.7.4