diff --git a/src/ReactiveUI.Tests/Routing/Mocks/TestView.cs b/src/ReactiveUI.Tests/Routing/Mocks/TestView.cs new file mode 100644 index 0000000000..4def1f624d --- /dev/null +++ b/src/ReactiveUI.Tests/Routing/Mocks/TestView.cs @@ -0,0 +1,26 @@ +// Copyright (c) 2021 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Splat; + +namespace ReactiveUI.Tests +{ + public class TestView : ReactiveUserControl, IScreen + { +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + + public TestView() +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + { + } + + public TestView(IScreen? screen = null) + { + Router = screen?.Router ?? Locator.Current.GetService()!; + } + + public RoutingState Router { get; } + } +} diff --git a/src/ReactiveUI.Tests/Routing/RoutedViewHostTests.cs b/src/ReactiveUI.Tests/Routing/RoutedViewHostTests.cs new file mode 100644 index 0000000000..8450d1a794 --- /dev/null +++ b/src/ReactiveUI.Tests/Routing/RoutedViewHostTests.cs @@ -0,0 +1,134 @@ +// Copyright (c) 2021 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using System.Reactive.Concurrency; +using System.Windows; +using DynamicData; +using ReactiveUI.Tests.Wpf; +using Splat; +using Xunit; + +namespace ReactiveUI.Tests +{ + public class RoutedViewHostTests + { + [StaFact] + public void RoutedViewHostDefaultContentNotNull() + { + var uc = new RoutedViewHost + { + DefaultContent = new System.Windows.Controls.Label() + }; + var window = new WpfTestWindow(); + window.RootGrid.Children.Add(uc); + + var activation = new ActivationForViewFetcher(); + + activation.GetActivationForView(window).ToObservableChangeSet(scheduler: ImmediateScheduler.Instance).Bind(out var windowActivated).Subscribe(); + + activation.GetActivationForView(uc).ToObservableChangeSet(scheduler: ImmediateScheduler.Instance).Bind(out var controlActivated).Subscribe(); + + var loaded = new RoutedEventArgs + { + RoutedEvent = FrameworkElement.LoadedEvent + }; + + window.RaiseEvent(loaded); + uc.RaiseEvent(loaded); + + new[] { true }.AssertAreEqual(windowActivated); + new[] { true }.AssertAreEqual(controlActivated); + + Assert.NotNull(uc.Content); + + window.Dispatcher.InvokeShutdown(); + } + + [StaFact] + public void RoutedViewHostDefaultContentNotNullWithViewModelAndActivated() + { + Locator.CurrentMutable.Register(() => new(ImmediateScheduler.Instance)); + Locator.CurrentMutable.Register(() => new()); + Locator.CurrentMutable.Register>(() => new TestView()); + + var uc = new RoutedViewHost + { + DefaultContent = new System.Windows.Controls.Label(), + Router = Locator.Current.GetService()! + }; + var window = new WpfTestWindow(); + window.RootGrid.Children.Add(uc); + + var activation = new ActivationForViewFetcher(); + + activation.GetActivationForView(window).ToObservableChangeSet(scheduler: ImmediateScheduler.Instance).Bind(out var windowActivated).Subscribe(); + + activation.GetActivationForView(uc).ToObservableChangeSet(scheduler: ImmediateScheduler.Instance).Bind(out var controlActivated).Subscribe(); + + var loaded = new RoutedEventArgs + { + RoutedEvent = FrameworkElement.LoadedEvent + }; + + window.RaiseEvent(loaded); + uc.RaiseEvent(loaded); + + new[] { true }.AssertAreEqual(windowActivated); + new[] { true }.AssertAreEqual(controlActivated); + + // Default Content + Assert.IsType(uc.Content); + + // Test Navigation after activated + uc.Router.Navigate.Execute(Locator.Current.GetService()!); + Assert.IsType(uc.Content); + + window.Dispatcher.InvokeShutdown(); + } + + [StaFact] + public void RoutedViewHostDefaultContentNotNullWithViewModelAndNotActivated() + { + Locator.CurrentMutable.Register(() => new(ImmediateScheduler.Instance)); + Locator.CurrentMutable.Register(() => new()); + Locator.CurrentMutable.Register>(() => new TestView()); + + var uc = new RoutedViewHost + { + DefaultContent = new System.Windows.Controls.Label(), + Router = Locator.Current.GetService()! + }; + var window = new WpfTestWindow(); + window.RootGrid.Children.Add(uc); + + var activation = new ActivationForViewFetcher(); + + activation.GetActivationForView(window).ToObservableChangeSet(scheduler: ImmediateScheduler.Instance).Bind(out var windowActivated).Subscribe(); + + activation.GetActivationForView(uc).ToObservableChangeSet(scheduler: ImmediateScheduler.Instance).Bind(out var controlActivated).Subscribe(); + + var loaded = new RoutedEventArgs + { + RoutedEvent = FrameworkElement.LoadedEvent + }; + + // Test navigation before Activation. + uc.Router.Navigate.Execute(Locator.Current.GetService()!); + + // Activate + window.RaiseEvent(loaded); + uc.RaiseEvent(loaded); + + new[] { true }.AssertAreEqual(windowActivated); + new[] { true }.AssertAreEqual(controlActivated); + + // Test Navigation before activated + Assert.IsType(uc.Content); + + window.Dispatcher.InvokeShutdown(); + } + } +} diff --git a/src/ReactiveUI.Tests/Routing/ViewModelViewHostTests.cs b/src/ReactiveUI.Tests/Routing/ViewModelViewHostTests.cs new file mode 100644 index 0000000000..62e5db4c38 --- /dev/null +++ b/src/ReactiveUI.Tests/Routing/ViewModelViewHostTests.cs @@ -0,0 +1,93 @@ +// Copyright (c) 2021 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using System.Reactive.Concurrency; +using System.Windows; +using DynamicData; +using ReactiveUI.Tests.Wpf; +using Splat; +using Xunit; + +namespace ReactiveUI.Tests +{ + public class ViewModelViewHostTests + { + [StaFact] + public void ViewModelViewHostDefaultContentNotNull() + { + var uc = new ViewModelViewHost + { + DefaultContent = new System.Windows.Controls.Label() + }; + var window = new WpfTestWindow(); + window.RootGrid.Children.Add(uc); + + var activation = new ActivationForViewFetcher(); + + activation.GetActivationForView(window) + .ToObservableChangeSet(scheduler: ImmediateScheduler.Instance) + .Bind(out var windowActivated) + .Subscribe(); + + activation.GetActivationForView(uc) + .ToObservableChangeSet(scheduler: ImmediateScheduler.Instance) + .Bind(out var controlActivated) + .Subscribe(); + + var loaded = new RoutedEventArgs + { + RoutedEvent = FrameworkElement.LoadedEvent + }; + + window.RaiseEvent(loaded); + uc.RaiseEvent(loaded); + + new[] { true }.AssertAreEqual(windowActivated); + new[] { true }.AssertAreEqual(controlActivated); + + Assert.NotNull(uc.Content); + + window.Dispatcher.InvokeShutdown(); + } + + [StaFact] + public void ViewModelViewHostContentNotNullWithViewModelAndActivated() + { + Locator.CurrentMutable.Register(() => new()); + Locator.CurrentMutable.Register>(() => new TestView()); + + var uc = new ViewModelViewHost + { + DefaultContent = new System.Windows.Controls.Label(), + ViewModel = Locator.Current.GetService() + }; + var window = new WpfTestWindow(); + window.RootGrid.Children.Add(uc); + + var activation = new ActivationForViewFetcher(); + + activation.GetActivationForView(window).ToObservableChangeSet(scheduler: ImmediateScheduler.Instance).Bind(out var windowActivated).Subscribe(); + + activation.GetActivationForView(uc).ToObservableChangeSet(scheduler: ImmediateScheduler.Instance).Bind(out var controlActivated).Subscribe(); + + var loaded = new RoutedEventArgs + { + RoutedEvent = FrameworkElement.LoadedEvent + }; + + window.RaiseEvent(loaded); + uc.RaiseEvent(loaded); + + new[] { true }.AssertAreEqual(windowActivated); + new[] { true }.AssertAreEqual(controlActivated); + + // Test IViewFor after activated + Assert.IsType(uc.Content); + + window.Dispatcher.InvokeShutdown(); + } + } +} diff --git a/src/ReactiveUI/Platforms/windows-common/RoutedViewHost.cs b/src/ReactiveUI/Platforms/windows-common/RoutedViewHost.cs index 31aa5195e7..db427230cd 100644 --- a/src/ReactiveUI/Platforms/windows-common/RoutedViewHost.cs +++ b/src/ReactiveUI/Platforms/windows-common/RoutedViewHost.cs @@ -16,12 +16,15 @@ using Windows.UI.Xaml; using Windows.UI.Xaml.Controls; #else + using System.Windows.Controls; + #endif #if HAS_UNO namespace ReactiveUI.Uno #else + namespace ReactiveUI #endif { @@ -55,6 +58,8 @@ class RoutedViewHost : TransitioningContentControl, IActivatableView, IEnableLog public static readonly DependencyProperty ViewContractObservableProperty = DependencyProperty.Register("ViewContractObservable", typeof(IObservable), typeof(RoutedViewHost), new PropertyMetadata(Observable.Default)); + private string? _viewContract; + /// /// Initializes a new instance of the class. /// @@ -66,14 +71,8 @@ public RoutedViewHost() HorizontalContentAlignment = HorizontalAlignment.Stretch; VerticalContentAlignment = VerticalAlignment.Stretch; - if (ModeDetector.InUnitTestRunner()) - { - ViewContractObservable = Observable.Never; - return; - } - var platform = Locator.Current.GetService(); - Func platformGetter = () => default!; + Func platformGetter = () => default; if (platform == null) { @@ -86,7 +85,9 @@ public RoutedViewHost() platformGetter = () => platform.GetOrientation(); } - ViewContractObservable = Observable.FromEvent( + ViewContractObservable = ModeDetector.InUnitTestRunner() + ? Observable.Never + : Observable.FromEvent( eventHandler => { void Handler(object sender, SizeChangedEventArgs e) => eventHandler(platformGetter()); @@ -94,12 +95,12 @@ public RoutedViewHost() }, x => SizeChanged += x, x => SizeChanged -= x) - .DistinctUntilChanged() - .StartWith(platformGetter()) - .Select(x => x); + .StartWith(platformGetter()) + .DistinctUntilChanged(); - var vmAndContract = this.WhenAnyObservable(x => x.Router.CurrentViewModel!).CombineLatest( - this.WhenAnyObservable(x => x.ViewContractObservable), + IRoutableViewModel? currentViewModel = null; + var vmAndContract = this.WhenAnyObservable(x => x.Router.CurrentViewModel).Do(x => currentViewModel = x).StartWith(currentViewModel).CombineLatest( + this.WhenAnyObservable(x => x.ViewContractObservable).Do(x => _viewContract = x).StartWith(ViewContract), (viewModel, contract) => (viewModel, contract)); this.WhenActivated(d => @@ -107,26 +108,8 @@ public RoutedViewHost() // NB: The DistinctUntilChanged is useful because most views in // WinRT will end up getting here twice - once for configuring // the RoutedViewHost's ViewModel, and once on load via SizeChanged - d(vmAndContract.DistinctUntilChanged().Subscribe( - x => - { - if (x.viewModel == null) - { - Content = DefaultContent; - return; - } - - var viewLocator = ViewLocator ?? ReactiveUI.ViewLocator.Current; - var view = viewLocator.ResolveView(x.viewModel, x.contract) ?? viewLocator.ResolveView(x.viewModel); - - if (view == null) - { - throw new Exception($"Couldn't find view for '{x.viewModel}'."); - } - - view.ViewModel = x.viewModel; - Content = view; - }, + d(vmAndContract.DistinctUntilChanged<(IRoutableViewModel? viewModel, string? contract)>().Subscribe( + ResolveViewForViewModel, ex => RxApp.DefaultExceptionHandler.OnNext(ex))); }); } @@ -162,6 +145,15 @@ public IObservable ViewContractObservable set => SetValue(ViewContractObservableProperty, value); } + /// + /// Gets or sets the view contract. + /// + public string? ViewContract + { + get => _viewContract; + set => ViewContractObservable = Observable.Return(value); + } + /// /// Gets or sets the view locator. /// @@ -169,5 +161,25 @@ public IObservable ViewContractObservable /// The view locator. /// public IViewLocator? ViewLocator { get; set; } + + private void ResolveViewForViewModel((IRoutableViewModel? viewModel, string? contract) x) + { + if (x.viewModel == null) + { + Content = DefaultContent; + return; + } + + var viewLocator = ViewLocator ?? ReactiveUI.ViewLocator.Current; + var view = viewLocator.ResolveView(x.viewModel, x.contract) ?? viewLocator.ResolveView(x.viewModel); + + if (view == null) + { + throw new Exception($"Couldn't find view for '{x.viewModel}'."); + } + + view.ViewModel = x.viewModel; + Content = view; + } } } diff --git a/src/ReactiveUI/Platforms/windows-common/ViewModelViewHost.cs b/src/ReactiveUI/Platforms/windows-common/ViewModelViewHost.cs index 210c9daafe..94a6729792 100644 --- a/src/ReactiveUI/Platforms/windows-common/ViewModelViewHost.cs +++ b/src/ReactiveUI/Platforms/windows-common/ViewModelViewHost.cs @@ -8,20 +8,25 @@ using System.Reactive; using System.Reactive.Disposables; using System.Reactive.Linq; -using System.Reactive.Subjects; using System.Windows; using Splat; #if NETFX_CORE || HAS_UNO + using Windows.UI.Xaml; using Windows.UI.Xaml.Controls; + #else + using System.Windows.Controls; + #endif #if HAS_UNO + namespace ReactiveUI.Uno #else + namespace ReactiveUI #endif { @@ -30,13 +35,11 @@ namespace ReactiveUI /// the ViewModel property and display it. This control is very useful /// inside a DataTemplate to display the View associated with a ViewModel. /// - [SuppressMessage("Design", "CA1010:Collections should implement generic interface", Justification = "Deliberate usage")] - [SuppressMessage("Design", "CA1063: Remove IDisposable from the list of interfaces implemented", Justification = "Deliberate usage")] public #if HAS_UNO partial #endif - class ViewModelViewHost : TransitioningContentControl, IViewFor, IEnableLogger, IDisposable + class ViewModelViewHost : TransitioningContentControl, IViewFor, IEnableLogger { /// /// The default content dependency property. @@ -48,18 +51,15 @@ class ViewModelViewHost : TransitioningContentControl, IViewFor, IEnableLogger, /// The view model dependency property. /// public static readonly DependencyProperty ViewModelProperty = - DependencyProperty.Register(nameof(ViewModel), typeof(object), typeof(ViewModelViewHost), new PropertyMetadata(null, ViewModelChanged)); + DependencyProperty.Register(nameof(ViewModel), typeof(object), typeof(ViewModelViewHost), new PropertyMetadata(null)); /// /// The view contract observable dependency property. /// public static readonly DependencyProperty ViewContractObservableProperty = - DependencyProperty.Register(nameof(ViewContractObservable), typeof(IObservable), typeof(ViewModelViewHost), new PropertyMetadata(Observable.Default, ViewContractChanged)); + DependencyProperty.Register(nameof(ViewContractObservable), typeof(IObservable), typeof(ViewModelViewHost), new PropertyMetadata(Observable.Default)); - private readonly Subject _updateViewModel = new(); - private readonly Subject _updateViewContract = new(); private string? _viewContract; - private bool _isDisposed; /// /// Initializes a new instance of the class. @@ -70,14 +70,6 @@ public ViewModelViewHost() DefaultStyleKey = typeof(ViewModelViewHost); #endif - if (ModeDetector.InUnitTestRunner()) - { - ViewContractObservable = Observable.Never; - - // NB: InUnitTestRunner also returns true in Design Mode - return; - } - var platform = Locator.Current.GetService(); Func platformGetter = () => default; @@ -92,26 +84,30 @@ public ViewModelViewHost() platformGetter = () => platform.GetOrientation(); } - var contractChanged = _updateViewContract.Select(_ => ViewContractObservable).Switch(); - var viewModelChanged = _updateViewModel.Select(_ => ViewModel); + ViewContractObservable = ModeDetector.InUnitTestRunner() + ? Observable.Never + : Observable.FromEvent( + eventHandler => + { + void Handler(object? sender, SizeChangedEventArgs e) => eventHandler(platformGetter()!); + return Handler; + }, + x => SizeChanged += x, + x => SizeChanged -= x) + .StartWith(platformGetter()) + .DistinctUntilChanged(); + + var contractChanged = this.WhenAnyObservable(x => x.ViewContractObservable).Do(x => _viewContract = x).StartWith(ViewContract); + var viewModelChanged = this.WhenAnyValue(x => x.ViewModel).StartWith(ViewModel); - var vmAndContract = contractChanged.CombineLatest(viewModelChanged, (contract, vm) => new { ViewModel = vm, Contract = contract }); - - vmAndContract.Subscribe(x => ResolveViewForViewModel(x.ViewModel, x.Contract)); contractChanged .ObserveOn(RxApp.MainThreadScheduler) .Subscribe(x => _viewContract = x ?? string.Empty); - ViewContractObservable = Observable.FromEvent( - eventHandler => - { - void Handler(object? sender, SizeChangedEventArgs e) => eventHandler(platformGetter()!); - return Handler; - }, - x => SizeChanged += x, - x => SizeChanged -= x) - .StartWith(platformGetter()) - .DistinctUntilChanged(); + var vmAndContract = contractChanged + .CombineLatest(viewModelChanged, (contract, vm) => (ViewModel: vm, Contract: contract)); + + this.WhenActivated(d => d(vmAndContract.DistinctUntilChanged().Subscribe(x => ResolveViewForViewModel(x.ViewModel, x.Contract)))); } /// @@ -155,43 +151,6 @@ public string? ViewContract /// public IViewLocator? ViewLocator { get; set; } - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Disposes of resources inside the class. - /// - /// If we are disposing managed resources. - protected virtual void Dispose(bool isDisposing) - { - if (_isDisposed) - { - return; - } - - if (isDisposing) - { - _updateViewModel.Dispose(); - _updateViewContract.Dispose(); - } - - _isDisposed = true; - } - - private static void ViewModelChanged(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs dependencyPropertyChangedEventArgs) - { - ((ViewModelViewHost)dependencyObject)._updateViewModel.OnNext(Unit.Default); - } - - private static void ViewContractChanged(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs dependencyPropertyChangedEventArgs) - { - ((ViewModelViewHost)dependencyObject)._updateViewContract.OnNext(Unit.Default); - } - private void ResolveViewForViewModel(object? viewModel, string? contract) { if (viewModel == null)