JordanMarr / ReactiveElmish.Avalonia

Static Avalonia views for Elmish programs
Other
92 stars 8 forks source link

ReactiveElmish.Avalonia NuGet version (ReactiveElmish.Avalonia)

Elmish Stores + Custom Bindings + Avalonia Static Views

Elmish Stores + Custom bindings

This example shows using an Elmish Store to manage local view state: image

Avalonia Static Views

Create views using Avalonia xaml.

Install the Avalonia for Visual Studio 2022 extension for a design preview panel. JetBrains Rider also supports Avalonia previews out-of-the-box! https://docs.avaloniaui.net/docs/getting-started/ide-support

This screenshot shows the Avalonia design preview in Visual Studio: image

Benefits

Feature Highlights

Elmish Stores

ReactiveElmish.Avalonia introduces the ElmishStore which is an Rx powered Elmish loop that can be used to power one or more view models. This provides flexibility for how you want to configure your viewmodels.

App Store

A global app store can be shared between view models to, for example, provide view routing:

module App

open System
open Elmish
open ReactiveElmish.Avalonia
open ReactiveElmish

type Model =  
    { 
        View: View
    }

and View = 
    | CounterView
    | ChartView
    | AboutView
    | FilePickerView

type Msg = 
    | SetView of View
    | GoHome

let init () = 
    { 
        View = CounterView
    }

let update (msg: Msg) (model: Model) = 
    match msg with
    | SetView view -> { View = view }   
    | GoHome -> { View = CounterView }

let app = 
    Program.mkAvaloniaSimple init update
    |> Program.withErrorHandler (fun (_, ex) -> printfn $"Error: {ex.Message}")
    |> Program.withConsoleTrace
    |> Program.mkStore

Accessing the Global App Store

In this example, a simple AboutViewModel can access the global App store to dispatch a custom navigation message when the Ok button is clicked:

namespace AvaloniaExample.ViewModels

open ReactiveElmish
open App

type AboutViewModel() =
    inherit ReactiveElmishViewModel()

    member this.Version = "v1.0"
    member this.Ok() = app.Dispatch GoHome

    static member DesignVM = new AboutViewModel()

View Model with its own local Store

In this example, a view model has its own local store, and it also accesses the global App store:

namespace AvaloniaExample.ViewModels

open ReactiveElmish.Avalonia
open ReactiveElmish
open Elmish
open AvaloniaExample

module FilePicker = 

    type Model = 
        {
            FilePath: string option
        }

    type Msg = 
        | SetFilePath of string option

    let init () = 
        { 
            FilePath = None
        }

    let update (msg: Msg) (model: Model) = 
        match msg with
        | SetFilePath path ->
            { FilePath = path }

open FilePicker

type FilePickerViewModel(fileSvc: FileService) =
    inherit ReactiveElmishViewModel()

    let app = App.app

    let local = 
        Program.mkAvaloniaSimple init update
        |> Program.mkStore

    member this.FilePath = this.Bind(local, _.FilePath >> Option.defaultValue "Not Set")
    member this.Ok() = app.Dispatch App.GoHome
    member this.PickFile() = 
        task {
            let! path = fileSvc.TryPickFile()
            local.Dispatch(SetFilePath path)
        }

    static member DesignVM = new FilePickerViewModel(Design.stub)

Creating an Elmish Store

Opening the ReactiveElmish.Avalonia, and Elmish namespaces adds the following extensions to Program:

Program.mkAvaloniaProgram

Creates a store via Program.mkProgram (init and update functions return a Model * Cmd tuple).

let store = 
   Program.mkAvaloniaProgram init update
   |> Program.mkStore

Program.mkAvaloniaSimple

Creates an Avalonia program via Program.mkSimple. (init and update functions return a Model).

let store = 
   Program.mkAvaloniaSimple init update
   |> Program.mkStore

Program.withSubscription

Creates one or more Elmish subscriptions that can dispatch messages and be enabled/disabled based on the model.

let subscriptions (model: Model) : Sub<Msg> =
   let autoUpdateSub (dispatch: Msg -> unit) = 
      Observable
          .Interval(TimeSpan.FromSeconds(1))
          .Subscribe(fun _ -> 
              dispatch AddItem
          )

   [
      if model.IsAutoUpdateChecked then
         [ nameof autoUpdateSub ], autoUpdateSub
   ]
let store = 
   Program.mkAvaloniaSimple init update
   |> Program.withSubscription subscriptions
   |> Program.mkStore

Program.mkStoreWithTerminate

Creates a store that configures Program.withTermination using the given terminate 'Msg, and fires the terminate 'Msg when the view is Unloaded. This pattern will dispose your subscriptions when the view is Unloaded.

let update (msg: Msg) (model: Model) =
    // ...
    | Terminate -> model // This is just a stub Msg that needs to exist -- it doesn't need to do anything.
    let local = 
        Program.mkAvaloniaSimple init update
        |> Program.withErrorHandler (fun (_, ex) -> printfn $"Error: {ex.Message}")
        |> Program.mkStoreWithTerminate this Terminate 

ReactiveElmishViewModel Bindings

The ReactiveElmishViewModel base class contains binding methods that are used to bind data between your Elmish model and your view model. All binding methods on the ReactiveElmishViewModel are disposed when the view model is diposed.

Bind

The Bind method binds data from an IStore to a property on your view model. This can be a simple model property or a projection based on the model.

type CounterViewModel() =
    inherit ReactiveElmishViewModel()

    let local = 
        Program.mkAvaloniaSimple init update
        |> Program.mkStore

    member this.Count = this.Bind(local, _.Count)
    member this.IsResetEnabled = this.Bind(local, fun m -> m.Count <> 0)

BindOnChanged

The BindOnChanged method binds a VM property to a modelProjection value and refreshes the VM property when the onChanged value changes. The modelProjection function will only be called when the onChanged value changes. onChanged usually returns a property value or a tuple of property values.

This was added to avoid evaluating an expensive model projection more than once. For example, when evaluating the current ContentView property on the MainViewModel. Using Bind in this case would execute the modelProjection twice: once to determine if the value had changed, and then again to bind to the property. Using BindOnChanged will simply check to see if the _.View property changed on the model instead of evaluating the modelProjection twice, thereby creating the current view twice.

namespace AvaloniaExample.ViewModels

open ReactiveElmish.Avalonia
open ReactiveElmish
open App

type MainViewModel(root: CompositionRoot) =
    inherit ReactiveElmishViewModel()

    member this.ContentView = 
        this.BindOnChanged (app, _.View, fun m -> 
            match m.View with
            | CounterView -> root.GetView<CounterViewModel>()
            | AboutView -> root.GetView<AboutViewModel>()
            | ChartView -> root.GetView<ChartViewModel>()
            | FilePickerView -> root.GetView<FilePickerViewModel>()
        )

    member this.ShowChart() = app.Dispatch(SetView ChartView)
    member this.ShowCounter() = app.Dispatch(SetView CounterView)
    member this.ShowAbout() = app.Dispatch(SetView AboutView)
    member this.ShowFilePicker() = app.Dispatch(SetView FilePickerView)

    static member DesignVM = new MainViewModel(Design.stub)

BindList

BindList binds a collection type on the model to a DynamicData.SourceList behind the scenes. Changes to the collection in the model are diffed and updated for you in the SourceList. BindList also has an optional map parameter that allows you to transform items when they are added to the SourceList.

module Counter = 
    type Model =  { Count: int; Actions: Action list }
    // ...
type CounterViewModel() =
    inherit ReactiveElmishViewModel()

    let local = 
        Program.mkAvaloniaSimple init update
        |> Program.mkStore

    member this.Count = this.Bind(local, _.Count)
    member this.Actions = this.BindList(local, _.Actions, map = fun a -> { a with Description = $"** {a.Description} **" })

BindKeyedList

Binds a Map<'Key, 'Value> "keyed list" to an ObservableCollection behind the scenes. Changes to the Map in the model are diffed based on the provided getKey function that returns the 'Key for each item.

Also has an optional update parameter that allows you to provide a function to update the keyed item when a change is detected. Note that using the update parameter will cause every item in the list to be diffed for changes which will be more expensive. You can generally avoid having to use the update parameter by storing state changes on your mapped item (assuming you have mapped it to a view model that store its own state).

Use BindKeyedList when you want to store a list of items that can be identified by one or more identifying keys.

module TodoApp = 
    type Model = { Todos: Map<Guid, Todo> }
    and Todo = { Id: Guid; Description: string; Completed: bool }
    /// ...
type TodoListViewModel() =
    inherit ReactiveElmishViewModel()

    let store = 
        Program.mkAvaloniaProgram init update
        |> Program.mkStore

    member this.Todos = 
        this.BindKeyedList(store, _.Todos
            , map = fun todo -> new TodoViewModel(store, todo)
            , getKey = fun todoVM -> todoVM.Id
            //, update = fun todo todoVM -> todoVM.Update(todo)     // Optional
            //, sortBy = fun todo -> todo.Completed                 // Optional
        )

BindSourceList

The BindSourceList method binds a DynamicData SourceList property on the Model to a view model property. This provides list Add and Removed notifications to the view. There is also a SourceList helper module that makes it a little nicer to work with by allowing you to mutate the collection inline.

    let update (msg: Msg) (model: Model) = 
        match msg with
        | Increment ->
            { 
                Count = model.Count + 1 
                Actions = model.Actions |> SourceList.add { Description = "Incremented"; Timestamp = DateTime.Now }
            }
        | Decrement ->
            { 
                Count = model.Count - 1 
                Actions = model.Actions |> SourceList.add { Description = "Decremented"; Timestamp = DateTime.Now }
            }
        | Reset ->
            {
                Count = 0 
                Actions = model.Actions |> SourceList.clear |> SourceList.add { Description = "Reset"; Timestamp = DateTime.Now }
            }
type CounterViewModel() =
    inherit ReactiveElmishViewModel()

    let local = 
        Program.mkAvaloniaSimple init update
        |> Program.mkStore

    member this.Actions = this.BindSourceList(local.Model.Actions)

BindSourceCache

The BindSourceCache method binds a DynamicData SourceCache property on the Model to a view model property. This provides list Add and Removed notifications to the view for lists with items that have unique keys. There is also a SourceCache helper module that makes it a little nicer to work with by allowing you to mutate the collection inline.

    type Model =
        {
            FileQueue: SourceCache<File, string>
        }

    let init () =
        {
            FileQueue = SourceCache.create _.FullName
        }

    let update message model =
        | QueueFile path ->
            let file = mkFile path
            { model with FileQueue = model.FileQueue |> SourceCache.addOrUpdate file }
        | UpdateFileStatus (file, progress, moveFileStatus) ->
            let updatedFile = { file with Progress = progress; Status = moveFileStatus }
            { model with FileQueue = model.FileQueue |> SourceCache.addOrUpdate updatedFile }
        | RemoveFile file ->
            { model with FileQueue = model.FileQueue |> SourceCache.removeKey file.FullName}
type MainWindowViewModel() as this =
    inherit ReactiveElmishViewModel()

    member this.FileQueue = this.BindSourceCache(store.Model.FileQueue)

Tips for Binding Collections

When binding a collection from your model to the view, special binding events must be raised to notify the view when an item has been added, removed or edited. These events make it possible to incrementally update a list without having to replace (and refresh) the entire list in the view everytime the contents of the list change. Examples of collection types that utilize these events are ObservableCollection, DynamicData.SourceList and DynamicData.SourceCache.

This library gives you a multiple options for binding lists.

BindList and BindKeyedList

These methods allow you to use regular F# collections like list and Map in your model. These bindings will diff your collection for changes and then update the backing MVVM collection class (SourceList or ObservableCollection) for you.

BindSourceList and BindSourceCache

These methods allow you to use the DynamicData MVVM collections directly in your model.

Personally, I would recommend using regular F# collections with BindList and BindKeyedList by default and only switching to BindSourceList and BindSourceCache if performance becomes an issue for a given form.

Composition Root

The composition root is where you register your views/vms as well as any injected services.

namespace AvaloniaExample

open ReactiveElmish.Avalonia
open Microsoft.Extensions.DependencyInjection
open AvaloniaExample.ViewModels
open AvaloniaExample.Views

type AppCompositionRoot() =
    inherit CompositionRoot()

    let mainView = MainView()

    override this.RegisterServices services = 
        base.RegisterServices(services)                        // Auto-registers view models
            .AddSingleton<FileService>(FileService(mainView))  // Add any additional services

    override this.RegisterViews() = 
        Map [
            VM.Key<MainViewModel>(), View.Singleton(mainView)
            VM.Key<CounterViewModel>(), View.Singleton<CounterView>()
            VM.Key<AboutViewModel>(), View.Singleton<AboutView>()
            VM.Key<ChartViewModel>(), View.Singleton<ChartView>()
            VM.Key<FilePickerViewModel>(), View.Singleton<FilePickerView>()
        ]

Project Setup

Steps to create a new project:

1) Create a new project using the Avalonia .NET MVVM App Template for F#. 2) Install the ReactiveElmish.Avalonia package from NuGet. 3) Create an AppCompositionRoot (see the Composition Root section above) that inherits from CompositionRoot to define your view/VM pairs (required) and any DI services (optional). 4) Launch the startup window using your CompositionRoot class in the App.axaml.fs

Refer to the AvaloniaExample project in the Samples directory as a reference.

namespace AvaloniaExample

open Avalonia
open Avalonia.Controls
open Avalonia.Markup.Xaml
open Avalonia.Controls.ApplicationLifetimes

type App() =
    inherit Application()

    override this.Initialize() =
        // Initialize Avalonia controls from NuGet packages:
        let _ = typeof<Avalonia.Controls.DataGrid>

        AvaloniaXamlLoader.Load(this)

    override this.OnFrameworkInitializationCompleted() =
        match this.ApplicationLifetime with
        | :? IClassicDesktopStyleApplicationLifetime as desktop ->         
            let appRoot = AppCompositionRoot()
            desktop.MainWindow <- appRoot.GetView<ViewModels.MainViewModel>() :?> Window
        | _ -> 
            // leave this here for design view re-renders
            ()

        base.OnFrameworkInitializationCompleted()

Sample Project

The included sample app shows a obligatory Elmish counter app, and also the Avalonia DataGrid control. Please view the AvaloniaExample project.

ReactiveElmish.Wpf NuGet version (ReactiveElmish.Wpf)

Features:

Here is a sample CounterViewModel in C#: image