Skip to content

Binding Context

StardewUI's framework API is based on data binding. While the UI state or "widget tree" is retained, you don't interact it with it directly; instead, in order to provide interactivity and/or dynamic content to views, you define a data model, which becomes the context.

Context is simply a tree node which contains the particular model/data for a specific view or position in the hierarchy. It provides access to the model itself, as well as redirects, which are important for building more complex UI. Other frameworks use similar names, such as DataContext in WPF.

Context vs Data

As an example, consider a simple list of selectable items:

classDiagram
direction LR
    class MenuViewModel {
        string Title
        List~MenuItem~ Items
        SelectItem(id)
    }
    class MenuItem {
        string Id
        string Text
        int Price
    }
    MenuViewModel *-- MenuItem
block-beta
columns 2
    A["Joey Jo-Jo Junior Shabadoo's Shop"]:2
    space:2
    B["Bread"] C["500"]
    D["Peanut Butter"] E["1200"]
    F["Strawberry Jam"] G["800"]
<lane layout="500px content" orientation="vertical">
    <label font="dialogue" text={Title} />
    <lane *repeat={Items} click=|^SelectItem(Id)|>
        <label margin="0, 0, 0, 32" text={Text} />
        <label text={Price} />
    </lane>
</lane>

The specific layout or exact appearance in game isn't important to this example; what we're focused on here are the binding attributes and events. Specifically, we have:

  • text={Title}, which is clearly referring to MenuViewModel.Title
  • click=|^SelectItem(Id)|, which refers to the same-named method on MenuViewModel
  • text={Text} and text={Price} which are referring, not to the MenuViewModel anymore, but to properties on the MenuItem.

What happened? Although we only actually provided a single "model" (the MenuViewModel), the context changed as soon as *repeat was encountered. Repeaters replace the original binding context (MenuViewModel) with a context referring to the specific item in the collection (MenuItem). As far as those inner <label> elements are concerned, the "model" or "data" is actually MenuItem.

This behavior isn't necessarily limited to repeaters; for example, another way to narrow or drill down the context would be the *context attribute.

But note the click event in particular: ^SelectItem(Id). The Id is a property on the MenuItem, as we would expect since it is attached to the repeating element; however, SelectItem is referring back to the MenuViewModel. It does this using the ^ redirect, which instructs StardewUI to look at the parent or previous context to find the SelectItem method.

The model or data refers to the specific object attached, or "bound", to any given node; the outer <lane> is bound to a MenuViewModel and each inner <lane> is bound to a MenuItem. The context has an awareness of the overall structure, and is able to backtrack to a previous level at any time.

Source Updates

It is rare for any UI to be completely static, with content that never changes. Most UI needs to respond not only to user input, but to changes in the underlying data or state. More generally, it should always show the most current data, however one chooses to define it.

Broken

public class CounterViewModel
{
    public int Count { get; set; }

    public void Increment()
    {
        Count++;
    }
}
<lane>
    <button click=|Increment()| text="Add One" />
    <label text={Count} />
</lane>

If you run this example, and click the button, you'll see that nothing happens. Our Count does get incremented, but the UI never updates. That is because StardewUI's data binding depends on having an implementation of INotifyPropertyChanged (hereafter: "INPC") in order to detect changes. Without INPC, every binding is a "one-time" binding, meaning it is only read when the view is first created.

Implementing INotifyPropertyChanged

INPC can be extremely tedious to implement for many models and properties, as it requires intercepting every property setter, comparing the values, raising an event, etc. Fortunately, the .NET ecosystem has many tools to automate the process, and using one is strongly recommended over a "manual" implementation.

The recommended options for INPC include:

Library Package Source
PropertyChanged.SourceGenerator NuGet GitHub
PropertyChanged.Fody NuGet GitHub

The author(s) of this guide and of StardewUI have no affiliation with any of the above projects; they are recommended on the basis of:

  • ✅ Leaving no footprint.

    Although they are installed as packages, they do not add any new assemblies to your build output, meaning your INPC-enhanced mod can continue to be a single DLL;

  • ✅ Handling dependent properties automatically.

    For example, public string FullName => $"{FirstName} {LastName}" will raise notifications for changes to either FirstName or LastName.

  • ✅ Being free and open source, with a permissive license (MIT, Apache, etc.).1

    In other words, their license imposes no requirements on your license, your ability to opt into Nexus Donation Points, etc.

Using the first library (PropertyChanged.SourceGenerator), we can quickly convert the non-working example above to one that does work:

Success

using PropertyChanged.SourceGenerator;

public partial class CounterViewModel
{
    [Notify] private int count;

    public void Increment()
    {
        Count++;
    }
}
<lane>
    <button click=|Increment()| text="Add One" />
    <label text={Count} />
</lane>

Note that the markup has not changed at all. All we had to do for the C# code was:

  • Add the partial keyword to the CounterViewModel class (required for code generation)
  • Change the Count auto-property to be a field
  • Make it lowerCamelCase, so it doesn't conflict with the auto-generated property
  • Add a [Notify] attribute.

You don't need to [Notify] every single property, only the ones that might change. The mixed approach is demonstrated in several examples.

Collection Updates

A special case of updates is collections. Consider the case of a UI that adds items to a list:

Broken

public partial class TodoViewModel
{
    public List<string> Items { get; } = [];

    [Notify] private string currentItem = "";

    public void AddCurrentItem()
    {
        if (!string.IsNullOrWhitespace(CurrentItem))
        {
            Items.Add(CurrentItem);
            CurrentItem = "";
        }
    }
}
<lane layout="500px content" orientation="vertical">
    <lane>
        <textinput text={CurrentItem} />
        <button text="Add" click=|AddCurrentItem()| />
    </lane>
    <label *repeat={Items} text={this} />
</lane>

If you run this, you'll observe that the text box is cleared when clicking "Add", but the item does not appear to be added to the list. Moreover, converting Items to a [Notify] will not help in this case, because the field itself has not changed. Items always points to the same list.

Before explaining the solution, it is worth noting the workarounds that should not be used, even if they appear to be effective at first:

  • ❌ Converting the Items list to [Notify] (or equivalent INPC) and replacing the entire list with a new list, e.g. Items = Items.Append(CurrentItem).ToList().

    This has quadratic or Schlemiel the Painter performance, in both time and memory.

  • ❌ Leaving the list alone, but calling an OnPropertyChanged or PropertyChanged?.Invoke after adding an item, to force an INPC change notification.

    While not as serious an offense as the previous version, it still forces StardewUI to rebuild the entire view tree for the *repeat. Even if only one item changed, it must recreate the views for all of them.

All performance is relative, and these workarounds might be perfectly acceptable for our toy example above. However, if there are hundreds of items in the list, and each item has many views within—for example, an image, quality icon, quantity text, etc.—then making this type of change very often is still likely to cause jank.

StardewUI has a better solution: INotifyCollectionChanged, which is the collection-based counterpart to INPC. While "INCC" is also difficult to implement, you don't have to, because there is already an ObservableCollection type to do it for you.

Using ObservableCollection, the revised and working code then becomes:

Success

public partial class TodoViewModel
{
    public ObservableCollection<string> Items { get; } = [];

    [Notify] private string currentItem = "";

    public void AddCurrentItem()
    {
        if (!string.IsNullOrWhitespace(CurrentItem))
        {
            Items.Add(CurrentItem);
            CurrentItem = "";
        }
    }
}
<lane layout="500px content" orientation="vertical">
    <lane>
        <textinput text={CurrentItem} />
        <button text="Add" click=|AddCurrentItem()| />
    </lane>
    <label *repeat={Items} text={this} />
</lane>

We changed only one line here: List<string> became ObservableCollection<string>.

Redirects

Any binding that references context data, including properties or event handlers or arguments, can use a redirect operator.

The types of redirects are:

Name Syntax Example Behavior
Parent ^ ^Prop Goes back to the previous, or "parent" context.
Can be repeated.
Ancestor ~Type ~Foo.Prop Backtracks to the nearest ancestor of the specified type.
Cannot be repeated.

The following example will walk through the different redirects in more detail.

class InventoryViewModel
{
    public string OwnerName { get; set; }
    public PageViewModel ActivePage { get; set; }
}

class PageViewModel
{
    public string Category { get; set; }
    public List<ItemViewModel> Items { get; set; }
}

record ItemViewModel(string Name);

void ShowMenu()
{
    var context = new InventoryViewModel()
    {
        OwnerName = "Timmy",
        ActivePage = new()
        {
            Category = "Tools",
            Items = [new("Axe"), new("Hoe")]
        },
    };
    Game1.activeClickableMenu =
        viewEngine.CreateMenuFromAsset("Mods/Xyz/Views/Inventory", context);
}
<lane orientation="vertical" *context={ActivePage}>
    <label text={^OwnerName} />
    <label text={~InventoryViewModel.OwnerName} />
    <lane *repeat={Items}>
        <label text={Name} />
        <label text={^Category} />
        <label text={~PageViewModel.Category} />
        <label text={^^OwnerName} />
        <label text={~InventoryViewModel.OwnerName} />
    </lane>
</lane>
block-beta
columns 5
    A["Timmy"]:5
    B["Timmy"]:5
    C1["Axe"]
    C2["Tools"]
    C3["Tools"]
    C4["Timmy"]
    C5["Timmy"]
    D1["Hoe"]
    D2["Tools"]
    D3["Tools"]
    D4["Timmy"]
    D5["Timmy"]

The above is not intended to represent any kind of realistic UI scenario, only to demonstrate what the different redirects do. It is important to realize that redirects do not navigate the actual model data; there is nothing in StardewUI, or anywhere else, that knows how to get from a PageViewModel back to its "parent" InventoryViewModel, and such a relationship may not exist or be meaningful at all.

Instead, we assign a context to each node:

Node/Element Has Context
<lane orientation​=​"vertical"...> InventoryViewModel ("Timmy")
<label text={^OwnerName} /> PageViewModel ("Tools")
<label text={~PageViewModel.OwnerName} />
<lane *repeat={Items}>
[1] <label text={Name} /> ItemViewModel ("Axe")
[1] <label text={^Category} />
[1] <label text={~PageViewModel.Category} />
[1] <label text={^^OwnerName} />
[1] <label text={~InventoryViewModel.OwnerName} />
[2] <label text={Name} /> ItemViewModel ("Hoe")
[2] <label text={^Category} />
[2] <label text={~PageViewModel.Category} />
[2] <label text={^^OwnerName} />
[2] <label text={~InventoryViewModel.OwnerName} />

To resolve the ^^OwnerName near the end of the above table:

  1. Walk up the parent elements until we find one that changed the context. Since *repeat has a context effect, the first parent is the <lane *repeat={Items}> element.
  2. Repeat the process from that position, since there is a second ^. Since the <lane orientation="vertical"> element has a *context modifier, it is chosen next.
  3. Take the context data linked to the element we ended up at; the root <lane> is associated with the InventoryViewModel.
  4. Read the property from the data we just found, i.e. InventoryViewModel.OwnerName.

Resolving ~InventoryViewModel.OwnerName follows a very similar process, but instead of going "up" a specific number of times (twice, for ^^, or once, for a single ^), it repeats the traversal step as many times as necessary until it reaches an element that has an InventoryViewModel as its context.

Summary

The most important lesson to take away from this is that context redirects follow the document structure, not the data (model/view-model) structure.

In an MV* design, document structure is itself usually based on the model, so you can often treat redirects as going to the "parent object", but may eventually run into scenarios where this doesn't work as expected.

Remember that context lives outside your data.

Update Ticks

As a convenience, StardewUI can dispatch update ticks to any objects bound as context so that you do not need to "drive" them from ModEntry, i.e. using SMAPI's UpdateTicked event.

Importantly, these updates can be at any arbitrary nesting level, as long as they are reachable by some view/node; they do not have to be at the top level. This can be useful for running animations, synchronizing with external game state or netfields, or for any other purpose that requires frame-precision updates.

To opt in, simply add a void Update method to any context type with either no parameters or a single TimeSpan parameter.

Example

public class OuterModel
{
    public StopwatchModel Stopwatch { get; set; }
}

public class InnerModel
{
    public string FormattedTime => Elapsed.ToString(@"mm\:ss\.fff");

    [Notify] private TimeSpan elapsed;

    public void Update(TimeSpan elapsed)
    {
        Elapsed += elapsed;
    }
}
<frame *context={Stopwatch}>
    <label text={FormattedTime} />
</frame>

The Update method must match the signature above (with or without the TimeSpan argument); any other signature will cause a warning to be logged and the method to be ignored.


  1. Fody is "legally free, morally paid", but the advantages of this clearly show in its maintenance statistics; PropertyChanged is over 12 years old, still being updated, and has the most frictionless syntax, in addition to the Fody master project being a much larger collection of also useful tools.