Custom Views
If the standard views aren't enough to implement a particular design, StardewUI supports the creation of custom views.
There are several types of custom views, each best suited for a different scenario.
Reusable Widgets
Strategy: ComponentView
A custom view can simply be a collection of standard views (or a combination of standard views and other custom views) that are designed to work together, and in a particular way. Many of the standard views follow this pattern themselves; for example, a Checkbox is nothing more than a Lane with an Image and Label, with some properties and event handlers to tie them together.
It is usually possible to do this using StarML alone, via the use of included views. However, this may start to feel awkward or clumsy if internal UI/layout state finds its way into the data model; or the view may require the use of overlays or animation which are cumbersome or impractical to control via the data binding system. For those cases, the solution tends to be a small amount of code that performs the same basic function as a StarML-based view, but without the requirement of a backing data model and with more flexibility to interact directly with the view properties and sometimes with the outside environment.
These types of widgets are referred to as component views and the process for implementing one is simple and straightforward:
- Create a class inheriting from
ComponentView
orComponentView<T>
, whereT
is the type of the "root" view—generally one of the standard layout views. - Implement the
CreateView
method by creating the entire view tree. If some aspects of the tree are dynamic—for example, the aforementionedCheckbox
may or may not include a text label—then create the "initial" or "default" state of the tree here. - If any views within the view tree are meant to be controlled by properties, or otherwise change in response to user input or other view state, then add fields for those views and assign their values in
CreateView
1. - Add properties, event handlers, and any other logic required to coordinate behavior and state between different views in the tree.
- For any new properties, ensure that property notifications are sent, i.e. by calling
OnPropertyChanged
when the value changes. Also forward thePropertyChanged
event and any other important events from the root view and/or descendants, if they are meant to be handled from a StarML template.
Note on DecoratorView
For rare scenarios where the entire inner view needs to change, there are also decorator views. These have the same concept, but the View
has to be set directly, which means it is also nullable; inheriting from DecoratorView<T>
is therefore more difficult and error prone than ComponentView<T>
because a decorator's code has to be null-safe everywhere. Component views are inherently null-safe because the view tree is created on construction.
The only built-in view that uses DecoratorView
directly is the internal DynamicDropDownList
used to automagically detect data binding types and recreate the internal dropdown via reflection. Unless you are dealing with a similarly specialized scenario, prioritize ComponentView
first and only use DecoratorView
when necessary.
The relative simplicity of the implementation does not mean it can only be used for views with simple behavior; the Drop-Down List and Slider are fairly complex widgets, but both are based on ComponentView
.
Rather than provide contrived examples here, the best reference for implementing component views is the StardewUI source itself. All of the following widgets are component views:
There is no difference between a ComponentView
-based widget in StardewUI's core library and one implemented in a mod or addon; standard views do not receive any special treatment.
Custom Layout or Drawing
Strategy: View
Views that are not component views will most often inherit from the View
base class, which is designed so that it is relatively easy to create a very simple view, with many methods to situationally override as the scenario becomes more complex.
The main reason to use a View
subclass instead of ComponentView
is when it needs direct control over the layout or drawing phases. While the standard layouts should cover the majority of possible UI designs, it is always possible to run into situations that don't fit the mold, e.g. if an equivalent to WPF's WrapPanel were required. Similarly, if you wanted to draw using a custom effect (shader) instead of basic text and sprites, or need to control clipping (as in the Marquee), or are desaturating and re-tinting some other view, then ComponentView
doesn't offer enough flexibility and you will need to inherit from View
instead.
As the heading suggests, there are only two required method implementations for View
:
OnMeasure(Vector2)
, the view's contribution to the combined measure and layout pass;OnDrawContent(ISpriteBatch)
, which is where the actual rendering occurs.
The Spacer source demonstrates the bare minimum, although the spacer does not actually do anything other than occupy space, so a custom View
will be more involved. For a more comprehensive example, see the source for the test addon's Carousel view, also shown in the Carousel example.
The main thing that OnMeasure
must always do before returning is set the view's ContentSize
, as this and only this is what layout views use to arrange content. If ContentSize
is not set, the view will be "laid out" with a size of zero, and anything it tries to draw may overlap with or be overwritten by other views.
Almost every OnMeasure
method should start with the line:
This translates the view's LayoutParameters into an actual pixel size representing the maximum width and height available for use by that view. The availableSize
argument is what the parent is willing to give; the limits
derived from GetLimits
are what the view is willing to take.
Similarly, ContentSize
should normally be set using some variant of:
Where maxChildSize
is the accumulated size of all child views after layout has finished on them. Using Resolve
in this context ensures that the length type of each dimension and other constraints such as min/max width and height are all respected.
Aside from setting ContentSize
, OnMeasure
is also the place to perform any updates in response to the view's size or content changing; for example, performing layout on a NineSlice, breaking/wrapping text into lines, etc.
View Performance
As a View
implementer, it becomes your responsibility to ensure that the layout and rendering methods are efficient and not janky. To start with, avoid doing anything complex in the draw method. Drawing should, in most cases, require no calculations, no new
objects except for scoped state (e.g. saved transforms and clip states from ISpriteBatch
), and definitely no writing any mutable state, whether internal or external.
Expensive operations should happen during layout instead. Layout is cached, while drawing is not; OnMeasure
only runs when something has actually changed that requires a new layout, whereas OnDrawContent
runs every single frame during which the UI is on screen.
To ensure that layout is, in fact, correctly cached, the majority of views should implement two other methods:
IsContentDirty()
, which queries whether any state has changed and therefore whether the view actually needs layout this frame;ResetDirty()
, which runs at the end of every successful layout and ensures thatIsContentDirty()
returnsfalse
on the next frame, unless something else changes between the reset and the next frame.
Some useful tools are provided to help facilitate dirty checks, mainly DirtyTracker<T>
for single properties and DirtyTrackingList<T>
for collections, the latter being especially useful for tracking collections of child views. These are ubiquitous throughout StardewUI's own library code and are designed to minimize the amount of boilerplate needed.
A typical implementation requires only a readonly
field declaration, a property wrapper, and lines in the dirty-check and reset methods.
Example
public class MyView : View
{
public string Text
{
get => text.Value;
set
{
if (text.SetIfChanged(value))
{
OnPropertyChanged(nameof(Text));
}
}
}
private readonly DirtyTracker<string> text = new("");
protected override bool IsContentDirty()
{
return text.IsDirty;
}
protected override void OnDrawContent(ISpriteBatch b) { ... }
protected override void OnMeasure(Vector2 availableSize) { ... }
protected override void ResetDirty()
{
text.ResetDirty();
}
}
Always make sure that dirty checks are properly paired with dirty resets, otherwise the view may never reach a clean state, forcing layout to happen for the entire view tree on every frame.
View Children
When subclassing View
for the purposes of layout, as opposed to custom drawing, additional considerations are required for managing child views. The more complex the layout, the more (potentially) involved the process will be.
Roughly, the steps for implementing a layout view are:
- Decide whether the view can have only one child, like a Frame, or multiple children, such as a Panel. By convention, single-child views tend to refer to
Content
instead ofChildren
, but this makes no difference to the layout system or to the StarML renderer. - Add a dirty-tracking field; for a single child, a
DirtyTracker<IView>
and for a list, useDirtyTrackingList<IView>
. - Add an accessor property, dirty-check and dirty-reset as described in View Performance above. When implementing dirty checking, be sure to check both the tracker itself and the tracked view(s). For example, the logic for a list of children is:
children.IsDirty || children.Any(child => child.IsDirty())
- Perform child layout in
OnMeasure
. This depends on the specifics of your layout, so there is no standard set of sub-steps to follow, but the Grid and Panel implementations are good places to start. It is usually a good idea to store the results in aList<ViewChild>
or other collection ofViewChild
, as other methods will need to retrieve that specific type. - Implement (override)
GetLocalChildren
andFindFocusableDescendant
. The former is required to propagate events and updates, and the latter is required for focus searches. You can also overrideGetLocalChildrenAt
if the view might have a very large number of children and a more optimal implementation than the default linear search is available, but this is optional.
You'll notice that all of the overridable View
methods use local coordinates, so they do not need to be concerned about screen positions or their overall place in the hierarchy. Except for certain edge cases such as negative margins, a view's boundaries are always from (0, 0)
to its OuterSize
(which is ContentSize
plus padding, border and margins).
Focus Search
The most difficult aspect of implementing any custom layout is generally going to be the focus search behavior, i.e. the FindFocusableDescendant
method.
Focus search is analogous to HTML tab index, Qt tab order and so on, with one crucial difference: Stardew Valley does not have true explicit input/accessibility focus. Instead, the "focused" element is whichever focusable control is underneath the mouse/gamepad cursor at that exact moment. Focus search is therefore defined as determining the "best" (generally what users would perceive as nearest) focusable view in a given direction.
There are no specific instructions because, like the OnMeasure
implementation, it is completely dependent on the specific layout; a layout that arranges children in a horizontal line is going to have a very different implementation from one that arranges them in a grid, or in the same overlapping position. However, the following tips should prove helpful in getting to a correct implementation:
- All focus searches are recursive; if a direct child is not focusable, one of its own children/descendants may be. Therefore, when "considering" a child as a candidate for focus search, layout views must recursively run the focus search on that child.
- As a corollary to the above, finding an adjacent child that fails focus search (returns
null
fromFocusSearch
) does not mean that the search should stop; it means that the next child in the same direction must be queried, and so on until a match is found or there are no more views left. - Focus search is not constrained to the container; it is a normal and expected part of the flow for focus to move from the last child of one layout view to the first child of another layout view. Do not expect the
contentPosition
to always be within the view's boundary, and do not skip focus searches when this occurs. Instead, try to return whichever focusable view the cursor might land on (or near) if it moved continuously from its current position—which may be negative or out of bounds—in the specifieddirection
. - Use the
ViewChild.IsInDirection
helper to help with matching instead of writingswitch
statements/expressions. - If the
contentPosition
is completely out of bounds on both axes, or if movement in the specifieddirection
will never intersect with any point within the layout boundaries, then returnnull
.
D-pad navigation in a non-uniform layout can often be ambiguous and there is not necessarily a single "right answer" for any given focus search. Instead, try to ensure that every focusable control on screen is reachable somehow, even if the path to get there seems slightly unintuitive.
General Interactivity
The View
base class already provides everything required to emit events such as Click
, PointerEnter
and PointerLeave
with no additional code. However, it may be the case that the custom view is expected to have its own consistent behavior, independent of whatever event handlers are attached. For example, a Text Input must handle clicks in order to move the caret; it should not rely on the caller setting up an event handler to do this.
These situations are actually rare. TextInput
handles its own click events and Scrollable Views handle mouse wheel events; aside from these two instances, there are no built-in views that override the default event emitters, but it can be done when needed.
The overridable event-related methods are:
Other public events, such as LeftClick
and DragEnd
, are derived from the implementations of the above methods.
Warning
When overriding the above event-raising methods, remember to invoke the base method, e.g. base.OnClick(e)
, unless you actually intend to prevent regular event handlers from detecting the same event. Suppressing overrides may also want to set the Handled
property in order to suppress the event not only from their own children, but also their ancestors and siblings.
Starting from Scratch
Strategy: IView
Danger
Advanced users only. You are now heading into the untamed wilderness without a map. There are no guards, no guardrails, nor even many useful signposts. You should only be attempting this if you have already attempted all the other methods and found it impossible to achieve what you want.
Implementing IView directly is essentially opting out of the entire built-in layout, drawing and event system and deciding to make your own. StardewUI allows this—all view trees are based on the IView
interface and not the View
base class—however, it requires significantly more code to achieve, and significantly more care and testing to get right.
Most IView
implementations that are not based on View
are really just "pass-through" or "wrapper" views that take some inner IView
and forward most of the properties and events; the most prominent of these is ComponentView, which many widgets and the Framework's internal DocumentView
are based on. Even then, the implementation is fairly complex; refer to the DecoratorView source as an example of what is involved.
From-scratch implementations have to take on all the responsibilities of a View
and also:
- Translating coordinates from screen-space or parent-space to local space;
- Implementing their own dirty-checking cycle, since there is no automatically-called
Reset
method; - Handling and dispatching game update ticks, as well as all the events described in general interactivity;
- Providing their own margins, padding, or any other non-content dimensions that can affect the difference between the different bounds and size properties (e.g. ActualBounds vs. ContentBounds), and defining their own
OuterSize
implementation; - Implementing accessors for all the basic view attributes: focusability, visibility, tags, tooltips, and so on.
A full guide to implementing IView
would be out of scope for this page. Consider it the "I Know What I'm Doing Mode" of StardewUI and only reach for this option if you are certain that you know what you're doing.
-
For projects enforcing nullable reference types, initialize the field to
null!
in order to prevent the error/warning; sinceComponentView
invokesCreateView
from its constructor, it is virtually impossible for them to benull
when referenced from any user code. ↩