diff --git a/src/Avalonia.ReactiveUI/RoutedViewHost.cs b/src/Avalonia.ReactiveUI/RoutedViewHost.cs new file mode 100644 index 0000000000..726e086d9c --- /dev/null +++ b/src/Avalonia.ReactiveUI/RoutedViewHost.cs @@ -0,0 +1,224 @@ +using System; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using Avalonia.Animation; +using Avalonia.Controls; +using Avalonia.Styling; +using ReactiveUI; +using Splat; + +namespace Avalonia +{ + /// + /// This control hosts the View associated with ReactiveUI RoutingState, + /// and will display the View and wire up the ViewModel whenever a new + /// ViewModel is navigated to. Nested routing is also supported. + /// + /// + /// + /// ReactiveUI routing consists of an IScreen that contains current + /// RoutingState, several IRoutableViewModels, and a platform-specific + /// XAML control called RoutedViewHost. + /// + /// + /// RoutingState manages the ViewModel navigation stack and allows + /// ViewModels to navigate to other ViewModels. IScreen is the root of + /// a navigation stack; despite the name, its views don't have to occupy + /// the whole screen. RoutedViewHost monitors an instance of RoutingState, + /// responding to any changes in the navigation stack by creating and + /// embedding the appropriate view. + /// + /// + /// Place this control to a view containing your ViewModel that implements + /// IScreen, and bind IScreen.Router property to RoutedViewHost.Router property. + /// + /// + /// + /// + /// + /// + /// ]]> + /// + /// + /// + /// See + /// ReactiveUI routing documentation website for more info. + /// + /// + public class RoutedViewHost : UserControl, IActivatable, IEnableLogger + { + /// + /// The router dependency property. + /// + public static readonly AvaloniaProperty RouterProperty = + AvaloniaProperty.Register(nameof(Router)); + + /// + /// The default content property. + /// + public static readonly AvaloniaProperty DefaultContentProperty = + AvaloniaProperty.Register(nameof(DefaultContent)); + + /// + /// Fade in animation property. + /// + public static readonly AvaloniaProperty FadeInAnimationProperty = + AvaloniaProperty.Register(nameof(DefaultContent), + CreateOpacityAnimation(0d, 1d, TimeSpan.FromSeconds(0.25))); + + /// + /// Fade out animation property. + /// + public static readonly AvaloniaProperty FadeOutAnimationProperty = + AvaloniaProperty.Register(nameof(DefaultContent), + CreateOpacityAnimation(1d, 0d, TimeSpan.FromSeconds(0.25))); + + /// + /// Initializes a new instance of the class. + /// + public RoutedViewHost() + { + this.WhenActivated(disposables => + { + this.WhenAnyObservable(x => x.Router.CurrentViewModel) + .DistinctUntilChanged() + .Subscribe(NavigateToViewModel) + .DisposeWith(disposables); + }); + } + + /// + /// Gets or sets the of the view model stack. + /// + public RoutingState Router + { + get => GetValue(RouterProperty); + set => SetValue(RouterProperty, value); + } + + /// + /// Gets or sets the content displayed whenever there is no page currently routed. + /// + public object DefaultContent + { + get => GetValue(DefaultContentProperty); + set => SetValue(DefaultContentProperty, value); + } + + /// + /// Gets or sets the animation played when page appears. + /// + public IAnimation FadeInAnimation + { + get => GetValue(FadeInAnimationProperty); + set => SetValue(FadeInAnimationProperty, value); + } + + /// + /// Gets or sets the animation played when page disappears. + /// + public IAnimation FadeOutAnimation + { + get => GetValue(FadeOutAnimationProperty); + set => SetValue(FadeOutAnimationProperty, value); + } + + /// + /// Duplicates the Content property with a private setter. + /// + public new object Content + { + get => base.Content; + private set => base.Content = 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. + /// + /// Thrown when ViewLocator is unable to find the appropriate view. + /// + private void NavigateToViewModel(IRoutableViewModel viewModel) + { + if (viewModel == null) + { + this.Log().Info("ViewModel is null, falling back to default content."); + UpdateContent(DefaultContent); + return; + } + + var viewLocator = ViewLocator ?? ReactiveUI.ViewLocator.Current; + var view = viewLocator.ResolveView(viewModel); + if (view == null) throw new Exception($"Couldn't find view for '{viewModel}'. Is it registered?"); + + this.Log().Info($"Ready to show {view} with autowired {viewModel}."); + view.ViewModel = viewModel; + UpdateContent(view); + } + + /// + /// Updates the content with transitions. + /// + /// New content to set. + private async void UpdateContent(object newContent) + { + if (FadeOutAnimation != null) + await FadeOutAnimation.RunAsync(this, Clock); + Content = newContent; + if (FadeInAnimation != null) + await FadeInAnimation.RunAsync(this, Clock); + } + + /// + /// Creates opacity animation for this routed view host. + /// + /// Opacity to start from. + /// Opacity to finish with. + /// Duration of the animation. + /// Animation object instance. + private static IAnimation CreateOpacityAnimation(double from, double to, TimeSpan duration) + { + return new Avalonia.Animation.Animation + { + Duration = duration, + Children = + { + new KeyFrame + { + Setters = + { + new Setter + { + Property = OpacityProperty, + Value = from + } + }, + Cue = new Cue(0d) + }, + new KeyFrame + { + Setters = + { + new Setter + { + Property = OpacityProperty, + Value = to + } + }, + Cue = new Cue(1d) + } + } + }; + } + } +} diff --git a/tests/Avalonia.ReactiveUI.UnitTests/RoutedViewHostTest.cs b/tests/Avalonia.ReactiveUI.UnitTests/RoutedViewHostTest.cs new file mode 100644 index 0000000000..c85b999af2 --- /dev/null +++ b/tests/Avalonia.ReactiveUI.UnitTests/RoutedViewHostTest.cs @@ -0,0 +1,104 @@ +using System; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using Avalonia.Controls; +using Avalonia.Rendering; +using Avalonia.Platform; +using Avalonia.UnitTests; +using Avalonia; +using ReactiveUI; +using DynamicData; +using Xunit; +using Splat; +using Avalonia.Markup.Xaml; +using System.ComponentModel; +using System.Threading.Tasks; +using System.Reactive; + +namespace Avalonia +{ + public class RoutedViewHostTest + { + public class FirstRoutableViewModel : ReactiveObject, IRoutableViewModel + { + public string UrlPathSegment => "first"; + + public IScreen HostScreen { get; set; } + } + + public class FirstRoutableView : ReactiveUserControl { } + + public class SecondRoutableViewModel : ReactiveObject, IRoutableViewModel + { + public string UrlPathSegment => "second"; + + public IScreen HostScreen { get; set; } + } + + public class SecondRoutableView : ReactiveUserControl { } + + public class ScreenViewModel : ReactiveObject, IScreen + { + public RoutingState Router { get; } = new RoutingState(); + } + + public RoutedViewHostTest() + { + Locator.CurrentMutable.RegisterConstant(new AvaloniaActivationForViewFetcher(), typeof(IActivationForViewFetcher)); + Locator.CurrentMutable.Register(() => new FirstRoutableView(), typeof(IViewFor)); + Locator.CurrentMutable.Register(() => new SecondRoutableView(), typeof(IViewFor)); + } + + [Fact] + public void RoutedViewHostShouldStayInSyncWithRoutingState() + { + var screen = new ScreenViewModel(); + var defaultContent = new TextBlock(); + var host = new RoutedViewHost + { + Router = screen.Router, + DefaultContent = defaultContent, + FadeOutAnimation = null, + FadeInAnimation = null + }; + + var root = new TestRoot + { + Child = host + }; + + Assert.NotNull(host.Content); + Assert.Equal(typeof(TextBlock), host.Content.GetType()); + Assert.Equal(defaultContent, host.Content); + + screen.Router.Navigate + .Execute(new FirstRoutableViewModel()) + .Subscribe(); + + Assert.NotNull(host.Content); + Assert.Equal(typeof(FirstRoutableView), host.Content.GetType()); + + screen.Router.Navigate + .Execute(new SecondRoutableViewModel()) + .Subscribe(); + + Assert.NotNull(host.Content); + Assert.Equal(typeof(SecondRoutableView), host.Content.GetType()); + + screen.Router.NavigateBack + .Execute(Unit.Default) + .Subscribe(); + + Assert.NotNull(host.Content); + Assert.Equal(typeof(FirstRoutableView), host.Content.GetType()); + + screen.Router.NavigateBack + .Execute(Unit.Default) + .Subscribe(); + + Assert.NotNull(host.Content); + Assert.Equal(typeof(TextBlock), host.Content.GetType()); + Assert.Equal(defaultContent, host.Content); + } + } +} \ No newline at end of file