Elmish Stores + Custom Bindings + Avalonia Static Views
ReactiveElmishViewModel
base class to bind data from your Elmish Stores to your Views.This example shows using an Elmish Store to manage local view state:
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:
DesignInstance
VM for each view that utilizes the Elmish init
function. DictionaryViewModel
brought over from Elmish.WPF was not able to take advantage of this because it relied on reflection-based bindings.)ReactiveElmishViewModel
class inherits from ReactiveObject
.) Since this is the default view model library for Avalonia, this makes it easier to take advantage of existing patterns when needed.DynamicData
which provides a simple way to bind lists between the Elmish model and the view / view model. (DynamicData lists properly handle properly refreshing the view when adding and removing items from a bound list.)Microsoft.Extensions.DependencyInjection
.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.
ElmishStore
.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
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()
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)
Opening the ReactiveElmish.Avalonia
, and Elmish
namespaces adds the following extensions to Program
:
Creates a store via Program.mkProgram (init
and update
functions return a Model * Cmd
tuple).
let store =
Program.mkAvaloniaProgram init update
|> Program.mkStore
Creates an Avalonia program via Program.mkSimple. (init
and update
functions return a Model
).
let store =
Program.mkAvaloniaSimple init update
|> Program.mkStore
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
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
.
Terminate
'Msg
that will be registered to trigger loop termination.mkStoreWithTerminate
only works with "Transient" views (see View Registration section). "Singleton" views do not call Dispose
on view models on view Unloaded
, so Terminate will be ignored. For Singleton views, you are responsible for manually enabling/disabling any Elmish subscriptions.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
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)
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.
BindList
and BindKeyedList
were added).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.
The composition root is where you register your views/vms as well as any injected services.
RegisterServices
allows you to specify dependencies that can be injected into other view model and service constructors. View models are automatically injected on app load.RegisterViews
allows you to pair up your views and view models and assign them a lifetime. Transient
- view/VM will both be recreated every time GetView
is called; VM will be disposed on view Unloaded
, along with any Elmish subscriptions if Program.mkStoreWithTerminate
is configured.Singleton
- view/VM will both be created only once and then reused on subsequent calls to GetView
. The VM is not Disposed on view Unloaded
. Program.mkStoreWithTerminate
will be ignored in Singleton views.)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>()
]
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()
The included sample app shows a obligatory Elmish counter app, and also the Avalonia DataGrid control. Please view the AvaloniaExample project.
Features:
The ReactiveElmishStore
allows you to create a store from an Elmish loop that can be bound in a C# or F# view model.
The ReactiveStore
is a simplified (non-Elmish) store that you can create in C# (if you don't want to use F# or Elmish for you model).
The Rx bindings can be easily added to any ViewModelBase
class (see Rx
property in view model example below) by initializing an instance of ReactiveBindingsCS
ReactiveObject
, initialize like this:
new ReactiveBindingsCS(this.RaisePropertyChanged);
OnPropertyChanged
method:
new ReactiveBindingsCS(this.OnPropertyChanged);
Here is a sample CounterViewModel
in C#: