diff --git a/src/ReactiveUI.Maui/Common/RoutedViewHost.cs b/src/ReactiveUI.Maui/Common/RoutedViewHost.cs index 811ef7c8fa..572894e9c8 100644 --- a/src/ReactiveUI.Maui/Common/RoutedViewHost.cs +++ b/src/ReactiveUI.Maui/Common/RoutedViewHost.cs @@ -6,13 +6,11 @@ #if WINUI_TARGET using System.Diagnostics.CodeAnalysis; using Microsoft.UI.Xaml; -using ReactiveUI.Internal; #if REACTIVE_SHIM using ReactiveUI.Reactive.Maui.Internal; #else using ReactiveUI.Maui.Internal; #endif -using ReactiveUI.Primitives; using Splat; #if REACTIVE_SHIM @@ -58,78 +56,14 @@ public RoutedViewHost() HorizontalContentAlignment = HorizontalAlignment.Stretch; VerticalContentAlignment = VerticalAlignment.Stretch; - var platform = AppLocator.Current.GetService(); - Func platformGetter = static () => default; - - if (platform is null) - { - // NB: This used to be an error but WPF design mode can't read - // good or do other stuff good. - this.Log().Error( - "Couldn't find an IPlatformOperations implementation. Please make sure you have installed the latest " - + "version of the ReactiveUI packages for your platform. See https://reactiveui.net/docs/getting-started/installation for guidance."); - } - else - { - platformGetter = platform.GetOrientation; - } - - ViewContractObservable = ModeDetector.InUnitTestRunner() - ? Signal.Silent() - - // Replaces FromEvent(SizeChanged).StartWith(platformGetter()).DistinctUntilChanged(). - : new StartWithObservable( - new FromEventObservable(onNext => - { - SizeChangedEventHandler handler = (_, _) => onNext(platformGetter()); - SizeChanged += handler; - return new ActionDisposable(() => SizeChanged -= handler); - }), - platformGetter()) - .DistinctUntilChanged(); - - // Observe Router property changes using DependencyProperty (AOT-friendly) - var routerChanged = MauiReactiveHelpers.CreatePropertyValueObservable( - this, - nameof(Router), - RouterProperty, - () => Router); - - // Observe ViewContractObservable property changes using DependencyProperty (AOT-friendly) - var viewContractObservableChanged = MauiReactiveHelpers.CreatePropertyValueObservable( - this, - nameof(ViewContractObservable), - ViewContractObservableProperty, - () => ViewContractObservable); - - // Observe current view model from router. Replaces Where(...).SelectMany(r => r.CurrentViewModel).StartWith(null). - var currentViewModel = new StartWithObservable( - new KeepSignal(routerChanged, static router => router is not null) - .SelectMany(static router => router!.CurrentViewModel), - null); - - // Flatten the ViewContractObservable observable-of-observable. - // Replaces SelectMany(x => x ?? Return(null)).Do(x => _viewContract = x).StartWith(ViewContract). - var viewContract = new StartWithObservable( - viewContractObservableChanged - .SelectMany(static x => x ?? Signal.Emit(null)) - .Do(x => _viewContract = x), - ViewContract); - - var viewModelAndContract = currentViewModel - .CombineLatest( - viewContract, - static (viewModel, contract) => (viewModel, contract)); - - // Subscribe directly without WhenActivated - // 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 - _ = viewModelAndContract.DistinctUntilChanged() - .Subscribe(new DelegateObserver<(IRoutableViewModel? viewModel, string? contract)>( - ResolveViewForViewModel, - RxState.DefaultExceptionHandler.OnNext)) - .DisposeWith(_subscriptions); + MauiReactiveHelpers.InitializeRoutedViewHost( + (this, this.Log(), observable => ViewContractObservable = observable), + (nameof(Router), RouterProperty, () => Router), + (nameof(ViewContractObservable), ViewContractObservableProperty, () => ViewContractObservable), + () => ViewContract, + contract => _viewContract = contract, + ResolveViewForViewModel, + _subscriptions); } /// Gets or sets the of the view model stack. diff --git a/src/ReactiveUI.Maui/Common/RoutedViewHost{TViewModel}.cs b/src/ReactiveUI.Maui/Common/RoutedViewHost{TViewModel}.cs index 07e6abcd39..ddd0d83a68 100644 --- a/src/ReactiveUI.Maui/Common/RoutedViewHost{TViewModel}.cs +++ b/src/ReactiveUI.Maui/Common/RoutedViewHost{TViewModel}.cs @@ -6,13 +6,11 @@ #if WINUI_TARGET using System.Diagnostics.CodeAnalysis; using Microsoft.UI.Xaml; -using ReactiveUI.Internal; #if REACTIVE_SHIM using ReactiveUI.Reactive.Maui.Internal; #else using ReactiveUI.Maui.Internal; #endif -using ReactiveUI.Primitives; using Splat; #if REACTIVE_SHIM @@ -60,80 +58,22 @@ public RoutedViewHost() HorizontalContentAlignment = HorizontalAlignment.Stretch; VerticalContentAlignment = VerticalAlignment.Stretch; - var platform = AppLocator.Current.GetService(); - Func platformGetter = static () => default; - - if (platform is null) - { - // NB: This used to be an error but WPF design mode can't read - // good or do other stuff good. - this.Log().Error( - "Couldn't find an IPlatformOperations implementation. Please make sure you have installed the latest " - + "version of the ReactiveUI packages for your platform. See https://reactiveui.net/docs/getting-started/installation for guidance."); - } - else - { - platformGetter = platform.GetOrientation; - } - - ViewContractObservable = ModeDetector.InUnitTestRunner() - ? Signal.Silent() - - // Replaces FromEvent(SizeChanged).StartWith(platformGetter()).DistinctUntilChanged(). - : new StartWithObservable( - new FromEventObservable(onNext => - { - SizeChangedEventHandler handler = (_, _) => onNext(platformGetter()); - SizeChanged += handler; - return new ActionDisposable(() => SizeChanged -= handler); - }), - platformGetter()) - .DistinctUntilChanged(); - - // Observe Router property changes using DependencyProperty (AOT-friendly) - var routerChanged = MauiReactiveHelpers.CreatePropertyValueObservable( - this, - nameof(Router), - RouterProperty, - () => Router); - - // Observe ViewContractObservable property changes using DependencyProperty (AOT-friendly) - var viewContractObservableChanged = MauiReactiveHelpers.CreatePropertyValueObservable( - this, - nameof(ViewContractObservable), - ViewContractObservableProperty, - () => ViewContractObservable); - - // Observe current view model from router. Replaces Where(...).SelectMany(r => r.CurrentViewModel).StartWith(null). - var currentViewModel = new StartWithObservable( - new KeepSignal(routerChanged, static router => router is not null) - .SelectMany(static router => router!.CurrentViewModel), - null); - - // Flatten the ViewContractObservable observable-of-observable. - // Replaces SelectMany(x => x ?? Return(null)).Do(x => _viewContract = x).StartWith(ViewContract). - var viewContract = new StartWithObservable( - viewContractObservableChanged - .SelectMany(static x => x ?? Signal.Emit(null)) - .Do(x => _viewContract = x), - ViewContract); - - var viewModelAndContract = currentViewModel - .CombineLatest( - viewContract, - static (viewModel, contract) => (viewModel, contract)); - - // Subscribe directly without WhenActivated - // 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 - _ = viewModelAndContract.DistinctUntilChanged() - .Subscribe(new DelegateObserver<(IRoutableViewModel? viewModel, string? contract)>( - ResolveViewForViewModel, - RxState.DefaultExceptionHandler.OnNext)) - .DisposeWith(_subscriptions); + MauiReactiveHelpers.InitializeRoutedViewHost( + (this, this.Log(), observable => ViewContractObservable = observable), + (nameof(Router), RouterProperty, () => Router), + (nameof(ViewContractObservable), ViewContractObservableProperty, () => ViewContractObservable), + () => ViewContract, + contract => _viewContract = contract, + ResolveViewForViewModel, + _subscriptions); } + /// Gets or sets the view locator. + /// + /// The view locator. + /// + public IViewLocator? ViewLocator { get; set; } + /// Gets or sets the of the view model stack. public RoutingState Router { @@ -169,12 +109,6 @@ public string? ViewContract } } - /// Gets or sets the view locator. - /// - /// The view locator. - /// - public IViewLocator? ViewLocator { get; set; } - /// /// Resolves and displays the view for the given view model and contract. /// This method uses the generic ViewLocator.ResolveView{TViewModel} which is AOT-safe. diff --git a/src/ReactiveUI.Maui/Common/ViewModelViewHost.cs b/src/ReactiveUI.Maui/Common/ViewModelViewHost.cs index 632cc0268a..f540e1d7ee 100644 --- a/src/ReactiveUI.Maui/Common/ViewModelViewHost.cs +++ b/src/ReactiveUI.Maui/Common/ViewModelViewHost.cs @@ -5,13 +5,11 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.UI.Xaml; -using ReactiveUI.Internal; #if REACTIVE_SHIM using ReactiveUI.Reactive.Maui.Internal; #else using ReactiveUI.Maui.Internal; #endif -using ReactiveUI.Primitives; using Splat; #if REACTIVE_SHIM @@ -58,58 +56,12 @@ public partial class ViewModelViewHost : TransitioningContentControl, IViewFor, Justification = "The single-threaded UI control hands 'this' to MauiReactiveHelpers to observe its own dependency-property changes; it is never published to another thread.")] public ViewModelViewHost() { - var platform = AppLocator.Current.GetService(); - Func platformGetter = static () => default; - - if (platform is null) - { - // NB: This used to be an error but WPF design mode can't read - // good or do other stuff good. - this.Log().Error( - "Couldn't find an IPlatformOperations implementation. Please make sure you have installed the latest " - + "version of the ReactiveUI packages for your platform. See https://reactiveui.net/docs/getting-started/installation for guidance."); - } - else - { - platformGetter = platform.GetOrientation; - } - - ViewContractObservable = ModeDetector.InUnitTestRunner() - ? Signal.Silent() - - // Replaces FromEvent(SizeChanged).StartWith(platformGetter()).DistinctUntilChanged(). - : new StartWithObservable( - new FromEventObservable(onNext => - { - SizeChangedEventHandler handler = (_, _) => onNext(platformGetter()); - SizeChanged += handler; - return new ActionDisposable(() => SizeChanged -= handler); - }), - platformGetter()) - .DistinctUntilChanged(); - - // Observe ViewModel property changes without expression trees (AOT-friendly) - var viewModelChanged = MauiReactiveHelpers.CreatePropertyValueObservable( - this, - nameof(ViewModel), - ViewModelProperty, - () => ViewModel); - - // Combine contract observable (recording the latest contract) with ViewModel changes. - var viewModelAndContract = ViewContractObservable.Do(x => _viewContract = x) - .CombineLatest( - viewModelChanged, - static (contract, vm) => (vm, contract)); - - // Subscribe directly without WhenActivated - _ = new ObserveOnObservable(ViewContractObservable, RxSchedulers.MainThreadScheduler) - .Subscribe(new DelegateObserver(x => _viewContract = x ?? string.Empty)) - .DisposeWith(_subscriptions); - - _ = viewModelAndContract.DistinctUntilChanged() - .Subscribe(new DelegateObserver<(object? ViewModel, string? Contract)>( - x => ResolveViewForViewModel(x.ViewModel, x.Contract))) - .DisposeWith(_subscriptions); + MauiReactiveHelpers.InitializeViewModelViewHost( + (this, this.Log(), observable => ViewContractObservable = observable), + (nameof(ViewModel), ViewModelProperty, () => ViewModel), + contract => _viewContract = contract, + ResolveViewForViewModel, + _subscriptions); } /// Gets or sets the view contract observable. diff --git a/src/ReactiveUI.Maui/Common/ViewModelViewHost{TViewModel}.cs b/src/ReactiveUI.Maui/Common/ViewModelViewHost{TViewModel}.cs index c2ee3fe631..ddcddc7665 100644 --- a/src/ReactiveUI.Maui/Common/ViewModelViewHost{TViewModel}.cs +++ b/src/ReactiveUI.Maui/Common/ViewModelViewHost{TViewModel}.cs @@ -5,13 +5,11 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.UI.Xaml; -using ReactiveUI.Internal; #if REACTIVE_SHIM using ReactiveUI.Reactive.Maui.Internal; #else using ReactiveUI.Maui.Internal; #endif -using ReactiveUI.Primitives; using Splat; #if REACTIVE_SHIM @@ -60,65 +58,12 @@ public partial class ViewModelViewHost< Justification = "The single-threaded UI control hands 'this' to MauiReactiveHelpers to observe its own dependency-property changes; it is never published to another thread.")] public ViewModelViewHost() { - var platform = AppLocator.Current.GetService(); - Func platformGetter = static () => default; - - if (platform is null) - { - // NB: This used to be an error but WPF design mode can't read - // good or do other stuff good. - this.Log().Error( - "Couldn't find an IPlatformOperations implementation. Please make sure you have installed the latest " - + "version of the ReactiveUI packages for your platform. See https://reactiveui.net/docs/getting-started/installation for guidance."); - } - else - { - platformGetter = platform.GetOrientation; - } - - ViewContractObservable = ModeDetector.InUnitTestRunner() - ? Signal.Silent() - - // Replaces FromEvent(SizeChanged).StartWith(platformGetter()).DistinctUntilChanged(). - : new StartWithObservable( - new FromEventObservable(onNext => - { - SizeChangedEventHandler handler = (_, _) => onNext(platformGetter()); - SizeChanged += handler; - return new ActionDisposable(() => SizeChanged -= handler); - }), - platformGetter()) - .DistinctUntilChanged(); - - // Observe ViewModel property changes without expression trees (AOT-friendly) - var viewModelChanged = MauiReactiveHelpers.CreatePropertyValueObservable( - this, - nameof(ViewModel), - ViewModelProperty, - () => ViewModel); - - // Combine contract observable (recording the latest contract) with ViewModel changes. - var viewModelAndContract = ViewContractObservable.Do(x => _viewContract = x) - .CombineLatest( - viewModelChanged, - static (contract, vm) => (vm, contract)); - - // Subscribe directly without WhenActivated - _ = new ObserveOnObservable(ViewContractObservable, RxSchedulers.MainThreadScheduler) - .Subscribe(new DelegateObserver(x => _viewContract = x ?? string.Empty)) - .DisposeWith(_subscriptions); - - _ = viewModelAndContract.DistinctUntilChanged() - .Subscribe(new DelegateObserver<(TViewModel? ViewModel, string? Contract)>( - x => ResolveViewForViewModel(x.ViewModel, x.Contract))) - .DisposeWith(_subscriptions); - } - - /// Gets or sets the view contract observable. - public IObservable ViewContractObservable - { - get => (IObservable)GetValue(ViewContractObservableProperty); - set => SetValue(ViewContractObservableProperty, value); + MauiReactiveHelpers.InitializeViewModelViewHost( + (this, this.Log(), observable => ViewContractObservable = observable), + (nameof(ViewModel), ViewModelProperty, () => ViewModel), + contract => _viewContract = contract, + ResolveViewForViewModel, + _subscriptions); } /// Gets or sets the content displayed by default when no content is set. @@ -135,6 +80,13 @@ public TViewModel? ViewModel set => SetValue(ViewModelProperty, value); } + /// Gets or sets the view contract observable. + public IObservable ViewContractObservable + { + get => (IObservable)GetValue(ViewContractObservableProperty); + set => SetValue(ViewContractObservableProperty, value); + } + /// Gets or sets the ViewModel to display (non-generic interface implementation). object? IViewFor.ViewModel { diff --git a/src/ReactiveUI.Maui/Internal/MauiReactiveHelpers.cs b/src/ReactiveUI.Maui/Internal/MauiReactiveHelpers.cs index 6034921e57..96045b4e63 100644 --- a/src/ReactiveUI.Maui/Internal/MauiReactiveHelpers.cs +++ b/src/ReactiveUI.Maui/Internal/MauiReactiveHelpers.cs @@ -8,6 +8,8 @@ #if IS_WINUI using Microsoft.UI.Xaml; +using ReactiveUI.Primitives; +using Splat; #endif #if REACTIVE_SHIM @@ -137,6 +139,131 @@ internal static IObservable CreatePropertyValueObservable( return new ActionDisposable(() => source.UnregisterPropertyChangedCallback(property, token)); }); } + + /// Initializes a WinUI routed host and its view-contract subscriptions. + /// The host, logger, and view-contract setter. + /// The router property metadata and accessor. + /// The view-contract observable property metadata and accessor. + /// Gets the current view contract. + /// Stores the latest view contract. + /// Resolves a routed view model and contract. + /// Collects the host subscription. + internal static void InitializeRoutedViewHost( + (FrameworkElement Source, IFullLogger Logger, Action> SetViewContractObservable) host, + (string Name, DependencyProperty Property, Func GetValue) router, + (string Name, DependencyProperty Property, Func> GetValue) viewContractObservable, + Func getViewContract, + Action setViewContract, + Action<(IRoutableViewModel? viewModel, string? contract)> resolveView, + MultipleDisposable subscriptions) + { + host.SetViewContractObservable(CreateViewContractObservable(host.Source, host.Logger)); + SubscribeRoutedViewHost( + host.Source, + router, + viewContractObservable, + getViewContract, + setViewContract, + resolveView, + subscriptions); + } + + /// Initializes a WinUI view-model host and its view-contract subscriptions. + /// The hosted view-model type. + /// The host, logger, and view-contract setter. + /// The view-model property metadata and accessor. + /// Stores the latest view contract. + /// Resolves a view model and contract. + /// Collects the host subscriptions. + internal static void InitializeViewModelViewHost( + (FrameworkElement Source, IFullLogger Logger, Action> SetViewContractObservable) host, + (string Name, DependencyProperty Property, Func GetValue) viewModel, + Action setViewContract, + Action resolveView, + MultipleDisposable subscriptions) + { + var viewContractObservable = CreateViewContractObservable(host.Source, host.Logger); + host.SetViewContractObservable(viewContractObservable); + SubscribeViewModelViewHost(host.Source, viewModel, viewContractObservable, setViewContract, resolveView, subscriptions); + } + + /// Subscribes a WinUI routed host to its router and view-contract properties. + /// The host dependency object. + /// The router property metadata and accessor. + /// The view-contract observable property metadata and accessor. + /// Gets the current view contract. + /// Stores the latest view contract. + /// Resolves a routed view model and contract. + /// Collects the host subscription. + internal static void SubscribeRoutedViewHost( + DependencyObject source, + (string Name, DependencyProperty Property, Func GetValue) router, + (string Name, DependencyProperty Property, Func> GetValue) viewContractObservable, + Func getViewContract, + Action setViewContract, + Action<(IRoutableViewModel? viewModel, string? contract)> resolveView, + MultipleDisposable subscriptions) + { + var routerChanged = CreatePropertyValueObservable(source, router.Name, router.Property, router.GetValue); + var viewContractObservableChanged = CreatePropertyValueObservable( + source, + viewContractObservable.Name, + viewContractObservable.Property, + viewContractObservable.GetValue); + var currentViewModel = new StartWithObservable( + new KeepSignal(routerChanged, static router => router is not null) + .SelectMany(static router => router!.CurrentViewModel), + null); + var viewContract = new StartWithObservable( + viewContractObservableChanged + .SelectMany(static observable => observable ?? Signal.Emit(null)) + .Do(setViewContract), + getViewContract()); + + _ = currentViewModel + .CombineLatest(viewContract, static (viewModel, contract) => (viewModel, contract)) + .DistinctUntilChanged() + .Subscribe(new DelegateObserver<(IRoutableViewModel? viewModel, string? contract)>( + resolveView, + RxState.DefaultExceptionHandler.OnNext)) + .DisposeWith(subscriptions); + } + + /// Subscribes a WinUI view-model host to its view model and contract. + /// The hosted view-model type. + /// The host dependency object. + /// The view-model property metadata and accessor. + /// The view-contract observable. + /// Stores the latest view contract. + /// Resolves a view model and contract. + /// Collects the host subscriptions. + internal static void SubscribeViewModelViewHost( + DependencyObject source, + (string Name, DependencyProperty Property, Func GetValue) viewModel, + IObservable viewContractObservable, + Action setViewContract, + Action resolveView, + MultipleDisposable subscriptions) + { + var viewModelChanged = CreatePropertyValueObservable( + source, + viewModel.Name, + viewModel.Property, + viewModel.GetValue); + var viewModelAndContract = viewContractObservable + .Do(setViewContract) + .CombineLatest(viewModelChanged, static (contract, viewModel) => (viewModel, contract)); + + _ = new ObserveOnObservable(viewContractObservable, RxSchedulers.MainThreadScheduler) + .Subscribe(new DelegateObserver(contract => setViewContract(contract ?? string.Empty))) + .DisposeWith(subscriptions); + _ = viewModelAndContract + .DistinctUntilChanged() + .Subscribe(new DelegateObserver<(TViewModel? viewModel, string? contract)>( + pair => resolveView(pair.viewModel, pair.contract))) + .DisposeWith(subscriptions); + } + #endif /// Wires up activation for a view model that supports activation. @@ -159,4 +286,23 @@ internal static IDisposable WireActivationIfSupported( return new MultipleDisposable(activatedSub, deactivatedSub); } + +#if IS_WINUI + /// Creates a WinUI view-contract stream from platform orientation and size changes. + /// The host element. + /// The host logger. + /// The view-contract stream. + private static IObservable CreateViewContractObservable(FrameworkElement source, IFullLogger logger) + { + var platformGetter = ViewContractObservableHelpers.GetPlatformOrientation(logger); + return ViewContractObservableHelpers.Create( + platformGetter, + new FromEventObservable(onNext => + { + SizeChangedEventHandler handler = (_, _) => onNext(platformGetter()); + source.SizeChanged += handler; + return new ActionDisposable(() => source.SizeChanged -= handler); + })); + } +#endif } diff --git a/src/ReactiveUI.Shared/Bindings/Command/CommandBinderImplementation.cs b/src/ReactiveUI.Shared/Bindings/Command/CommandBinderImplementation.cs index da78af3055..37eb98c1c4 100644 --- a/src/ReactiveUI.Shared/Bindings/Command/CommandBinderImplementation.cs +++ b/src/ReactiveUI.Shared/Bindings/Command/CommandBinderImplementation.cs @@ -79,30 +79,12 @@ public IReactiveBinding BindCommand< ArgumentExceptionHelper.ThrowIfNull(viewModelProperty); ArgumentExceptionHelper.ThrowIfNull(controlProperty); - var viewModelExpression = Reflection.Rewrite(viewModelProperty.Body); - var controlExpression = Reflection.Rewrite(controlProperty.Body); var parameterExpression = Reflection.Rewrite(withParameter.Body); - var source = new MapSignal(Reflection.ViewModelWhenAnyValue(viewModel, view, viewModelExpression), static x => (TProp)x!); - // Observe the parameter through the view's current view model (not the originally supplied one) so the - // parameter rebinds when the view model instance is replaced, matching the command source above. + // parameter rebinds when the view model instance is replaced, matching the command source. var parameter = new MapSignal(Reflection.ViewModelWhenAnyValue(viewModel, view, parameterExpression), static x => (TParam?)x); - - var bindingDisposable = BindCommandInternal( - source, - view, - controlExpression, - parameter, - toEvent); - - return new ReactiveBinding( - view, - controlExpression, - viewModelExpression, - source, - BindingDirection.OneWay, - bindingDisposable); + return BindCommand(viewModel, view, viewModelProperty, controlProperty, parameter, toEvent); } /// diff --git a/src/ReactiveUI.Shared/Bindings/Command/CommandBinderMixins.cs b/src/ReactiveUI.Shared/Bindings/Command/CommandBinderMixins.cs index 7b4dcf6e14..63f7c4fcd7 100644 --- a/src/ReactiveUI.Shared/Bindings/Command/CommandBinderMixins.cs +++ b/src/ReactiveUI.Shared/Bindings/Command/CommandBinderMixins.cs @@ -66,15 +66,8 @@ public IReactiveBinding BindCommand< IObservable withParameter) where TViewModel : class where TProp : ICommand - where TControl : class - { - ArgumentExceptionHelper.ThrowIfNull(view); - ArgumentExceptionHelper.ThrowIfNull(propertyName); - ArgumentExceptionHelper.ThrowIfNull(controlName); - ArgumentExceptionHelper.ThrowIfNull(withParameter); - - return _binderImplementation.BindCommand(viewModel, view, propertyName, controlName, withParameter); - } + where TControl : class => + BindCommand(view, viewModel, propertyName, controlName, withParameter, null); /// /// Binds a command from the view model to a control on the view, enabling the control to execute the command with a @@ -114,10 +107,7 @@ public IReactiveBinding BindCommand< where TProp : ICommand where TControl : class { - ArgumentExceptionHelper.ThrowIfNull(view); - ArgumentExceptionHelper.ThrowIfNull(propertyName); - ArgumentExceptionHelper.ThrowIfNull(controlName); - ArgumentExceptionHelper.ThrowIfNull(withParameter); + ValidateBindingArguments(view, propertyName, controlName, withParameter); return _binderImplementation.BindCommand(viewModel, view, propertyName, controlName, withParameter, toEvent); } @@ -180,9 +170,7 @@ public IReactiveBinding BindCommand< where TProp : ICommand where TControl : class { - ArgumentExceptionHelper.ThrowIfNull(view); - ArgumentExceptionHelper.ThrowIfNull(propertyName); - ArgumentExceptionHelper.ThrowIfNull(controlName); + ValidateBindingArguments(view, propertyName, controlName); return _binderImplementation.BindCommand(viewModel, view, propertyName, controlName, Signal.None(), toEvent); } @@ -212,15 +200,8 @@ public IReactiveBinding BindCommand< Expression> withParameter) where TViewModel : class where TProp : ICommand - where TControl : class - { - ArgumentExceptionHelper.ThrowIfNull(view); - ArgumentExceptionHelper.ThrowIfNull(propertyName); - ArgumentExceptionHelper.ThrowIfNull(controlName); - ArgumentExceptionHelper.ThrowIfNull(withParameter); - - return _binderImplementation.BindCommand(viewModel, view, propertyName, controlName, withParameter); - } + where TControl : class => + BindCommand(view, viewModel, propertyName, controlName, withParameter, null); /// /// Binds a command from the view model to a control on the view, enabling the control to execute the command with a @@ -260,12 +241,31 @@ public IReactiveBinding BindCommand< where TProp : ICommand where TControl : class { - ArgumentExceptionHelper.ThrowIfNull(view); - ArgumentExceptionHelper.ThrowIfNull(propertyName); - ArgumentExceptionHelper.ThrowIfNull(controlName); - ArgumentExceptionHelper.ThrowIfNull(withParameter); + ValidateBindingArguments(view, propertyName, controlName, withParameter); return _binderImplementation.BindCommand(viewModel, view, propertyName, controlName, withParameter, toEvent); } } + + /// Validates the arguments shared by command-binding overloads. + /// The view being bound. + /// The command property expression. + /// The control expression. + private static void ValidateBindingArguments(object? view, object? propertyName, object? controlName) + { + ArgumentExceptionHelper.ThrowIfNull(view); + ArgumentExceptionHelper.ThrowIfNull(propertyName); + ArgumentExceptionHelper.ThrowIfNull(controlName); + } + + /// Validates the arguments shared by parameterized command-binding overloads. + /// The view being bound. + /// The command property expression. + /// The control expression. + /// The command parameter source. + private static void ValidateBindingArguments(object? view, object? propertyName, object? controlName, object? withParameter) + { + ValidateBindingArguments(view, propertyName, controlName); + ArgumentExceptionHelper.ThrowIfNull(withParameter); + } } diff --git a/src/ReactiveUI.Shared/ObservableForProperty/OAPHCreationHelperMixins.cs b/src/ReactiveUI.Shared/ObservableForProperty/OAPHCreationHelperMixins.cs index 9e78faf843..4c26ecdd3f 100644 --- a/src/ReactiveUI.Shared/ObservableForProperty/OAPHCreationHelperMixins.cs +++ b/src/ReactiveUI.Shared/ObservableForProperty/OAPHCreationHelperMixins.cs @@ -1053,26 +1053,7 @@ internal ObservableAsPropertyHelper ObservableToProperty( ArgumentExceptionHelper.ThrowIfNull(observable); ArgumentExceptionHelper.ThrowIfNull(property); - var expression = Reflection.Rewrite(property.Body); - - var parent = expression.GetParent() - ?? throw new ArgumentException( - "The property expression does not have a valid parent.", - nameof(property)); - if (parent.NodeType != ExpressionType.Parameter) - { - throw new ArgumentException("Property expression must be of the form 'x => x.SomeProperty'"); - } - - var memberInfo = expression.GetMemberInfo() - ?? throw new ArgumentException( - "The property expression does not point towards a valid member.", - nameof(property)); - var name = memberInfo.Name; - if (expression is IndexExpression) - { - name += "[]"; - } + var name = GetPropertyName(property); return new( observable, @@ -1111,26 +1092,7 @@ internal ObservableAsPropertyHelper ObservableToProperty( ArgumentExceptionHelper.ThrowIfNull(observable); ArgumentExceptionHelper.ThrowIfNull(property); - var expression = Reflection.Rewrite(property.Body); - - var parent = expression.GetParent() - ?? throw new ArgumentException( - "The property expression does not have a valid parent.", - nameof(property)); - if (parent.NodeType != ExpressionType.Parameter) - { - throw new ArgumentException("Property expression must be of the form 'x => x.SomeProperty'"); - } - - var memberInfo = expression.GetMemberInfo() - ?? throw new ArgumentException( - "The property expression does not point towards a valid member.", - nameof(property)); - var name = memberInfo.Name; - if (expression is IndexExpression) - { - name += "[]"; - } + var name = GetPropertyName(property); return new( observable, @@ -1211,4 +1173,26 @@ internal ObservableAsPropertyHelper ObservableToProperty( scheduler); } } + + /// Gets the property name represented by a valid property expression. + /// The object declaring the property. + /// The property value type. + /// The property expression. + /// The property name used by change notifications. + private static string GetPropertyName(Expression> property) + { + var expression = Reflection.Rewrite(property.Body); + var parent = expression.GetParent(); + ArgumentExceptionHelper.ThrowIfNull(parent); + + if (parent.NodeType != ExpressionType.Parameter) + { + throw new ArgumentException("Property expression must be of the form 'x => x.SomeProperty'"); + } + + var memberInfo = expression.GetMemberInfo(); + ArgumentExceptionHelper.ThrowIfNull(memberInfo); + + return expression is IndexExpression ? $"{memberInfo.Name}[]" : memberInfo.Name; + } } diff --git a/src/ReactiveUI.Shared/ReactiveObject/ReactiveNotificationHelpers.cs b/src/ReactiveUI.Shared/ReactiveObject/ReactiveNotificationHelpers.cs new file mode 100644 index 0000000000..639fce2fcd --- /dev/null +++ b/src/ReactiveUI.Shared/ReactiveObject/ReactiveNotificationHelpers.cs @@ -0,0 +1,110 @@ +// Copyright (c) 2009-2026 .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.ComponentModel; + +#if REACTIVE_SHIM +namespace ReactiveUI.Reactive; +#else +namespace ReactiveUI; +#endif + +/// Manages notification state shared by reactive object implementations. +internal static class ReactiveNotificationHelpers +{ + /// Adds a property-changing event handler. + /// The reactive object owning the event. + /// Tracks whether the observable has been initialized. + /// The event handler store. + /// The handler to add. + internal static void AddPropertyChanging( + IReactiveObject source, + ref bool subscribed, + ref PropertyChangingEventHandler? handlers, + PropertyChangingEventHandler? handler) + { + if (!subscribed) + { + source.SubscribePropertyChangingEvents(); + subscribed = true; + } + + handlers += handler; + } + + /// Adds a property-changed event handler. + /// The reactive object owning the event. + /// Tracks whether the observable has been initialized. + /// The event handler store. + /// The handler to add. + internal static void AddPropertyChanged( + IReactiveObject source, + ref bool subscribed, + ref PropertyChangedEventHandler? handlers, + PropertyChangedEventHandler? handler) + { + if (!subscribed) + { + source.SubscribePropertyChangedEvents(); + subscribed = true; + } + + handlers += handler; + } + + /// Gets the lazily initialized property-changing observable. + /// The reactive object owning the observable. + /// The observable store. + /// The property-changing observable. + internal static IObservable> GetChanging( + IReactiveObject source, + ref IObservable>? observable) + { + var current = Volatile.Read(ref observable); + if (current is not null) + { + return current; + } + + _ = Interlocked.CompareExchange(ref observable, source.GetChangingObservable(), null); + return Volatile.Read(ref observable)!; + } + + /// Gets the lazily initialized property-changed observable. + /// The reactive object owning the observable. + /// The observable store. + /// The property-changed observable. + internal static IObservable> GetChanged( + IReactiveObject source, + ref IObservable>? observable) + { + var current = Volatile.Read(ref observable); + if (current is not null) + { + return current; + } + + _ = Interlocked.CompareExchange(ref observable, source.GetChangedObservable(), null); + return Volatile.Read(ref observable)!; + } + + /// Gets the lazily initialized exception observable. + /// The reactive object owning the observable. + /// The observable store. + /// The exception observable. + internal static IObservable GetThrownExceptions( + IReactiveObject source, + ref IObservable? observable) + { + var current = Volatile.Read(ref observable); + if (current is not null) + { + return current; + } + + _ = Interlocked.CompareExchange(ref observable, source.GetThrownExceptionsObservable(), null); + return Volatile.Read(ref observable)!; + } +} diff --git a/src/ReactiveUI.Shared/ReactiveObject/ReactiveObject.cs b/src/ReactiveUI.Shared/ReactiveObject/ReactiveObject.cs index 4f2c4208eb..0e81a2e42e 100644 --- a/src/ReactiveUI.Shared/ReactiveObject/ReactiveObject.cs +++ b/src/ReactiveUI.Shared/ReactiveObject/ReactiveObject.cs @@ -32,46 +32,37 @@ public class ReactiveObject : IReactiveNotifyPropertyChanged, I /// Tracks whether PropertyChanged event subscriptions have been initialized. private bool _propertyChangedEventsSubscribed; - /// Stores this instance's reactive notification state directly, avoiding a table lookup. - [IgnoreDataMember] - [SuppressMessage("Design", "SST1424:Make field readonly", Justification = "Mutated in place through the ref returned by GetReactiveStateSlot.")] - private object? _reactiveStateSlot; - /// Backing handler for the PropertyChanging event. private PropertyChangingEventHandler? _propertyChangingHandler; /// Backing handler for the PropertyChanged event. private PropertyChangedEventHandler? _propertyChangedHandler; + /// Stores the property-changing observable. + private IObservable>? _changing; + + /// Stores the property-changed observable. + private IObservable>? _changed; + + /// Stores the exception observable. + private IObservable? _thrownExceptions; + + /// Stores this instance's extension state directly, avoiding a table lookup. + [IgnoreDataMember] + [SuppressMessage("Design", "SST1424:Make field readonly", Justification = "Mutated in place through the ref returned by GetReactiveStateSlot.")] + private object? _reactiveStateSlot; + /// public event PropertyChangingEventHandler? PropertyChanging { - add - { - if (!_propertyChangingEventsSubscribed) - { - this.SubscribePropertyChangingEvents(); - _propertyChangingEventsSubscribed = true; - } - - _propertyChangingHandler += value; - } + add => ReactiveNotificationHelpers.AddPropertyChanging(this, ref _propertyChangingEventsSubscribed, ref _propertyChangingHandler, value); remove => _propertyChangingHandler -= value; } /// public event PropertyChangedEventHandler? PropertyChanged { - add - { - if (!_propertyChangedEventsSubscribed) - { - this.SubscribePropertyChangedEvents(); - _propertyChangedEventsSubscribed = true; - } - - _propertyChangedHandler += value; - } + add => ReactiveNotificationHelpers.AddPropertyChanged(this, ref _propertyChangedEventsSubscribed, ref _propertyChangedHandler, value); remove => _propertyChangedHandler -= value; } @@ -83,8 +74,7 @@ public event PropertyChangedEventHandler? PropertyChanged [Display(Order = -1, AutoGenerateField = false, AutoGenerateFilter = false)] #endif public IObservable> Changing => - Volatile.Read(ref field) - ?? Interlocked.CompareExchange(ref field, ((IReactiveObject)this).GetChangingObservable(), null) ?? field; + ReactiveNotificationHelpers.GetChanging(this, ref _changing); /// [IgnoreDataMember] @@ -94,8 +84,7 @@ public event PropertyChangedEventHandler? PropertyChanged [Display(Order = -1, AutoGenerateField = false, AutoGenerateFilter = false)] #endif public IObservable> Changed => - Volatile.Read(ref field) - ?? Interlocked.CompareExchange(ref field, ((IReactiveObject)this).GetChangedObservable(), null) ?? field; + ReactiveNotificationHelpers.GetChanged(this, ref _changed); /// [IgnoreDataMember] @@ -105,8 +94,7 @@ public event PropertyChangedEventHandler? PropertyChanged [Display(Order = -1, AutoGenerateField = false, AutoGenerateFilter = false)] #endif public IObservable ThrownExceptions => - Volatile.Read(ref field) - ?? Interlocked.CompareExchange(ref field, this.GetThrownExceptionsObservable(), null) ?? field; + ReactiveNotificationHelpers.GetThrownExceptions(this, ref _thrownExceptions); /// void IReactiveObject.RaisePropertyChanging(PropertyChangingEventArgs args) => diff --git a/src/ReactiveUI.Shared/ReactiveObject/ReactiveRecord.cs b/src/ReactiveUI.Shared/ReactiveObject/ReactiveRecord.cs index 16677eb956..427a2836e9 100644 --- a/src/ReactiveUI.Shared/ReactiveObject/ReactiveRecord.cs +++ b/src/ReactiveUI.Shared/ReactiveObject/ReactiveRecord.cs @@ -37,36 +37,27 @@ public abstract record ReactiveRecord : IReactiveNotifyPropertyChangedBacking event store for property-changed notifications. private PropertyChangedEventHandler? _propertyChangedHandler; + /// Stores the property-changing observable. + private IObservable>? _changing; + + /// Stores the property-changed observable. + private IObservable>? _changed; + + /// Stores the exception observable. + private IObservable? _thrownExceptions; + /// public event PropertyChangingEventHandler? PropertyChanging { - add - { - if (!_propertyChangingEventsSubscribed) - { - this.SubscribePropertyChangingEvents(); - _propertyChangingEventsSubscribed = true; - } - - _propertyChangingHandler += value; - } - remove => _propertyChangingHandler -= value; + add => AddPropertyChanging(value); + remove => RemovePropertyChanging(value); } /// public event PropertyChangedEventHandler? PropertyChanged { - add - { - if (!_propertyChangedEventsSubscribed) - { - this.SubscribePropertyChangedEvents(); - _propertyChangedEventsSubscribed = true; - } - - _propertyChangedHandler += value; - } - remove => _propertyChangedHandler -= value; + add => AddPropertyChanged(value); + remove => RemovePropertyChanged(value); } /// @@ -76,9 +67,7 @@ public event PropertyChangedEventHandler? PropertyChanged [Browsable(false)] [Display(Order = -1, AutoGenerateField = false, AutoGenerateFilter = false)] #endif - public IObservable> Changing => - Volatile.Read(ref field) - ?? Interlocked.CompareExchange(ref field, ((IReactiveObject)this).GetChangingObservable(), null) ?? field; + public IObservable> Changing => GetChanging(); /// [IgnoreDataMember] @@ -87,9 +76,7 @@ public event PropertyChangedEventHandler? PropertyChanged [Browsable(false)] [Display(Order = -1, AutoGenerateField = false, AutoGenerateFilter = false)] #endif - public IObservable> Changed => - Volatile.Read(ref field) - ?? Interlocked.CompareExchange(ref field, ((IReactiveObject)this).GetChangedObservable(), null) ?? field; + public IObservable> Changed => GetChanged(); /// [IgnoreDataMember] @@ -98,11 +85,7 @@ public event PropertyChangedEventHandler? PropertyChanged [Browsable(false)] [Display(Order = -1, AutoGenerateField = false, AutoGenerateFilter = false)] #endif - public IObservable ThrownExceptions => Volatile.Read(ref field) - ?? Interlocked.CompareExchange( - ref field, - this.GetThrownExceptionsObservable(), - null) ?? field; + public IObservable ThrownExceptions => GetThrownExceptions(); /// void IReactiveObject.RaisePropertyChanging(PropertyChangingEventArgs args) => @@ -122,4 +105,37 @@ void IReactiveObject.RaisePropertyChanged(PropertyChangedEventArgs args) => /// Delays notifications until the return IDisposable is disposed. /// A disposable which when disposed will send delayed notifications. public IDisposable DelayChangeNotifications() => IReactiveObjectExtensions.DelayChangeNotifications(this); + + /// Adds a property-changing event handler. + /// The handler to add. + private void AddPropertyChanging(PropertyChangingEventHandler? handler) => + ReactiveNotificationHelpers.AddPropertyChanging(this, ref _propertyChangingEventsSubscribed, ref _propertyChangingHandler, handler); + + /// Removes a property-changing event handler. + /// The handler to remove. + private void RemovePropertyChanging(PropertyChangingEventHandler? handler) => _propertyChangingHandler -= handler; + + /// Adds a property-changed event handler. + /// The handler to add. + private void AddPropertyChanged(PropertyChangedEventHandler? handler) => + ReactiveNotificationHelpers.AddPropertyChanged(this, ref _propertyChangedEventsSubscribed, ref _propertyChangedHandler, handler); + + /// Removes a property-changed event handler. + /// The handler to remove. + private void RemovePropertyChanged(PropertyChangedEventHandler? handler) => _propertyChangedHandler -= handler; + + /// Gets the property-changing observable. + /// The property-changing observable. + private IObservable> GetChanging() => + ReactiveNotificationHelpers.GetChanging(this, ref _changing); + + /// Gets the property-changed observable. + /// The property-changed observable. + private IObservable> GetChanged() => + ReactiveNotificationHelpers.GetChanged(this, ref _changed); + + /// Gets the exception observable. + /// The exception observable. + private IObservable GetThrownExceptions() => + ReactiveNotificationHelpers.GetThrownExceptions(this, ref _thrownExceptions); } diff --git a/src/ReactiveUI.WinUI.Reactive/ReactiveUI.WinUI.Reactive.csproj b/src/ReactiveUI.WinUI.Reactive/ReactiveUI.WinUI.Reactive.csproj index 34b87b43c4..3d69b8d5c2 100644 --- a/src/ReactiveUI.WinUI.Reactive/ReactiveUI.WinUI.Reactive.csproj +++ b/src/ReactiveUI.WinUI.Reactive/ReactiveUI.WinUI.Reactive.csproj @@ -59,5 +59,6 @@ + diff --git a/src/ReactiveUI.WinUI/ReactiveUI.WinUI.csproj b/src/ReactiveUI.WinUI/ReactiveUI.WinUI.csproj index d1ee38c9ad..c12dd0dc45 100644 --- a/src/ReactiveUI.WinUI/ReactiveUI.WinUI.csproj +++ b/src/ReactiveUI.WinUI/ReactiveUI.WinUI.csproj @@ -44,5 +44,6 @@ + diff --git a/src/ReactiveUI.Wpf.Reactive/ReactiveUI.Wpf.Reactive.csproj b/src/ReactiveUI.Wpf.Reactive/ReactiveUI.Wpf.Reactive.csproj index bbd34e34fa..77e9134c45 100644 --- a/src/ReactiveUI.Wpf.Reactive/ReactiveUI.Wpf.Reactive.csproj +++ b/src/ReactiveUI.Wpf.Reactive/ReactiveUI.Wpf.Reactive.csproj @@ -53,6 +53,7 @@ + diff --git a/src/ReactiveUI.Wpf.Shared/Common/RoutedViewHost.cs b/src/ReactiveUI.Wpf.Shared/Common/RoutedViewHost.cs index cfe6d1106c..e52d1beecf 100644 --- a/src/ReactiveUI.Wpf.Shared/Common/RoutedViewHost.cs +++ b/src/ReactiveUI.Wpf.Shared/Common/RoutedViewHost.cs @@ -65,34 +65,15 @@ public RoutedViewHost() HorizontalContentAlignment = HorizontalAlignment.Stretch; VerticalContentAlignment = VerticalAlignment.Stretch; - var platform = AppLocator.Current.GetService(); - Func platformGetter = static () => null; - - if (platform is null) - { - // NB: This used to be an error but WPF design mode can't read - // good or do other stuff good. - this.Log().Error( - "Couldn't find an IPlatformOperations implementation. Please make sure you have installed " - + "the latest version of the ReactiveUI packages for your platform. " - + "See https://reactiveui.net/docs/getting-started/installation for guidance."); - } - else - { - platformGetter = platform.GetOrientation; - } - - ViewContractObservable = ModeDetector.InUnitTestRunner() - ? Signal.Silent() - : new StartWithObservable( - new FromEventObservable(onNext => - { - SizeChangedEventHandler handler = (_, _) => onNext(platformGetter()); - SizeChanged += handler; - return new ActionDisposable(() => SizeChanged -= handler); - }), - platformGetter()) - .DistinctUntilChanged(); + var platformGetter = ViewContractObservableHelpers.GetPlatformOrientation(this.Log()); + ViewContractObservable = ViewContractObservableHelpers.Create( + platformGetter, + new FromEventObservable(onNext => + { + SizeChangedEventHandler handler = (_, _) => onNext(platformGetter()); + SizeChanged += handler; + return new ActionDisposable(() => SizeChanged -= handler); + })); IRoutableViewModel? currentViewModel = null; var viewModelAndContract = new StartWithObservable( diff --git a/src/ReactiveUI.Wpf.Shared/Common/ViewModelViewHost.cs b/src/ReactiveUI.Wpf.Shared/Common/ViewModelViewHost.cs index 3d2ddb1731..5e2671f66a 100644 --- a/src/ReactiveUI.Wpf.Shared/Common/ViewModelViewHost.cs +++ b/src/ReactiveUI.Wpf.Shared/Common/ViewModelViewHost.cs @@ -64,34 +64,15 @@ class ViewModelViewHost : TransitioningContentControl, IViewFor, IEnableLogger /// Initializes a new instance of the class. public ViewModelViewHost() { - var platform = AppLocator.Current.GetService(); - Func platformGetter = static () => null; - - if (platform is null) - { - // NB: This used to be an error but WPF design mode can't read - // good or do other stuff good. - this.Log().Error( - "Couldn't find an IPlatformOperations implementation. Please make sure you have installed " - + "the latest version of the ReactiveUI packages for your platform. " - + "See https://reactiveui.net/docs/getting-started/installation for guidance."); - } - else - { - platformGetter = platform.GetOrientation; - } - - ViewContractObservable = ModeDetector.InUnitTestRunner() - ? Signal.Silent() - : new StartWithObservable( - new FromEventObservable(onNext => - { - SizeChangedEventHandler handler = (_, _) => onNext(platformGetter()); - SizeChanged += handler; - return new ActionDisposable(() => SizeChanged -= handler); - }), - platformGetter()) - .DistinctUntilChanged(); + var platformGetter = ViewContractObservableHelpers.GetPlatformOrientation(this.Log()); + ViewContractObservable = ViewContractObservableHelpers.Create( + platformGetter, + new FromEventObservable(onNext => + { + SizeChangedEventHandler handler = (_, _) => onNext(platformGetter()); + SizeChanged += handler; + return new ActionDisposable(() => SizeChanged -= handler); + })); var contractChanged = new StartWithObservable( this.WhenAnyObservable(x => x.ViewContractObservable).Do(x => _viewContract = x), diff --git a/src/ReactiveUI.Wpf/ReactiveUI.Wpf.csproj b/src/ReactiveUI.Wpf/ReactiveUI.Wpf.csproj index ad3d22056f..ab32cd63a3 100644 --- a/src/ReactiveUI.Wpf/ReactiveUI.Wpf.csproj +++ b/src/ReactiveUI.Wpf/ReactiveUI.Wpf.csproj @@ -49,6 +49,7 @@ + diff --git a/src/Shared/ViewContractObservableHelpers.cs b/src/Shared/ViewContractObservableHelpers.cs new file mode 100644 index 0000000000..2b8607cf1e --- /dev/null +++ b/src/Shared/ViewContractObservableHelpers.cs @@ -0,0 +1,46 @@ +// Copyright (c) 2009-2026 .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 ReactiveUI.Primitives; +using Splat; + +#if REACTIVE_SHIM +namespace ReactiveUI.Reactive; +#else +namespace ReactiveUI; +#endif + +/// Creates the view-contract streams shared by platform view hosts. +internal static class ViewContractObservableHelpers +{ + /// Gets the platform orientation callback used by a view host. + /// The host logger. + /// The platform orientation callback. + internal static Func GetPlatformOrientation(IFullLogger logger) + { + var platform = AppLocator.Current.GetService(); + if (platform is null) + { + logger.Error( + "Couldn't find an IPlatformOperations implementation. Please make sure you have installed " + + "the latest version of the ReactiveUI packages for your platform. " + + "See https://reactiveui.net/docs/getting-started/installation for guidance."); + return static () => null; + } + + return platform.GetOrientation; + } + + /// Creates a contract stream from platform orientation and host size changes. + /// Gets the current platform orientation. + /// Signals the current orientation after a host size change. + /// The view-contract stream. + internal static IObservable Create( + Func platformGetter, + IObservable sizeChanges) => + ModeDetector.InUnitTestRunner() + ? Signal.Silent() + : new StartWithObservable(sizeChanges, platformGetter()).DistinctUntilChanged(); +} diff --git a/src/tests/ReactiveUI.Tests/CommandBinding/CommandBindingTests.cs b/src/tests/ReactiveUI.Tests/CommandBinding/CommandBindingTests.cs index 54a5ee7d2b..4a2f6b6184 100644 --- a/src/tests/ReactiveUI.Tests/CommandBinding/CommandBindingTests.cs +++ b/src/tests/ReactiveUI.Tests/CommandBinding/CommandBindingTests.cs @@ -25,6 +25,22 @@ namespace ReactiveUI.Tests.CommandBinding; [TestExecutor] public class CommandBindingTests { + /// The event used by the fake control command bindings. + private const string ClickEvent = "Click"; + + /// The command-parameter source shapes exposed by the binding mixins. + public enum ParameterSource + { + /// No command parameter. + None, + + /// An observable command parameter. + Observable, + + /// A view-model expression command parameter. + Expression + } + /// Verifies that the command binder binds a command to a control event so the command executes when the event is raised. /// A representing the asynchronous operation. [Test] @@ -40,7 +56,7 @@ public async Task CommandBinderImplementation_Should_Bind_Command_To_Event() vm => vm.Command, v => v.Control, Signal.Emit((object?)null), - "Click"); + ClickEvent); await Assert.That(disp).IsNotNull(); @@ -73,6 +89,41 @@ public async Task CommandBinderImplementation_Should_Use_Custom_Binder() await Assert.That(FakeCustomBinder.BindCalled).IsTrue(); } + /// Verifies that each parameter-source extension overload creates an executable binding. + /// The command-parameter source shape to bind. + /// A representing the asynchronous operation. + [Test] + [Arguments(ParameterSource.None)] + [Arguments(ParameterSource.Observable)] + [Arguments(ParameterSource.Expression)] + public async Task BindCommand_ParameterSource_CreatesExecutableBinding(ParameterSource parameterSource) + { + var viewModel = new FakeViewModel(); + var view = new FakeView { ViewModel = viewModel }; + var executed = false; + _ = viewModel.Command.Subscribe(_ => executed = true); + + using var binding = parameterSource switch + { + ParameterSource.None => view.BindCommand(viewModel, model => model.Command, target => target.Control, ClickEvent), + ParameterSource.Observable => view.BindCommand( + viewModel, + model => model.Command, + target => target.Control, + Signal.Emit(default)), + ParameterSource.Expression => view.BindCommand( + viewModel, + model => model.Command, + target => target.Control, + model => model.Parameter), + _ => throw new ArgumentOutOfRangeException(nameof(parameterSource), parameterSource, null) + }; + + view.Control.RaiseClick(); + + await Assert.That(executed).IsTrue(); + } + /// Provides test execution support for command binding scenarios using the ReactiveUI framework. public class CommandBindingExecutorTests : BaseAppBuilderTestExecutor { @@ -201,5 +252,8 @@ private sealed class FakeViewModel : ReactiveObject { /// Gets the command under test. public ReactiveCommand Command { get; } = ReactiveCommand.Create(static () => { }); + + /// Gets the expression-backed command parameter. + public RxVoid Parameter => default; } } diff --git a/src/tests/ReactiveUI.Tests/ObservableForProperty/OaphCreationHelperMixinTest.cs b/src/tests/ReactiveUI.Tests/ObservableForProperty/OaphCreationHelperMixinTest.cs index 059081027d..98ec14d4d4 100644 --- a/src/tests/ReactiveUI.Tests/ObservableForProperty/OaphCreationHelperMixinTest.cs +++ b/src/tests/ReactiveUI.Tests/ObservableForProperty/OaphCreationHelperMixinTest.cs @@ -220,6 +220,39 @@ await Assert.That(() => observable.ToProperty(source, (Expression(); } + /// Tests that a static expression is rejected because it has no object parent. + /// A representing the asynchronous operation. + [Test] + public async Task ToProperty_WithStaticExpression_ThrowsOnMissingParent() + { + var source = new TestReactiveObject(); + + await Assert.That(() => Signal.Emit(TestText).ToProperty(source, static _ => TestReactiveObject.StaticProperty)) + .Throws(); + } + + /// Tests that a nested property expression is rejected because its parent is not the lambda parameter. + /// A representing the asynchronous operation. + [Test] + public async Task ToProperty_WithNestedExpression_ThrowsOnNonParameterParent() + { + var source = new TestReactiveObject(); + + await Assert.That(() => Signal.Emit(TestText).ToProperty(source, static value => value.Nested.TestProperty)) + .Throws(); + } + + /// Tests that an array index expression is rejected because it has no member metadata. + /// A representing the asynchronous operation. + [Test] + public async Task ToProperty_WithArrayIndexExpression_ThrowsOnMissingMember() + { + var source = new TestReactiveObject(); + + await Assert.That(() => Signal.Emit(TestText).ToProperty(source, static value => value.Values[0])) + .Throws(); + } + /// Tests that ToProperty with Expression and initial value creates a helper with initial value. /// A representing the asynchronous operation. [Test] @@ -415,6 +448,15 @@ public async Task ToProperty_WithStringNameAndOut_ReturnsHelperThroughOutParamet /// Test reactive object for testing. private sealed class TestReactiveObject : ReactiveObject { + /// Gets a static value used to create a parentless expression. + public static string StaticProperty => TestText; + + /// Gets a nested object used to create a non-direct expression. + public TestReactiveObject Nested => this; + + /// Gets values used to create an array index expression. + public string[] Values { get; } = [TestText]; + /// Gets or sets the test property. public string? TestProperty { diff --git a/src/tests/ReactiveUI.Tests/ReactiveObjects/ReactiveNotificationHelpersTests.cs b/src/tests/ReactiveUI.Tests/ReactiveObjects/ReactiveNotificationHelpersTests.cs new file mode 100644 index 0000000000..3546314e0f --- /dev/null +++ b/src/tests/ReactiveUI.Tests/ReactiveObjects/ReactiveNotificationHelpersTests.cs @@ -0,0 +1,118 @@ +// Copyright (c) 2009-2026 .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.ComponentModel; + +namespace ReactiveUI.Tests.ReactiveObjects; + +/// Tests for . +public class ReactiveNotificationHelpersTests +{ + /// The number of handlers registered in combination tests. + private const int ExpectedHandlerCalls = 2; + + /// Verifies that property-changing handlers are initialized and retained. + /// A task representing the asynchronous test. + [Test] + public async Task AddPropertyChanging_NewHandlers_InitializesAndCombinesHandlers() + { + var source = new TestReactiveObject(); + var subscribed = false; + PropertyChangingEventHandler? handlers = null; + var calls = 0; + + ReactiveNotificationHelpers.AddPropertyChanging(source, ref subscribed, ref handlers, (_, _) => calls++); + ReactiveNotificationHelpers.AddPropertyChanging(source, ref subscribed, ref handlers, (_, _) => calls++); + handlers?.Invoke(source, new PropertyChangingEventArgs(nameof(TestReactiveObject.Value))); + + using (Assert.Multiple()) + { + await Assert.That(subscribed).IsTrue(); + await Assert.That(calls).IsEqualTo(ExpectedHandlerCalls); + } + } + + /// Verifies that property-changed handlers are initialized and retained. + /// A task representing the asynchronous test. + [Test] + public async Task AddPropertyChanged_NewHandlers_InitializesAndCombinesHandlers() + { + var source = new TestReactiveObject(); + var subscribed = false; + PropertyChangedEventHandler? handlers = null; + var calls = 0; + + ReactiveNotificationHelpers.AddPropertyChanged(source, ref subscribed, ref handlers, (_, _) => calls++); + ReactiveNotificationHelpers.AddPropertyChanged(source, ref subscribed, ref handlers, (_, _) => calls++); + handlers?.Invoke(source, new PropertyChangedEventArgs(nameof(TestReactiveObject.Value))); + + using (Assert.Multiple()) + { + await Assert.That(subscribed).IsTrue(); + await Assert.That(calls).IsEqualTo(ExpectedHandlerCalls); + } + } + + /// Verifies that the property-changing observable is cached after its first creation. + /// A task representing the asynchronous test. + [Test] + public async Task GetChanging_RepeatedCalls_ReturnsCachedObservable() + { + var source = new TestReactiveObject(); + IObservable>? observable = null; + + var first = ReactiveNotificationHelpers.GetChanging(source, ref observable); + var second = ReactiveNotificationHelpers.GetChanging(source, ref observable); + + await Assert.That(second).IsSameReferenceAs(first); + } + + /// Verifies that the property-changed observable is cached after its first creation. + /// A task representing the asynchronous test. + [Test] + public async Task GetChanged_RepeatedCalls_ReturnsCachedObservable() + { + var source = new TestReactiveObject(); + IObservable>? observable = null; + + var first = ReactiveNotificationHelpers.GetChanged(source, ref observable); + var second = ReactiveNotificationHelpers.GetChanged(source, ref observable); + + await Assert.That(second).IsSameReferenceAs(first); + } + + /// Verifies that the exception observable is cached after its first creation. + /// A task representing the asynchronous test. + [Test] + public async Task GetThrownExceptions_RepeatedCalls_ReturnsCachedObservable() + { + var source = new TestReactiveObject(); + IObservable? observable = null; + + var first = ReactiveNotificationHelpers.GetThrownExceptions(source, ref observable); + var second = ReactiveNotificationHelpers.GetThrownExceptions(source, ref observable); + + await Assert.That(second).IsSameReferenceAs(first); + } + + /// Minimal reactive object used to exercise helper-owned state. + private sealed class TestReactiveObject : IReactiveObject + { + /// + public event PropertyChangingEventHandler? PropertyChanging; + + /// + public event PropertyChangedEventHandler? PropertyChanged; + + /// Gets or sets the test value. + public int Value { get; set; } + + /// + public void RaisePropertyChanging(PropertyChangingEventArgs args) => PropertyChanging?.Invoke(this, args); + + /// + public void RaisePropertyChanged(PropertyChangedEventArgs args) => PropertyChanged?.Invoke(this, args); + } +} diff --git a/src/tests/ReactiveUI.Wpf.Tests/Wpf/ViewContractObservableHelpersTests.cs b/src/tests/ReactiveUI.Wpf.Tests/Wpf/ViewContractObservableHelpersTests.cs new file mode 100644 index 0000000000..2bb4d389c1 --- /dev/null +++ b/src/tests/ReactiveUI.Wpf.Tests/Wpf/ViewContractObservableHelpersTests.cs @@ -0,0 +1,118 @@ +// Copyright (c) 2009-2026 .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 ReactiveUI.Tests.Utilities.Logging; +using Splat; +using TUnit.Core.Executors; +using TUnit.Core.Interfaces; + +namespace ReactiveUI.Tests.Wpf; + +/// Tests for . +public class ViewContractObservableHelpersTests +{ + /// The initial orientation returned by the platform. + private const string Portrait = "portrait"; + + /// The changed orientation emitted by the host. + private const string Landscape = "landscape"; + + /// The runtime size-change signals, including a consecutive duplicate. + private static readonly string?[] RuntimeSignals = [Portrait, Landscape, Landscape]; + + /// The distinct runtime contracts expected from the helper. + private static readonly string?[] ExpectedRuntimeContracts = [Portrait, Landscape]; + + /// Verifies that test mode suppresses platform view-contract signals. + /// A task representing the asynchronous test. + [Test] + public async Task Create_TestMode_ReturnsSilentObservable() + { + var values = ViewContractObservableHelpers.Create(static () => Portrait, Signal.Emit(Landscape)).Collect(); + + await Assert.That(values).IsEmpty(); + } + + /// Verifies that runtime mode starts with the current orientation and removes consecutive duplicates. + /// A task representing the asynchronous test. + [Test] + [TestExecutor] + public async Task Create_RuntimeMode_StartsWithOrientationAndRemovesDuplicates() + { + var values = ViewContractObservableHelpers.Create( + static () => Portrait, + Signal.FromEnumerable(RuntimeSignals)) + .Collect(); + + await Assert.That(values).IsEquivalentTo(ExpectedRuntimeContracts); + } + + /// Verifies that the registered platform orientation callback is returned. + /// A task representing the asynchronous test. + [Test] + [TestExecutor] + public async Task GetPlatformOrientation_RegisteredPlatform_ReturnsPlatformCallback() + { + AppLocator.CurrentMutable.RegisterConstant(new FixedPlatformOperations()); + + var getOrientation = ViewContractObservableHelpers.GetPlatformOrientation(new LoggerHost().Log()); + + await Assert.That(getOrientation()).IsEqualTo(Landscape); + } + + /// Verifies that a missing platform is logged and represented by a null orientation callback. + /// A task representing the asynchronous test. + [Test] + [TestExecutor] + public async Task GetPlatformOrientation_MissingPlatform_LogsErrorAndReturnsNullCallback() + { + var logger = TestContext.Current?.GetTestLogger() + ?? throw new InvalidOperationException("The logging executor did not provide a test logger."); + + var getOrientation = ViewContractObservableHelpers.GetPlatformOrientation(new LoggerHost().Log()); + + using (Assert.Multiple()) + { + await Assert.That(getOrientation()).IsNull(); + await Assert.That(logger.Messages.Exists(static message => message.logLevel == LogLevel.Error)).IsTrue(); + } + } + + /// Runs a test with runtime mode enabled and restores test mode afterward. + public sealed class RuntimeModeTestExecutor : ITestExecutor + { + /// + public async ValueTask ExecuteTest(TestContext context, Func action) + { + ModeDetector.OverrideModeDetector(new FixedModeDetector(false)); + try + { + await action(); + } + finally + { + ModeDetector.OverrideModeDetector(new FixedModeDetector(true)); + } + } + } + + /// Returns a fixed unit-test mode value. + /// The mode value to return. + private sealed class FixedModeDetector(bool isTestMode) : IModeDetector + { + /// + public bool? InUnitTestRunner() => isTestMode; + } + + /// Returns a fixed platform orientation. + private sealed class FixedPlatformOperations : IPlatformOperations + { + /// + public string GetOrientation() => Landscape; + } + + /// Provides a logging category for helper tests. + private sealed class LoggerHost : IEnableLogger; +}