diff --git a/src/Avalonia.ReactiveUI/RoutedViewHost.cs b/src/Avalonia.ReactiveUI/RoutedViewHost.cs index 63456bc13a..a475cf5eac 100644 --- a/src/Avalonia.ReactiveUI/RoutedViewHost.cs +++ b/src/Avalonia.ReactiveUI/RoutedViewHost.cs @@ -57,7 +57,13 @@ namespace Avalonia.ReactiveUI /// public static readonly StyledProperty RouterProperty = AvaloniaProperty.Register(nameof(Router)); - + + /// + /// for the property. + /// + public static readonly StyledProperty ViewContractProperty = + AvaloniaProperty.Register(nameof(ViewContract)); + /// /// Initializes a new instance of the class. /// @@ -70,15 +76,18 @@ namespace Avalonia.ReactiveUI .Where(router => router == null)! .Cast(); + var viewContract = this.WhenAnyValue(x => x.ViewContract); + this.WhenAnyValue(x => x.Router) .Where(router => router != null) .SelectMany(router => router!.CurrentViewModel) .Merge(routerRemoved) - .Subscribe(NavigateToViewModel) + .CombineLatest(viewContract) + .Subscribe(tuple => NavigateToViewModel(tuple.First, tuple.Second)) .DisposeWith(disposables); }); } - + /// /// Gets or sets the of the view model stack. /// @@ -87,17 +96,27 @@ namespace Avalonia.ReactiveUI get => GetValue(RouterProperty); set => SetValue(RouterProperty, value); } - + + /// + /// Gets or sets the view contract. + /// + public string? ViewContract + { + get => GetValue(ViewContractProperty); + set => SetValue(ViewContractProperty, value); + } + /// /// Gets or sets the ReactiveUI view locator used by this router. /// public IViewLocator? ViewLocator { get; set; } - + /// /// Invoked when ReactiveUI router navigates to a view model. /// /// ViewModel to which the user navigates. - private void NavigateToViewModel(object? viewModel) + /// The contract for view resolution. + private void NavigateToViewModel(object? viewModel, string? contract) { if (Router == null) { @@ -112,17 +131,33 @@ namespace Avalonia.ReactiveUI Content = DefaultContent; return; } - + var viewLocator = ViewLocator ?? global::ReactiveUI.ViewLocator.Current; - var viewInstance = viewLocator.ResolveView(viewModel); + var viewInstance = viewLocator.ResolveView(viewModel, contract); if (viewInstance == null) { - this.Log().Warn($"Couldn't find view for '{viewModel}'. Is it registered? Falling back to default content."); + if (contract == null) + { + this.Log().Warn($"Couldn't find view for '{viewModel}'. Is it registered? Falling back to default content."); + } + else + { + this.Log().Warn($"Couldn't find view with contract '{contract}' for '{viewModel}'. Is it registered? Falling back to default content."); + } + Content = DefaultContent; return; } - - this.Log().Info($"Ready to show {viewInstance} with autowired {viewModel}."); + + if (contract == null) + { + this.Log().Info($"Ready to show {viewInstance} with autowired {viewModel}."); + } + else + { + this.Log().Info($"Ready to show {viewInstance} with autowired {viewModel} and contract '{contract}'."); + } + viewInstance.ViewModel = viewModel; if (viewInstance is IDataContextProvider provider) provider.DataContext = viewModel; diff --git a/src/Avalonia.ReactiveUI/ViewModelViewHost.cs b/src/Avalonia.ReactiveUI/ViewModelViewHost.cs index c88323d674..16dee00ebc 100644 --- a/src/Avalonia.ReactiveUI/ViewModelViewHost.cs +++ b/src/Avalonia.ReactiveUI/ViewModelViewHost.cs @@ -3,7 +3,7 @@ using System.Reactive.Disposables; using ReactiveUI; using Splat; -namespace Avalonia.ReactiveUI +namespace Avalonia.ReactiveUI { /// /// This content control will automatically load the View associated with @@ -18,6 +18,12 @@ namespace Avalonia.ReactiveUI public static readonly AvaloniaProperty ViewModelProperty = AvaloniaProperty.Register(nameof(ViewModel)); + /// + /// for the property. + /// + public static readonly StyledProperty ViewContractProperty = + AvaloniaProperty.Register(nameof(ViewContract)); + /// /// Initializes a new instance of the class. /// @@ -25,8 +31,8 @@ namespace Avalonia.ReactiveUI { this.WhenActivated(disposables => { - this.WhenAnyValue(x => x.ViewModel) - .Subscribe(NavigateToViewModel) + this.WhenAnyValue(x => x.ViewModel, x => x.ViewContract) + .Subscribe(tuple => NavigateToViewModel(tuple.Item1, tuple.Item2)) .DisposeWith(disposables); }); } @@ -39,7 +45,16 @@ namespace Avalonia.ReactiveUI get => GetValue(ViewModelProperty); set => SetValue(ViewModelProperty, value); } - + + /// + /// Gets or sets the view contract. + /// + public string? ViewContract + { + get => GetValue(ViewContractProperty); + set => SetValue(ViewContractProperty, value); + } + /// /// Gets or sets the view locator. /// @@ -49,7 +64,8 @@ namespace Avalonia.ReactiveUI /// Invoked when ReactiveUI router navigates to a view model. /// /// ViewModel to which the user navigates. - private void NavigateToViewModel(object? viewModel) + /// The contract for view resolution. + private void NavigateToViewModel(object? viewModel, string? contract) { if (viewModel == null) { @@ -57,17 +73,33 @@ namespace Avalonia.ReactiveUI Content = DefaultContent; return; } - + var viewLocator = ViewLocator ?? global::ReactiveUI.ViewLocator.Current; - var viewInstance = viewLocator.ResolveView(viewModel); + var viewInstance = viewLocator.ResolveView(viewModel, contract); if (viewInstance == null) { - this.Log().Warn($"Couldn't find view for '{viewModel}'. Is it registered? Falling back to default content."); + if (contract == null) + { + this.Log().Warn($"Couldn't find view for '{viewModel}'. Is it registered? Falling back to default content."); + } + else + { + this.Log().Warn($"Couldn't find view with contract '{contract}' for '{viewModel}'. Is it registered? Falling back to default content."); + } + Content = DefaultContent; return; } - - this.Log().Info($"Ready to show {viewInstance} with autowired {viewModel}."); + + if (contract == null) + { + this.Log().Info($"Ready to show {viewInstance} with autowired {viewModel}."); + } + else + { + this.Log().Info($"Ready to show {viewInstance} with autowired {viewModel} and contract '{contract}'."); + } + viewInstance.ViewModel = viewModel; if (viewInstance is IStyledElement styled) styled.DataContext = viewModel; diff --git a/tests/Avalonia.ReactiveUI.UnitTests/RoutedViewHostTest.cs b/tests/Avalonia.ReactiveUI.UnitTests/RoutedViewHostTest.cs index b82b1b1acc..244b5abc4e 100644 --- a/tests/Avalonia.ReactiveUI.UnitTests/RoutedViewHostTest.cs +++ b/tests/Avalonia.ReactiveUI.UnitTests/RoutedViewHostTest.cs @@ -29,6 +29,8 @@ namespace Avalonia.ReactiveUI.UnitTests public class FirstRoutableView : ReactiveUserControl { } + public class AlternativeFirstRoutableView : ReactiveUserControl { } + public class SecondRoutableViewModel : ReactiveObject, IRoutableViewModel { public string UrlPathSegment => "second"; @@ -38,16 +40,22 @@ namespace Avalonia.ReactiveUI.UnitTests public class SecondRoutableView : ReactiveUserControl { } + public class AlternativeSecondRoutableView : ReactiveUserControl { } + public class ScreenViewModel : ReactiveObject, IScreen { public RoutingState Router { get; } = new RoutingState(); } + public static string AlternativeViewContract => "AlternativeView"; + public RoutedViewHostTest() { Locator.CurrentMutable.RegisterConstant(new AvaloniaActivationForViewFetcher(), typeof(IActivationForViewFetcher)); Locator.CurrentMutable.Register(() => new FirstRoutableView(), typeof(IViewFor)); Locator.CurrentMutable.Register(() => new SecondRoutableView(), typeof(IViewFor)); + Locator.CurrentMutable.Register(() => new AlternativeFirstRoutableView(), typeof(IViewFor), AlternativeViewContract); + Locator.CurrentMutable.Register(() => new AlternativeSecondRoutableView(), typeof(IViewFor), AlternativeViewContract); } [Fact] @@ -101,6 +109,71 @@ namespace Avalonia.ReactiveUI.UnitTests Assert.Equal(defaultContent, host.Content); } + [Fact] + public void RoutedViewHost_Should_Stay_In_Sync_With_RoutingState_And_Contract() + { + var screen = new ScreenViewModel(); + var defaultContent = new TextBlock(); + var host = new RoutedViewHost + { + Router = screen.Router, + DefaultContent = defaultContent, + PageTransition = null + }; + + var root = new TestRoot + { + Child = host + }; + + Assert.NotNull(host.Content); + Assert.IsType(host.Content); + Assert.Equal(defaultContent, host.Content); + + var first = new FirstRoutableViewModel(); + screen.Router.Navigate.Execute(first).Subscribe(); + + host.ViewContract = null; + Assert.NotNull(host.Content); + Assert.IsType(host.Content); + Assert.Equal(first, ((FirstRoutableView)host.Content).DataContext); + Assert.Equal(first, ((FirstRoutableView)host.Content).ViewModel); + + host.ViewContract = AlternativeViewContract; + Assert.NotNull(host.Content); + Assert.IsType(host.Content); + Assert.Equal(first, ((AlternativeFirstRoutableView)host.Content).DataContext); + Assert.Equal(first, ((AlternativeFirstRoutableView)host.Content).ViewModel); + + var second = new SecondRoutableViewModel(); + screen.Router.Navigate.Execute(second).Subscribe(); + + host.ViewContract = null; + Assert.NotNull(host.Content); + Assert.IsType(host.Content); + Assert.Equal(second, ((SecondRoutableView)host.Content).DataContext); + Assert.Equal(second, ((SecondRoutableView)host.Content).ViewModel); + + host.ViewContract = AlternativeViewContract; + Assert.NotNull(host.Content); + Assert.IsType(host.Content); + Assert.Equal(second, ((AlternativeSecondRoutableView)host.Content).DataContext); + Assert.Equal(second, ((AlternativeSecondRoutableView)host.Content).ViewModel); + + screen.Router.NavigateBack.Execute(Unit.Default).Subscribe(); + + Assert.NotNull(host.Content); + Assert.IsType(host.Content); + Assert.Equal(first, ((AlternativeFirstRoutableView)host.Content).DataContext); + Assert.Equal(first, ((AlternativeFirstRoutableView)host.Content).ViewModel); + + screen.Router.NavigateBack.Execute(Unit.Default).Subscribe(); + + Assert.NotNull(host.Content); + Assert.IsType(host.Content); + Assert.Equal(defaultContent, host.Content); + } + [Fact] public void RoutedViewHost_Should_Show_Default_Content_When_Router_Is_Null() { diff --git a/tests/Avalonia.ReactiveUI.UnitTests/ViewModelViewHostTest.cs b/tests/Avalonia.ReactiveUI.UnitTests/ViewModelViewHostTest.cs index 35d1cbf62d..858c476227 100644 --- a/tests/Avalonia.ReactiveUI.UnitTests/ViewModelViewHostTest.cs +++ b/tests/Avalonia.ReactiveUI.UnitTests/ViewModelViewHostTest.cs @@ -12,15 +12,23 @@ namespace Avalonia.ReactiveUI.UnitTests public class FirstView : ReactiveUserControl { } + public class AlternativeFirstView : ReactiveUserControl { } + public class SecondViewModel : ReactiveObject { } public class SecondView : ReactiveUserControl { } + public class AlternativeSecondView : ReactiveUserControl { } + + public static string AlternativeViewContract => "AlternativeView"; + public ViewModelViewHostTest() { Locator.CurrentMutable.RegisterConstant(new AvaloniaActivationForViewFetcher(), typeof(IActivationForViewFetcher)); Locator.CurrentMutable.Register(() => new FirstView(), typeof(IViewFor)); Locator.CurrentMutable.Register(() => new SecondView(), typeof(IViewFor)); + Locator.CurrentMutable.Register(() => new AlternativeFirstView(), typeof(IViewFor), AlternativeViewContract); + Locator.CurrentMutable.Register(() => new AlternativeSecondView(), typeof(IViewFor), AlternativeViewContract); } [Fact] @@ -67,5 +75,67 @@ namespace Avalonia.ReactiveUI.UnitTests Assert.Equal(first, ((FirstView)host.Content).DataContext); Assert.Equal(first, ((FirstView)host.Content).ViewModel); } + + [Fact] + public void ViewModelViewHost_View_Should_Stay_In_Sync_With_ViewModel_And_Contract() + { + var defaultContent = new TextBlock(); + var host = new ViewModelViewHost + { + DefaultContent = defaultContent, + PageTransition = null + }; + + var root = new TestRoot + { + Child = host + }; + + Assert.NotNull(host.Content); + Assert.Equal(typeof(TextBlock), host.Content.GetType()); + Assert.Equal(defaultContent, host.Content); + + var first = new FirstViewModel(); + host.ViewModel = first; + + host.ViewContract = null; + Assert.NotNull(host.Content); + Assert.Equal(typeof(FirstView), host.Content.GetType()); + Assert.Equal(first, ((FirstView)host.Content).DataContext); + Assert.Equal(first, ((FirstView)host.Content).ViewModel); + + host.ViewContract = AlternativeViewContract; + Assert.NotNull(host.Content); + Assert.Equal(typeof(AlternativeFirstView), host.Content.GetType()); + Assert.Equal(first, ((AlternativeFirstView)host.Content).DataContext); + Assert.Equal(first, ((AlternativeFirstView)host.Content).ViewModel); + + var second = new SecondViewModel(); + host.ViewModel = second; + + host.ViewContract = null; + Assert.NotNull(host.Content); + Assert.Equal(typeof(SecondView), host.Content.GetType()); + Assert.Equal(second, ((SecondView)host.Content).DataContext); + Assert.Equal(second, ((SecondView)host.Content).ViewModel); + + host.ViewContract = AlternativeViewContract; + Assert.NotNull(host.Content); + Assert.Equal(typeof(AlternativeSecondView), host.Content.GetType()); + Assert.Equal(second, ((AlternativeSecondView)host.Content).DataContext); + Assert.Equal(second, ((AlternativeSecondView)host.Content).ViewModel); + + host.ViewModel = null; + + host.ViewContract = null; + Assert.NotNull(host.Content); + Assert.Equal(typeof(TextBlock), host.Content.GetType()); + Assert.Equal(defaultContent, host.Content); + + host.ViewContract = AlternativeViewContract; + Assert.NotNull(host.Content); + Assert.Equal(typeof(TextBlock), host.Content.GetType()); + Assert.Equal(defaultContent, host.Content); + } } -} \ No newline at end of file +}