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.
Measuring and painting
Section titled “Measuring and painting”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:
@Overrideprotected 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);}
@Overrideprotected 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.
State, and who hears it
Section titled “State, and who hears it”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;}
@Overrideprotected 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.
notifyChangeruns 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.setValueis the application talking to itself, so it passesCODE, 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.
What to mark when something changes
Section titled “What to mark when something changes”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.
@Overrideprotected 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(); }}
@Overrideprotected 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(); }}
@Overrideprotected void onFocusGained() { invalidate();}
@Overrideprotected 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.
What a screen reader is told
Section titled “What a screen reader is told”@Overrideprotected void onAccessibility(Accessibility a) { a.role(Accessible.Role.SLIDER); a.value(value, 0, MAX, 1, false); a.action(Accessible.Action.INCREMENT, Accessible.Action.DECREMENT);}
@Overrideprotected 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.
Testing it
Section titled “Testing it”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 of your own
Section titled “A layout of your own”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.