Views and view-models
A view is a screen: a tree of layouts and controls. A view-model is the C# class that holds the screen's state and commands. The view binds to the view-model by property name, so the layout can change without the logic changing with it.
A view is written in XML, in a .view.xml file, or built in C#. Both give the same screen.
dotnet add package BSGameLib.UI --prerelease
Set it up
Register the UI in Configure, and show the first view in Initialize:
using BSGameLib.Game;
using BSGameLib.UI;
using Microsoft.Extensions.DependencyInjection;
public class MyGame : Game
{
protected override void Configure(IServiceCollection services) => services.AddUI();
protected override void Initialize()
{
GetService<IViewManager>().SetView<MenuViewModel>();
base.Initialize();
}
}
AddUI() registers every concrete ViewModel in the loaded assemblies as a singleton, except
those in the system and Microsoft assemblies. The game therefore has one instance of each
view-model for its whole life, and a view-model's constructor can ask for any registered service.
A view-model
A view-model derives from ViewModel. It takes IViewManager
in its constructor, passes it on, and builds its view in BuildView:
using BSGameLib.UI;
using BSGameLib.UI.Builder;
public class MenuViewModel : ViewModel
{
private string _playerName = string.Empty;
public MenuViewModel(IViewManager viewManager) : base(viewManager)
{
PlayCommand = new DelegateCommand(() => ViewManager.SetView<GameViewModel>());
}
public ICommand PlayCommand { get; }
public string PlayerName
{
get => _playerName;
set => Set(ref _playerName, value);
}
protected override void BuildView(IViewBuilder builder) =>
builder.PopulateFromFile(Path.Combine(AppContext.BaseDirectory, "Views", "Menu.view.xml"));
}
public class GameViewModel : ViewModel
{
public GameViewModel(IViewManager viewManager) : base(viewManager) { }
protected override void BuildView(IViewBuilder builder) =>
builder.PopulateFromFile(Path.Combine(AppContext.BaseDirectory, "Views", "Game.view.xml"));
}
A property that the view reads or writes must be public. Set stores the value and tells the
view that the property changed, so the controls bound to it update. See Bindings and
commands.
Each visit builds the view again
The view-model is kept, but its view is built again every time the screen is shown. Fields on the view-model survive from one visit to the next, and the controls do not.
Override OnViewCreating to put per-visit state back to how a fresh screen should look. It runs
before the view is built. Do not touch controls there, because the view does not exist yet.
using BSGameLib.UI;
using BSGameLib.UI.Builder;
public class CharacterViewModel : ViewModel
{
private string _name = string.Empty;
public CharacterViewModel(IViewManager viewManager) : base(viewManager) { }
protected override void OnViewCreating() => _name = string.Empty;
public string Name
{
get => _name;
set => Set(ref _name, value);
}
protected override void BuildView(IViewBuilder builder) =>
builder.PopulateFromFile(Path.Combine(AppContext.BaseDirectory, "Views", "Character.view.xml"));
}
The view in XML
A view file has a <View> root. It holds exactly one root layout, which is a StackLayout,
DockLayout or GridLayout. Before the layout it may hold <Resources> and <References>, and
after it, <Decorations>. Layouts and controls are described in Layout and
Controls.
Views/Menu.view.xml:
<View>
<StackLayout Orientation="Vertical" HorizontalAlignment="Center" VerticalAlignment="Center"
VerticalSpacing="12">
<TextBox Id="name" Size="{240,30}" Text="{Binding PlayerName}" />
<Button Id="play" Text="Play" Size="{240,40}" Command="PlayCommand" />
</StackLayout>
</View>
Copy the views into the output folder in the project file, so that AppContext.BaseDirectory
finds them:
<ItemGroup>
<Content Include="Views\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
PopulateFromFile(string) reads the file. It returns false when the file cannot be read as a view, and building the view then reports why.
Building in C#
The fluent builder makes the same tree in code. Call one of the root methods on the builder in
BuildView: RootStackLayout, RootDockLayout or RootGridLayout.
using BSGameLib.UI;
using BSGameLib.UI.Builder;
using BSGameLib.UI.Builder.Models;
public class FormViewModel : ViewModel
{
private readonly ICommand _saveCommand;
public FormViewModel(IViewManager viewManager) : base(viewManager)
{
_saveCommand = new DelegateCommand(() => { /* save */ });
}
public ICommand SaveCommand => _saveCommand;
protected override void BuildView(IViewBuilder builder) =>
builder.RootStackLayout(stack => stack
.Vertical()
.AlignChildrenHorizontally(HorizontalAlignment.Center)
.AlignChildrenVertically(VerticalAlignment.Center)
.WithSpacing(12)
.AddTextBox("name", text => text.WithSize(240, 30).BindText("PlayerName"))
.AddButton("save", button => button
.WithText("Save")
.WithSize(240, 40)
.WithCommand("SaveCommand")));
}
The builder covers everything the XML can say: stack, dock and grid layouts; labels, buttons, text
boxes, password boxes, check boxes, radio buttons, combo boxes, images, progress bars, sliders and
repeaters; the view's resources; and decorations. Each attribute has a method. A method named
With... takes a literal, {Resource id} or {Localize key} where the attribute does, and a
method named Bind... takes a view-model property name, as {Binding Name} does in the XML. So
WithFontSize(12) is FontSize="12", and BindVisibility("ShowGold") is Visible="{Binding ShowGold}".
The controls' pages list each attribute, and the builder method is the same name with the prefix.
Resources and decorations are built the same way. A Color, String, Gradient, Style or
texture resource goes in WithResources, and a Decoration goes in AddDecoration:
using BSGameLib.UI;
using BSGameLib.UI.Builder;
using BSGameLib.UI.Controls;
public class ThemedViewModel : ViewModel
{
public ThemedViewModel(IViewManager viewManager) : base(viewManager)
{
}
protected override void BuildView(IViewBuilder builder)
{
builder
.WithResources(resources => resources
.WithColor("ink", "#1e2235")
.WithGradient("sheen", gradient => gradient.Linear().WithAngle(90)
.AddStop(0, "#ffffff")
.AddStop(1, "#1e2235")))
.AddDecoration(mark => mark
.WithAnchor(DecorationAnchor.TopRight)
.WithOffset(8, 8)
.WithSize(64, 64)
.WithFillGradient("sheen"))
.RootStackLayout(stack => stack
.AddLabel("title", label => label.WithText("Hello").WithColor("{Resource ink}")));
}
}
A repeater's item template is built in code too. It is one layout, and its bindings read each item's own properties, as they do in XML:
using BSGameLib.UI;
using BSGameLib.UI.Builder;
public class ServersViewModel : ViewModel
{
public ServersViewModel(IViewManager viewManager) : base(viewManager)
{
}
public IEnumerable<ServerRow> Servers { get; set; } = Array.Empty<ServerRow>();
public ServerRow SelectedServer { get; set; }
public ICommand ConnectCommand { get; } = new DelegateCommand(() => { /* connect to SelectedServer */ });
protected override void BuildView(IViewBuilder builder) =>
builder.RootStackLayout(stack => stack
.Vertical()
.AddRepeater("servers", list => list
.WithSize(300, 240)
.BindItems("Servers")
.BindSelectedItem("SelectedServer")
.WithActivateCommand("ConnectCommand")
.WithItemSpacing(4)
.WithItemTemplate(template => template.RootStackLayout(row => row
.Horizontal()
.WithSpacing(8)
.AddLabel(label => label.BindText("Name").WithSize(200, 24))
.AddLabel(label => label.BindText("Players").WithSize(80, 24))))));
}
public class ServerRow
{
public string Name { get; set; } = string.Empty;
public string Players { get; set; } = string.Empty;
}
BindItems, BindSelectedItem and WithActivateCommand name the view-model members, as the
ItemsSource, SelectedItem and ActivateCommand attributes do. Set Servers to a new list
when its contents change, as Controls describes for the XML repeater.
A builder call has no file to resolve a relative path from. A template named with
WithTemplateReference is therefore looked up in the search paths and then the working directory.
Give it a full path, such as one under AppContext.BaseDirectory.
Checking views before they run
A view that breaks the schema, or leaves out a required attribute, fails only when its screen is opened. To find those early, check the files in a test or a small tool with ViewFileValidator:
using BSGameLib.UI.Builder;
foreach (var file in Directory.EnumerateFiles("Views", "*.view.xml", SearchOption.AllDirectories))
{
foreach (var error in ViewFileValidator.Validate(file))
Console.Error.WriteLine($"{file}: {error}");
}
The check covers the schema only. It does not resolve <References>, load textures or fonts, or
check that a binding names a property on the view-model.
A view that is not valid fails when its screen is opened. The reasons are validation errors, and
they reach the game as an AggregateException thrown out of SetView, PushView or ShowPanel.
Things that are checked this way include:
- A view with no root layout.
- A
Button,TextBox,ComboBoxorImagewithout aSize, and aRepeaterwithout anItemsSource. - A
ComboBoxwith neither<Options>norOptionsSource.
A GridLayout cell whose Row or Column lies outside the grid throws an
ArgumentOutOfRangeException as the view is built.
A binding to a property that the view-model does not have doesn't stop the view. It logs a warning, once for that binding, and the control keeps its literal value, or its default. See Bindings and commands.
Next
- Layout: stacks, docks, grids, sizes and UI scale.
- Controls: every control and its attributes.
- Bindings and commands: connecting a view to its view-model.
- Styles and themes: colours, textures and themes.
- Navigation and panels: moving between views.