Logo MVUish.Avalonia

Collections

Assigning a new list to ItemsSource on every model change rebuilds every item container: selection, scroll position, and any state a row holds are lost. The MVUish.Avalonia collection bindings instead keep one owned ObservableCollection as ItemsSource and apply each snapshot from the model as minimal deltas, so containers are reused.

Both bindings assign ItemsSource once and return the control, so they chain in NXUI-style builders like the rest of the library. There is no disposable to manage: a binding keeps applying snapshots while its control is alive — including while detached from the visual tree — and ends itself on the first emission after the control is collected.

Keyed binding (preferred)

Match items by a stable key. Items whose key disappears are removed, new keys are inserted, a surviving key with a changed item raises a Replace, and reorders raise Move events.

open NXUI.Extensions
// Open the MVU namespaces after NXUI: F# extension shadowing must pick
// these bindings over the NXUI property binder of the same name.
open MVUish.Avalonia
open MVUish.Avalonia.Collections

type Note = { Id: Guid; Text: string }

let view (model: IObservable<Model>) (dispatch: Dispatch<Msg>) =
  let notes = model |> Observable.map (fun m -> m.Notes)

  // The list-typed overload accepts IObservable<Note list> directly and
  // returns the control, so it chains with the NXUI builders.
  ListBox().MinHeight(160.0).BindItemsSource(notes, (fun n -> n.Id))
using MVUish.Avalonia.Collections;

// Returns the list box, so the call chains in NXUI-style builders.
var listBox = CollectionExtensions.BindItemsSource(
    new ListBox { MinHeight = 160 }, model.Select(m => m.Notes), n => n.Id);

A snapshot that repeats a key throws ArgumentException. Inside a Program view the throw surfaces through the program's OnError handler instead of crashing the app.

Virtualization and row state

Rule: any list that virtualizes and needs to keep row state must use a template that supports recycling. Pass supportsRecycling: true to FuncDataTemplate<...>, keep the state on the item (the model), and rehydrate the row controls from the live DataContext. A template that refuses recycling (supportsRecycling: false) is only safe when the list is guaranteed not to scroll, for example a short list with a non-virtualizing ItemsPanel.

"Containers are reused" holds for containers that are realized. ListBox and friends virtualize by default: only the rows near the viewport are realized, and Avalonia recycles containers as the list scrolls. Two consequences:

The recyclable shape (code-built rows):

let rowTemplate =
  FuncDataTemplate<Note>(
    (fun (note: Note) (_: INameScope) ->
      let checkBox = CheckBox(IsChecked = note.Checked)
      let textBox = TextBox(Text = note.RowText)

      // Handlers dispatch against the live DataContext, never a captured item.
      checkBox.IsCheckedChanged.Add(fun _ ->
        match checkBox.DataContext with
        | :? Note as current when checkBox.IsChecked.GetValueOrDefault false <> current.Checked ->
          dispatch (SetChecked(current.Id, checkBox.IsChecked.GetValueOrDefault false))
        | _ -> ())

      // Commit text on focus loss. Dispatching per keystroke races the async
      // model update: the row is rebuilt with the previous model value and the
      // character being typed is overwritten.
      textBox.LostFocus.Add(fun _ ->
        match textBox.DataContext with
        | :? Note as current when textBox.Text <> current.RowText ->
          dispatch (SetRowText(current.Id, textBox.Text))
        | _ -> ())

      let row = StackPanel(Orientation = Orientation.Horizontal)
      row.Children.Add checkBox
      row.Children.Add textBox

      // Rehydrate when the container is recycled for another item.
      row.DataContextChanged.Add(fun _ ->
        match row.DataContext with
        | :? Note as current ->
          checkBox.IsChecked <- current.Checked
          textBox.Text <- current.RowText
        | _ -> ())
      row :> Control),
    true)

Two interaction notes:

The dashboard sample implements this shape in both languages: the template opts into recycling, the row syncs from DataContextChanged, and handlers read the live DataContext.

NXUI ships its own BindItemsSource extension that binds the raw ItemsSource property. In F#, open MVUish.Avalonia.Collections after NXUI.Extensions so shadowing picks this adapter, as shown above. In C#, call CollectionExtensions statically as shown above.

Structural binding

BindItemsSourceStructural (Extensions.BindItemsSourceStructural in F#, CollectionExtensions.BindItemsSource without a key in C#) diffs by structural equality instead of keys. Repeated values are allowed. Prefer the keyed binding: it also matches moved items.

Whole-list fallback

For short lists where none of the row state matters, BindStream on ItemsControl.ItemsSourceProperty still works: every emission reassigns the whole list and Avalonia resets the control. That is the cheapest option for a handful of items.

Threading

Both bindings apply changes on the thread that emits the snapshot. Program dispatches on the UI thread, so model streams need nothing extra. Sources that emit from background threads must hop first, for example with Observable.observeOn on Avalonia.Threading.Dispatcher.UIThread.

type Note = { Id: obj Text: string }
Multiple items
val string: value: 'T -> string

--------------------
type string = System.String
val view: model: System.IObservable<'a> -> dispatch: 'b -> 'c
val model: System.IObservable<'a>
val dispatch: 'b
val notes: System.IObservable<obj>
module Observable from Microsoft.FSharp.Control
val map: mapping: ('T -> 'U) -> source: System.IObservable<'T> -> System.IObservable<'U>
val m: 'a
val rowTemplate: obj
namespace Microsoft.FSharp.Control

Type something to start searching.