How to use WhenActivated with properties in avalonia
Asked Answered
P

1

6

I am trying to use ReactiveUI along with Avalonia. Due to initialization order in Avalonia 0.10 preview following code fails:

class ViewModel : IActivatableViewModel
{
    public ViewModel(){
        this.WhenActivated(disposables => {
            _myProperty = observable.ToProperty(this, nameof(MyProperty)).DisposeWith(disposables).
        });
    }

    private ObservableAsPropertyHelper<object> _myProperty = null!;
    public object MyProperty => _myProperty.Value;
}

Because WhenActivated is called after view binds to viewModel (hence _myProperty is null).

I see no easy workaround requiring lots of hacks, manually raising properties and so on.

So the question is:

How to work with OAPH and WhenActivated in Avalonia?

Perfidious answered 4/12, 2020 at 16:5 Comment(0)
L
7

Option #1

The most obvious pattern that allows you to resolve the issue is to use the null coalescing operator. By using this operator, you can achieve the desired behavior by adjusting the code to look somewhat like this:

private ObservableAsPropertyHelper<TValue>? _myProperty;
public TValue MyProperty => _myProperty?.Value;

Here, we are marking the declared field as nullable explicitly, using the new C# nullable annotations. We are doing this because until the WhenActivated block is called, the _myProperty field is set to null. Also, we use the _myProperty?.Value syntax here, as MyProperty getter should return null when the view model isn't initialized.

Option #2

Another option which is definitely better is to move the ToProperty subscription outside the WhenActivated block and to mark the ObservableAsPropertyHelper<T> field as readonly. If your computed property doesn't subscribe to external services that outlive the view model, then you don't need to dispose of the subscription returned by ToProperty. In 90% cases you don't need to keep ToProperty calls inside WhenActivated. See the When should I bother disposing of IDisposable objects? documentation page for more info. See also the Hot and Cold observables article that could also shed some light on this topic. So writing code like this is a good way to go in 90% cases:

private readonly ObservableAsPropertyHelper<TValue> _myProperty;
public TValue MyProperty => _myProperty.Value;

// In the view model constructor:
_myProperty = obs.ToProperty(this, x => x.MyProperty);

If you are actually subscribing to external services e.g. injected into the view model via the constructor, then you could convert MyProperty into a read-write property with a private setter, and write the following code:

class ViewModel : IActivatableViewModel
{
    public ViewModel(IDependency dependency)
    {
        this.WhenActivated(disposables =>
        {
            // We are using 'DisposeWith' here as we are
            // subscribing to an external dependency that
            // could potentially outlive the view model. So
            // we need to dispose the subscription in order
            // to avoid the potential for a memory leak. 
            dependency
                .ExternalHotObservable
                .Subscribe(value => MyProperty = value)
                .DisposeWith(disposables);
        });
    }

    private TValue _myProperty;
    public TValue MyProperty 
    {
        get => _myProperty;
        private set => this.RaiseAndSetIfChanged(ref _myProperty, value);
    }
}

Also, take a look at ReactiveUI.Fody if RaiseAndSetIfChanged syntax feels too verbose to you.

Option #3 (I'd recommend this option)

Worth noting that Avalonia supports binding to Tasks and Observables. This is a very useful feature that I'd highly recommend you trying out. This means, that in Avalonia you could simply declare a computed property as IObservable<TValue> and Avalonia will manage the lifetimes of subscriptions for you. So in the view model do this:

class ViewModel : IActivatableViewModel
{
    public ViewModel()
    {
        MyProperty =
          this.WhenAnyValue(x => x.AnotherProperty)
              .Select(value => $"Hello, {value}!");
    }

    public IObservable<TValue> MyProperty { get; }
    
    // lines omitted for brevity
}

And in the view, write the following code:

<TextBlock Text="{Binding MyProperty^}"/>

OAPHs were invented for platforms that can't do such tricks, but Avalonia is pretty good at clever markup extensions. So if you are targeting multiple UI frameworks and writing framework-agnostic view models, then OAPHs are good to go. But if you are targeting Avalonia only, then just use {Binding ^}.

Option #4

Or, if you prefer using code-behind ReactiveUI bindings, combine the view model code from option 3 with the following code-behind on the view side in the xaml.cs file:

this.WhenActivated(cleanup => {
    this.WhenAnyObservable(x => x.ViewModel.MyProperty)
        .BindTo(this, x => x.NamedTextBox.Text)
        .DisposeWith(cleanup);
});

Here we assume that the xaml file looks like this:

<TextBlock x:Name="NamedTextBox" />

We now have a source generator that could potentially help in generating x:Name references btw.

Litho answered 4/12, 2020 at 18:42 Comment(6)
Thanks for detailed answer! Option #1 is awful, defeats whole purpose of doing fun stuff. Option #2 is a bit useless, as it's all about external dependences. Option #3 is awesome, but why I have to use ^? It looks weird.Perfidious
The ^ sign is just an Avalonia markup extension syntax that allows binding controls to tasks and observables. It allows us to tell Avalonia that the value we are binding to is a Task or a IObservable, and that the bindings should behave differently and track subscriptions avaloniaui.net/docs/binding/binding-to-tasks-and-observables They call it a "stream binding operator"Litho
Ye, but couldn't this be default behavior? If something is observable it should be automatically subscribed, this ^ maybe could be used if there was need to pass raw observable to view.Perfidious
I guess worth opening an issue in Avalonia repository github.com/avaloniaui/avalonia or starting a discussion in their Gitter chat gitter.im/AvaloniaUI/Avalonia if you wish to learn more regarding this topic. Probably they prefer making bindings to tasks and observables more obvious and strict, or that works like this due to some other limitations of Avalonia internals. I heard the core team is planning to support compiling the bindings btw.Litho
I am using compiled bindings already. You just add x:CompileBindings=true, and x:DataType={local:MyViewModel} and all bindings in this file are compiled. This is awesome.Perfidious
The avalonia+rxui learning curve is a cliff. Such a relief to find this answer.Washery

© 2022 - 2024 — McMap. All rights reserved.