Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix for MAUI RoutedViewHost #3303

Merged
merged 10 commits into from
Jul 8, 2022
266 changes: 159 additions & 107 deletions src/ReactiveUI.Maui/RoutedViewHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Reactive;
using System.Reactive.Concurrency;
using System.Reactive.Disposables;
using System.Reactive.Linq;
using System.Reactive.Threading.Tasks;
using System.Reflection;
using Microsoft.Maui.Controls;
using Splat;
Expand All @@ -32,6 +32,15 @@ public class RoutedViewHost : NavigationPage, IActivatableView, IEnableLogger
typeof(RoutedViewHost),
default(RoutingState));

/// <summary>
/// The Set Title on Navigate property.
/// </summary>
public static readonly BindableProperty SetTitleOnNavigateProperty = BindableProperty.Create(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added Set Title On Navigate Property

nameof(SetTitleOnNavigate),
typeof(bool),
typeof(RoutedViewHost),
false);

/// <summary>
/// Initializes a new instance of the <see cref="RoutedViewHost"/> class.
/// </summary>
Expand All @@ -40,80 +49,31 @@ public RoutedViewHost()
{
this.WhenActivated(disposable =>
{
var currentlyPopping = false;
var popToRootPending = false;
var userInstigated = false;

this.WhenAnyObservable(x => x.Router.NavigationChanged)
.Where(_ => Router.NavigationStack.Count == 0)
.Select(x =>
{
// Xamarin Forms does not let us completely clear down the navigation stack
// instead, we have to delay this request momentarily until we receive the new root view
// then, we can insert the new root view first, and then pop to it
popToRootPending = true;
return x;
})
.Subscribe()
.DisposeWith(disposable);
var currentlyNavigating = false;

Router?
.NavigationChanged?
.CountChanged()
.Select(_ => Router.NavigationStack.Count)
.StartWith(Router.NavigationStack.Count)
.Buffer(2, 1)
.Select(counts => new
.NavigateBack
.Subscribe(async _ =>
{
Delta = counts[0] - counts[1],
Current = counts[1],
try
{
currentlyNavigating = true;
await PopAsync();
}
finally
{
currentlyNavigating = false;
}

// cache current viewmodel as it might change if some other Navigation command is executed midway
CurrentViewModel = Router.GetCurrentViewModel()
InvalidateCurrentViewModel();
SyncNavigationStacks();
})
.Where(_ => !userInstigated)
.Where(x => x.Delta > 0)
.Select(
async x =>
{
// XF doesn't provide a means of navigating back more than one screen at a time apart from navigating right back to the root page
// since we want as sensible an animation as possible, we pop to root if that makes sense. Otherwise, we pop each individual
// screen until the delta is made up, animating only the last one
var popToRoot = x.Current == 1;
currentlyPopping = true;

try
{
if (popToRoot)
{
await PopToRootAsync(true);
}
else if (!popToRootPending)
{
for (var i = 0; i < x.Delta; ++i)
{
await PopAsync(i == x.Delta - 1);
}
}
}
finally
{
currentlyPopping = false;
if (CurrentPage is IViewFor page && x.CurrentViewModel is not null)
{
page.ViewModel = x.CurrentViewModel;
}
}

return Unit.Default;
})
.Concat()
.Subscribe()
.DisposeWith(disposable);

Router?
.Navigate
.SelectMany(_ => PageForViewModel(Router.GetCurrentViewModel()))
.ObserveOn(RxApp.MainThreadScheduler)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added ObserveOn(RxApp.MainThreadScheduler)

.SelectMany(_ => PagesForViewModel(Router.GetCurrentViewModel()))
.SelectMany(async page =>
{
var animated = true;
Expand All @@ -123,17 +83,18 @@ public RoutedViewHost()
animated = false;
}

if (popToRootPending && Navigation.NavigationStack.Count > 0)
try
{
Navigation.InsertPageBefore(page, Navigation.NavigationStack[0]);
await PopToRootAsync(animated);
currentlyNavigating = true;
await PushAsync(page, animated);
}
else
finally
{
await PushAsync(page, animated);
currentlyNavigating = false;
}

popToRootPending = false;
SyncNavigationStacks();

return page;
})
.Subscribe()
Expand All @@ -151,26 +112,37 @@ public RoutedViewHost()
// NB: Catch when the user hit back as opposed to the application
// requesting Back via NavigateBack
poppingEvent
.Where(_ => !currentlyPopping && Router is not null)
.Where(_ => !currentlyNavigating && Router is not null)
.Subscribe(_ =>
{
userInstigated = true;

try
{
Router?.NavigationStack.RemoveAt(Router.NavigationStack.Count - 1);
}
finally
{
userInstigated = false;
}
Router!.NavigationStack.RemoveAt(Router.NavigationStack.Count - 1);

InvalidateCurrentViewModel();
})
.DisposeWith(disposable);

var poppingToRootEvent = Observable.FromEvent<EventHandler<NavigationEventArgs>, Unit>(
eventHandler =>
{
void Handler(object? sender, NavigationEventArgs e) => eventHandler(Unit.Default);
return Handler;
},
x => PoppedToRoot += x,
x => PoppedToRoot -= x);

var vm = Router?.GetCurrentViewModel();
if (CurrentPage is IViewFor page && vm is not null)
// NB: Catch when the user hit back as opposed to the application
// requesting Back via NavigateBack
poppingToRootEvent
.Where(_ => !currentlyNavigating && Router is not null)
.Subscribe(_ =>
{
for (var i = Router!.NavigationStack.Count - 1; i > 0; i--)
{
// don't replace view model if vm is null
page.ViewModel = vm;
Router.NavigationStack.RemoveAt(i);
}

InvalidateCurrentViewModel();
})
.DisposeWith(disposable);
});
Expand All @@ -182,24 +154,6 @@ public RoutedViewHost()
}

Router = screen.Router;

this.WhenAnyValue(x => x.Router)
.SelectMany(router => router!.NavigationStack
.ToObservable()
.Select(x => (Page)ViewLocator.Current.ResolveView(x)!)
.SelectMany(x => PushAsync(x).ToObservable())
.Finally(() =>
{
var vm = router.GetCurrentViewModel();
if (vm is null)
{
return;
}

((IViewFor)CurrentPage).ViewModel = vm;
CurrentPage.Title = vm.UrlPathSegment;
}))
.Subscribe();
}

/// <summary>
Expand All @@ -211,13 +165,22 @@ public RoutingState Router
set => SetValue(RouterProperty, value);
}

/// <summary>
/// Gets or sets a value indicating whether gets or sets the Set Title of the view model stack.
/// </summary>
public bool SetTitleOnNavigate
{
get => (bool)GetValue(SetTitleOnNavigateProperty);
set => SetValue(SetTitleOnNavigateProperty, value);
}

/// <summary>
/// Pages for view model.
/// </summary>
/// <param name="vm">The vm.</param>
/// <returns>An observable of the page associated to a <see cref="IRoutableViewModel"/>.</returns>
[SuppressMessage("Design", "CA1822: Can be made static", Justification = "Might be used by implementors.")]
protected IObservable<Page> PageForViewModel(IRoutableViewModel? vm)
protected virtual IObservable<Page> PagesForViewModel(IRoutableViewModel? vm)
{
if (vm is null)
{
Expand All @@ -235,8 +198,97 @@ protected IObservable<Page> PageForViewModel(IRoutableViewModel? vm)
ret.ViewModel = vm;

var pg = (Page)ret;
pg.Title = vm.UrlPathSegment;
if (SetTitleOnNavigate)
{
pg.Title = vm.UrlPathSegment;
}

return Observable.Return(pg);
}
}

/// <summary>
/// Page for view model.
/// </summary>
/// <param name="vm">The vm.</param>
/// <returns>An observable of the page associated to a <see cref="IRoutableViewModel"/>.</returns>
[SuppressMessage("Design", "CA1822: Can be made static", Justification = "Might be used by implementors.")]
protected virtual Page PageForViewModel(IRoutableViewModel vm)
{
if (vm is null)
{
throw new ArgumentNullException(nameof(vm));
}

var ret = ViewLocator.Current.ResolveView(vm);
if (ret is null)
{
var msg = $"Couldn't find a View for ViewModel. You probably need to register an IViewFor<{vm.GetType().Name}>";

throw new Exception(msg);
}

ret.ViewModel = vm;

var pg = (Page)ret;

if (SetTitleOnNavigate)
{
RxApp.MainThreadScheduler.Schedule(() => pg.Title = vm.UrlPathSegment);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Used RxApp.MainThreadScheduler.Schedule to handle cross threading

}

return pg;
}

/// <summary>
/// Invalidates current page view model.
/// </summary>
protected void InvalidateCurrentViewModel()
{
var vm = Router?.GetCurrentViewModel();
if (CurrentPage is IViewFor page && vm is not null)
{
// don't replace view model if vm is null
page.ViewModel = vm;
}
}

/// <summary>
/// Syncs page's navigation stack with <see cref="Router"/>
/// to affect <see cref="Router"/> manipulations like Add or Clear.
/// </summary>
protected void SyncNavigationStacks()
{
if (Navigation.NavigationStack.Count != Router.NavigationStack.Count
|| StacksAreDifferent())
{
for (var i = Navigation.NavigationStack.Count - 2; i >= 0; i--)
{
Navigation.RemovePage(Navigation.NavigationStack[i]);
}

var rootPage = Navigation.NavigationStack[0];

for (var i = 0; i < Router.NavigationStack.Count - 1; i++)
{
var page = PageForViewModel(Router.NavigationStack[i]);
Navigation.InsertPageBefore(page, rootPage);
}
}
}

private bool StacksAreDifferent()
{
for (var i = 0; i < Router.NavigationStack.Count; i++)
{
var vm = Router.NavigationStack[i];
var page = Navigation.NavigationStack[i];

if (page is not IViewFor view || !ReferenceEquals(view.ViewModel, vm))
{
return true;
}
}

return false;
}
}