Skip to content
Theme

Your own widgets

The components are final, so a new kind of control is a class of your own that extends Widget. Most screens never need one, because a composition of the existing components is usually enough. This page builds one that could not be composed: a rating of zero to five dots. It measures and paints itself, takes a click and the arrow keys, tells the rest of the application what changed, and a screen reader reads it as a value. The code below is compiled and tested with the toolkit, and the test also holds the widget to the contract every toolkit value widget is held to.

A widget names itself as its type argument, class Rating extends Widget<Rating>, so the setters every widget has return your type: new Rating().setTooltip("Stars") is still a Rating. Two methods make it visible:

@Override
protected Size onMeasure(Constraints constraints) {
SizeTokens t = Theme.of(this).tokensFor(this); // here, never in the constructor
float dot = t.controlHeight();
return constraints.constrain(MAX * dot, dot);
}
@Override
protected void onPaint(Canvas canvas) {
Theme theme = Theme.of(this);
float dot = height();
float radius = dot * 0.3f;
for (int i = 0; i < MAX; i++) {
int slot = isRightToLeft() ? MAX - 1 - i : i; // the first dot is where reading starts
float cx = slot * dot + dot / 2;
if (i < value) {
canvas.fillCircle(cx, dot / 2, radius, theme.primary());
} else {
canvas.drawCircle(cx, dot / 2, radius, 1.5f, theme.outline());
}
}
if (isFocused()) {
canvas.drawRoundRect(0, 0, width(), height(), dot / 2, 2, theme.focusRing());
}
}

onMeasure is handed the constraints its parent allows and answers the size it wants within them; constrain clamps to what was allowed. onPaint draws in the widget’s own coordinates, from (0, 0) to width() by height().

The Canvas draws more than circles. Rectangles, rounded or not, and ellipses are filled or stroked, lines are drawn, text and images are placed, and any other outline is a Path2D, built with moveTo, lineTo, quadTo, cubicTo and close, then filled with fillPath or stroked with drawPath. Whatever takes a colour takes a Paint, which is a Color, a LinearGradient or a RadialGradient. drawShadow(shape, shadow) casts a Shadow under a rounded rectangle; save, translate, rotate, scale and restore move the coordinates, and clipRect and clipRoundRect cut what follows; setBlendMode draws in BlendMode.ADDITIVE, for light, or MULTIPLY, for shade; and saveLayer paints a group into a picture of its own, composited at one opacity, so overlapping parts do not show through each other. A widget that paints outside its box says how far, as A layout of your own explains.

Sizes, spacing and fonts come from the theme’s tokens for the widget’s control size, read in onMeasure and onPaint. Do not read them in the constructor: a widget being constructed has no parent yet, so the value it reads is the process default and never follows the control size it is placed under. Colours come from the theme the same way, which is what makes the dots follow a palette switch without a line of code.

private int value;
private Runnable onChange;
public Rating() {
setFocusable(true);
setCursor(Cursor.POINTER);
}
public int value() {
return value;
}
/** Sets the rating from code: a watcher hears it, the handler does not. */
public Rating setValue(int newValue) {
Ui.checkUiThread();
return apply(newValue, Change.Origin.CODE);
}
/** Runs when the user changes the rating; read {@link #value()} inside. */
public Rating onChange(Runnable handler) {
Ui.checkUiThread();
if (onChange != null && handler != null) {
throw new IllegalStateException("onChange already has a handler: pass null first");
}
onChange = handler;
return this;
}
private Rating apply(int newValue, Change.Origin origin) {
int clamped = Math.max(0, Math.min(MAX, newValue));
if (clamped != value) {
value = clamped;
invalidate(); // same size, new picture
notifyChange(Change.of(Change.Aspect.VALUE, origin)); // last, once settled
}
return this;
}
@Override
protected void handleUserChange(Change.Aspect aspect) {
if (aspect == Change.Aspect.VALUE) {
if (onChange != null) {
onChange.run();
}
} else {
super.handleUserChange(aspect);
}
}

Four rules are in those few lines, and every toolkit component follows them:

  • A public mutator checks the UI thread first. Ui.checkUiThread() throws on any other thread, so a write from a worker fails at the call instead of racing the frame.
  • A change that changes nothing says nothing. Setting the value the widget already holds marks nothing and announces nothing.
  • The announcement comes last. notifyChange runs after the state, the marks and anything else the change moved, because the watchers it reaches read the widget and must find it settled.
  • The origin decides who hears it. Every change reaches the watchers registered with observeChanges. Only a change the user made, Change.Origin.USER, also reaches the handler, after the watchers. setValue is the application talking to itself, so it passes CODE, and a handler that writes the value back cannot loop.

handleUserChange is where the handler runs. Switch on the aspect, and hand any aspect you do not own to super, because a widget you extend may have handlers of its own. A handler slot holds one handler, and a second registration throws rather than silently replacing the first; null clears it. Code that needs several listeners uses observeChanges.

The scene repaints and lays out only what was marked, so a change marks exactly what it affected:

What changed Call
the picture, in the same box invalidate()
a known part of the picture invalidate(x, y, width, height), in local coordinates
the size, and the parent should place everything again markNeedsLayout()
the size, for a reason of the widget’s own, such as text that changed width markNeedsLayoutInPlace()
something a screen reader cares about that paints nothing invalidateAccessible()

markNeedsLayoutInPlace() re-measures the ancestors only as far as the first whose size did not change, and repaints only the children that moved; markNeedsLayout() makes no such claim and costs a full frame. A widget that paints outside its own box widens the damage with Scene.damage(rect).

Never change anything from onPaint. A paint runs again whenever the scene needs the pixels, whether or not anything changed, so a change announced from one reports a change nobody made; the base class throws instead. Work that has to happen later runs from Ui.postDelayed, and a value that eases from one state to another from a Transition, never from a paint; Animations is the page on those.

@Override
protected void onMouseEvent(MouseEvent event) {
if (event.type() == MouseEvent.Type.PRESS && isEnabled()) {
requestFocus();
float x = sceneToLocal(event.x(), event.y()).x(); // the event is in the scene's coordinates
int slot = (int) (x / height());
int dot = isRightToLeft() ? MAX - 1 - slot : slot;
apply(dot + 1, Change.Origin.USER);
event.consume();
}
}
@Override
protected void onKeyEvent(KeyEvent event) {
if (!event.isPressed() || !isEnabled()) {
return;
}
int forward = isRightToLeft() ? Keys.LEFT : Keys.RIGHT;
int back = isRightToLeft() ? Keys.RIGHT : Keys.LEFT;
int key = event.key();
if (key == forward || key == Keys.UP) {
apply(value + 1, Change.Origin.USER);
event.consume();
} else if (key == back || key == Keys.DOWN) {
apply(value - 1, Change.Origin.USER);
event.consume();
}
}
@Override
protected void onFocusGained() {
invalidate();
}
@Override
protected void onFocusLost() {
invalidate();
}

Mouse events bubble from the widget under the pointer to its ancestors, and key events go to the focused widget first and then to its ancestors. A mouse event carries its point in the scene’s coordinates, whichever widget receives it, so a widget turns it into its own with sceneToLocal(event.x(), event.y()), as the rating does, which holds under a scroll and under a turned ancestor alike. Call consume() on an event you handled, and it goes no further. A widget takes the keyboard only if it is setFocusable(true), and taking focus on a press is what makes the arrows work after a click.

Two more types arrive under the pointer. WHEEL is a wheel or a trackpad scroll, its amounts in scrollX() and scrollY(). ZOOM asks to zoom around the pointer by scale(): a pinch on a trackpad, and the wheel turned with Command on macOS or Control elsewhere, which is offered as a zoom before it is a scroll. A widget that zooms consumes it, and the rest of that gesture then comes to it wherever the pointer goes; one that does not lets it bubble, and the wheel goes on as a scroll.

A right-to-left subtree mirrors the reading direction, so the rating’s first dot sits on the right there, and Left and Right swap along with it. Up and Down do not swap, because they are a direction of the value, not of the screen. isRightToLeft() answers for the widget’s own subtree.

@Override
protected void onAccessibility(Accessibility a) {
a.role(Accessible.Role.SLIDER);
a.value(value, 0, MAX, 1, false);
a.action(Accessible.Action.INCREMENT, Accessible.Action.DECREMENT);
}
@Override
protected boolean onAccessibilityAction(Accessible.Action action, Accessible.Argument arg) {
if (!isEnabled()) {
return false;
}
switch (action) {
case INCREMENT -> apply(value + 1, Change.Origin.USER);
case DECREMENT -> apply(value - 1, Change.Origin.USER);
case SET_VALUE -> {
double asked = Accessible.Argument.finiteValueOf(arg);
if (Double.isNaN(asked)) {
return false;
}
apply((int) Math.round(asked), Change.Origin.USER);
}
default -> {
return false;
}
}
return true;
}

onAccessibility describes the widget each time it is published: a role, and the facets that carry its behaviour. The value facet publishes the number, its range and its step, and the platform bridges turn that into what each platform’s readers expect. The verbs it lists are what a reader may ask for, and onAccessibilityAction performs them. A request from a reader is the user acting, so it passes USER and the handler runs, exactly as for a key.

Several things are not the widget’s to do. The scene adds focusing and scrolling into view to every focusable node itself. It withdraws every verb while the widget or an ancestor is disabled, hidden, or blocked by a modal. And the name comes from the application, through setAccessibleName or a bound Label, as it does for every component. onAccessibility runs on every publish, so it must not allocate: hand it the values you hold, not new objects.

Other shapes use other facets: a toggle, text, a selection, an expandable node, a plain action. Accessibility in the API reference has a setter for each.

The example’s test clicks the third dot and checks the value and the handler, writes the value from code and checks that a watcher heard it and the handler did not, and presses the arrows in both directions. It also runs ValueContract from limn-test: the same checks every value widget in the toolkit is held to, covering the published number, the step verbs and their bounds, a refused non-number, a disabled widget and a stable identity. ToggleContract, TextContract, RowsContract, GridContract, MenuContract, LeafActionContract and PopupOwnerContract do the same for the other shapes. Testing shows the driver the checks run on.

A layout extends Container, which adds and removes children in public, and takes its own type the same way: class Grid extends Container<Grid>. Its onMeasure measures each child with child.measure(constraints) and answers its own size from theirs. Its onLayout then places each child with child.layoutBox(x, y, width, height), in the container’s own coordinates. The container’s children are in children().

A container that does not clip lets what its children draw outside themselves show outside it too: the focus ring of a button at the edge of your layout hangs past the layout. A frame repaints only what changed, and it skips a whole subtree whose box, grown by what the subtree says it paints outside itself, misses the region being repainted. A layout that said nothing would be skipped when only the band beside it repaints, a neighbour’s hover for instance, and the ring in that band would be wiped.

Container says it for you. By default it declares the reach the theme gives a focus ring, Theme.of(this).focusRingReach(), which covers the ring of every control the toolkit ships, and the rows, columns, paddings and boxes you put your layout in declare it too. When a child draws farther, such as a cursor centred on its edge, a glow or a badge across a corner, declare that with paintOverflow(points), on your layout and on every layout between it and that child. When the children are known to stay inside, paintOverflow(0) spares the band on every repaint. paintOverflowFromTheme() goes back to the default. A layout that clips its children, by overriding clipsChildren(), declares nothing by default, because the clip already bounds them.

A layout that extends Widget directly instead of Container inherits none of this. It overrides paintOutset(), the hook paintOverflow sets, and returns the reach itself: Theme.of(this).focusRingReach() when it has nothing better to say. The hook is asked on every repaint, so it returns a number it holds and never walks the children.