Skip to content
Theme

Animations

Nothing in a Limn window moves by a timer of your own. A widget holds the number it wants to animate in a Transition, says where that number should go, and reads it back when it paints; the scene draws the frames in between and stops drawing them when the number has arrived. A window with nothing moving renders nothing.

The components animate themselves this way already: a button’s hover, a focus ring, a tab’s indicator, a list’s scroll bar. This page is for a widget of your own, and for motion that is not a move from one value to another.

A Transition is one animated number. Configure it once, send it somewhere with to, and read value() in onPaint:

/** A lamp that fades on and off. */
public static final class Lamp extends Widget<Lamp> {
// One transition for one number: how lit the lamp is, from 0 to 1.
private final Transition lit = new Transition(this).duration(0.18).easing(Easing.EASE_OUT);
/** Turns the lamp on or off; the picture follows over the next frames. */
public Lamp setOn(boolean on) {
Ui.checkUiThread();
lit.to(on ? 1 : 0); // where it is going; it repaints itself while it gets there
return this;
}
/** @return how lit the lamp is drawn now, from 0 to 1 */
public float brightness() {
return lit.value();
}
@Override
protected Size onMeasure(Constraints constraints) {
return constraints.constrain(32, 32);
}
@Override
protected void onPaint(Canvas canvas) {
float glow = lit.value(); // read at every paint: the eased value of this frame
canvas.fillCircle(width() / 2, height() / 2, 8 + 6 * glow,
Theme.of(this).primary().withAlpha(0.25f + 0.75f * glow));
}
}

to only says where the number is going. While it is on its way the transition asks the scene for frames and repaints its widget on each; when it arrives it stops asking. Sent somewhere else on the way, it goes on from where it is, so a lamp switched off in the middle of switching on does not jump.

duration is in seconds and easing is the curve: EASE_OUT, the default, starts fast and settles; EASE_IN, EASE_IN_OUT and LINEAR are the other plain ones, and RUBBER and BOUNCE overshoot or bounce at the end. An Easing is a function from 0..1 to 0..1, so a curve of your own is a lambda. snap(value) puts the number somewhere at once, with no animation.

A widget that is in no scene has no frames to animate over, so there to jumps. That is why a state set before a widget is shown is simply there when it appears. A repeating transition has no state to jump to, so it waits instead and starts the first time its widget is painted.

ColorTransition is the same thing for a Color, blending from the colour it is at to the one it is sent to:

/** A swatch whose fill goes to the hover colour and back. */
public static final class Swatch extends Widget<Swatch> {
private ColorTransition fill;
@Override
protected Size onMeasure(Constraints constraints) {
return constraints.constrain(96, 32);
}
@Override
protected void onMouseEvent(MouseEvent event) {
Theme theme = Theme.of(this);
if (event.type() == MouseEvent.Type.ENTER) {
fill().to(theme.primaryHover());
} else if (event.type() == MouseEvent.Type.EXIT) {
fill().to(theme.primary());
}
}
@Override
protected void onPaint(Canvas canvas) {
canvas.fillRoundRect(0, 0, width(), height(), 6, fill().value());
}
// Made at first use and not in the constructor: the theme's colours and durations are
// the ones in effect where the widget is shown.
private ColorTransition fill() {
if (fill == null) {
Theme theme = Theme.of(this);
fill = new ColorTransition(this, theme.primary())
.duration(theme.animHover).easing(theme.animEasing);
}
return fill;
}
}

The durations the components use are on the theme, so that motion of your own keeps their pace: animHover for a hover, animFocus for a focus ring, animFade for something appearing or a value settling, animTab for an indicator sliding, animExpand for a section opening or closing, animWindow for a popup or a dialog, and animEasing for the curve.

A part of a window fades, with everything inside it, in an Opacity container, which wraps one widget as Padding does. setOpacity sets how opaque it is drawn, at once, from 0 to 1; fadeTo moves it there over a duration, the theme’s animWindow when none is given, eased as the toolkit’s own popups fade, and runs what it is handed when it arrives:

/** A notice, in the container that fades it and all it holds as one picture; hidden to start with. */
static Opacity noticeOf(Widget<?> content) {
return new Opacity(content).setVisible(false);
}
/** Fades the notice in where it was hidden. */
static void showNotice(Opacity notice) {
notice.setOpacity(0).setVisible(true);
notice.fadeTo(1); // over the theme's window animation
}
/** Fades it out, and hides it when the fade arrives. */
static void dismissNotice(Opacity notice) {
// At 0 it would still take a click, a Tab and a reader's attention: hidden when the fade arrives.
notice.fadeTo(0, 0.25, () -> notice.setVisible(false));
}

Between 0 and 1 the container’s child and its subtree are drawn as one picture, and the picture is laid over the window with the opacity, so the parts of a panel do not show through each other halfway: a row’s highlight stays under its icon and a card’s border under its buttons. One container inside another multiplies its opacity. Something inside that draws in a blend mode, a tint in MULTIPLY, blends with the rest of the picture and is drawn as itself where the picture is empty, so during a fade it shows as itself over the page, and it multiplies the page once the fade arrives at 1, where there is no picture; that last frame jumps, as it does on a web page. At 1 nothing extra is drawn and at 0 nothing is drawn at all; in between, the picture costs a few hundredths of a millisecond a frame on a graphics card for something the size of a popup, and a percent or two of the frame where the system draws without one. Only what is in a container pays for it, and the container changes nothing else: it measures and lays out as its child alone, and a screen reader does not see it.

An opacity is a picture and nothing else. At 0 the child is still laid out, still takes a click and the keyboard, and a screen reader still reads it; to take it away, hide it, which the example does when its fade out arrives. A newer fadeTo or a setOpacity stops a fade where it is, and that fade’s onArrive never runs; a container taken out of its window mid-fade arrives as it leaves, and runs its onArrive once it is out, so a fade asked for there goes with the container and not with the window it left. While the user asked for less motion, fadeTo goes straight to its target, as it does in a window that is not shown yet, and runs onArrive after the seconds it was asked to take, as Ui.postDelayed would, or on the next turn of the UI thread for a fade of no time. onArrive never runs inside the fadeTo call, so the next step can be asked for in it: a sequence, fade out, change the content, fade back in, or a pulse, each fade asking for the next. The chain keeps the pace its fades ask for, also when they go straight there, and the window sleeps between its steps. It goes on, one step a period, shown or not, for as long as the code keeps asking, so a pulse stops itself: when what it stands for is over, or when its container is no longer showing (isShowing() in the onArrive). Something watching the container hears Change.Aspect.OPACITY each time setOpacity changes the value, and twice for a fade, as it starts and as it ends.

A widget that paints overlapping parts of its own and fades them, as a chart fades its tooltip, draws them as the same kind of picture with Canvas.saveLayer(x, y, width, height, opacity), closed by restore(). Canvas.setOpacity is the other way, an alpha on each draw, under which what one draw covers shows through it; it multiplies the opacity in force, so a part set to one half inside something fading at one half draws at a quarter.

A part of a window moves, scales, rotates or turns over in a Transform container, which wraps one widget as Opacity does and changes where it is drawn without changing where layout put anything: its box, its neighbours and the space it takes stay what they are, and a child scaled up overlaps what is beside it rather than pushing it. setTranslation moves it by points, at once, setScale scales it and setRotation turns it by degrees, clockwise, both about its origin, the middle by default and anywhere setOrigin puts it, and reset() puts everything back. slideTo, scaleTo and rotateTo move there over a duration, the theme’s animWindow when none is given, and flipTo turns the child over, running what it is handed when the child is edge-on, which is where to swap what it shows:

/** How wide the drawer is: the distance it slides. */
static final float DRAWER_WIDTH = 280;
/** A drawer laid out at the end edge of a page, in the container that moves its picture; hidden to start with. */
static Transform drawerOf(Widget<?> panel) {
return new Transform(new SizedBox(DRAWER_WIDTH, SizedBox.UNSET, panel)).setVisible(false);
}
/** Slides the drawer in from the end edge: the right in a left-to-right window, the left in a right-to-left one. */
static void openDrawer(Transform drawer) {
drawer.setTranslation(DRAWER_WIDTH, 0).setVisible(true); // its own width toward the end: out of sight
drawer.slideTo(0, 0); // in, over the theme's window animation
}
/** Slides it out again, and hides it when the slide arrives. */
static void closeDrawer(Transform drawer) {
// Out of sight it would still take a click and a Tab: hidden when the slide arrives.
drawer.slideTo(DRAWER_WIDTH, 0, 0.2, () -> drawer.setVisible(false));
}
/** Turns a card over: its two faces are layers of a stack, swapped while the card is edge-on. */
static void turnOver(Transform card, Widget<?> front, Widget<?> back) {
card.flipTo(Transform.Flip.FORWARD, () -> {
front.setVisible(!front.isVisible());
back.setVisible(!back.isVisible());
});
}
/** A section's arrow points to the end while it is closed, and turns to point down as it opens. */
static void showOpen(Transform arrow, boolean open) {
// A rotation turns as a clock does, the same way in either direction: from the end edge
// down is a quarter turn clockwise from the right, and counterclockwise from the left.
float down = arrow.isRightToLeft() ? -90 : 90;
arrow.rotateTo(open ? down : 0);
}

The translation’s x runs toward the end of the reading direction, and the origin is measured from the start edge, so the drawer above comes in from the right in an English window and from the left in an Arabic one with the same lines, and Flip.FORWARD turns the way a page turns forward in either. Flip.UP and Flip.DOWN turn about the horizontal axis. A rotation turns as a clock does, the same way in either direction, which is why the section’s arrow above asks which way is down from the end edge. A skew, or any other flat map, is setMatrix, with a matrix Mat4.affine2D makes from the six numbers a 2D canvas’s matrix has.

The child is drawn moved, and it is found there: a click lands on it where it is drawn, and a screen reader is told where it is when a value is set and when a motion arrives, not on every frame between. A move or a scale is drawn straight into the window, with every clip inside the child kept exact and text crisp at a uniform scale, and a frame of it repaints where the child was and where it is and lays nothing out. A rotated or skewed child is drawn upright into a picture of its own and the picture is laid at the angle: a scroll view inside still shows only what is in its view, the edges are smooth, text reads a little softer, and the picture costs what a fade of the same size costs, for as long as the child is turned. At rest the container costs nothing at all.

A part can also turn out of the screen. setRotation3D turns its plane about an axis through the origin, with x to the right, y down and z toward the reader, and setPerspective sets how far in front of the origin the eye is, so that the edge that comes toward the reader is drawn larger than the one that goes away; two to four times the part’s larger side is the usual look, and with none a turn about the vertical axis only narrows it. Seen in perspective, flipTo turns the part over in depth instead of flat:

/** A card whose flip turns it over in depth: seen from 900 points, about three times its width. */
static Transform cardInDepth(Widget<?> faces) {
return new Transform(faces).setPerspective(900); // flipTo now turns it in depth, not flat
}
/** Lays a panel back about its bottom edge, as a sheet lying on a desk is seen, or stands it up. */
static void layBack(Transform panel, boolean back) {
panel.setOrigin(0.5f, 1) // the bottom edge stays where it is
.setPerspective(1200) // from three or four times its height
.setRotation3D(Vec3.UNIT_X, back ? 25 : 0); // about x: the top edge goes away
}

A turn in depth has no motion of its own besides the flip, and is set at once. Turned past a quarter, the part shows its back, mirrored, unless setBackfaceVisible(false) hides it; turned exactly a quarter it is edge-on, draws nothing and takes no click. A part drawn as nothing, its back hidden, edge-on, or scaled to 0, is out of reach as a hidden one is: Tab passes it by, it loses the keyboard focus if it held it, a popover or a list open from a control on it closes, a screen reader is told it is hidden, and it comes back by itself when it is drawn again, without the focus. Its picture is drawn at the size of the edge nearest the reader, so text far from it reads softer; on a computer with no graphics processor it is drawn at the part’s own size, which costs that computer less and softens the near edge a little too. Where the part is seen at less than half its size, a page laid far back, its fine detail is averaged instead of flickering as it turns. What a turn brings past the eye is not drawn. A frosted glass inside a part seen in perspective shows its own tint and none of the window behind it.

As with a fade, moving something out of sight does not take it away: hide it when the motion arrives, as the drawer does. A newer motion of the same value, or a set of it, stops one in flight where it is, and its onArrive never runs; a slide and a scale run together. While the user asked for less motion, every motion goes straight to its target, and a flip swaps the faces with no turn: the swap comes on the next turn of the UI thread, so a press answers at once, and what the motion runs at its end after the seconds it was asked to take, never inside the call, as with a fade. Something watching the container hears Change.Aspect.TRANSFORM each time a set changes a value, and twice for a motion. To fade a moving part as well, put the Opacity outside: new Opacity(new Transform(child)).

A scale of 0 on either axis draws the part as nothing and so takes the focus out of it, whether it is set or a scaleTo arrives there: a card turned over by hand, scaled to 0 across, swapped and scaled back, always loses the focus it held, since a motion arrives exactly at its target. A scaleTo that only passes 0 on its way, from 1 to -1, keeps it, as its frames between do not land on 0. To turn a card over and keep the focus on it, use flipTo, whose frames pass the edge rather than stopping on it.

With repeat(true) a transition goes to its target and back for as long as it is left to, which is a pulse or a breath:

/** A dot that breathes while something is being waited for. */
public static final class Breathing extends Widget<Breathing> {
private final Transition breath = new Transition(this).duration(0.9)
.easing(Easing.EASE_IN_OUT).repeat(true);
/** Starts or stops the breathing; stopped, the dot rests small. */
public Breathing setWaiting(boolean waiting) {
Ui.checkUiThread();
if (waiting) {
breath.to(1); // with repeat, it goes there and back until told to stop
} else {
breath.snap(0); // now, with no animation
}
return this;
}
/** @return whether it is breathing now */
public boolean isBreathing() {
return breath.isAnimating();
}
@Override
protected Size onMeasure(Constraints constraints) {
return constraints.constrain(24, 24);
}
@Override
protected void onPaint(Canvas canvas) {
canvas.fillCircle(width() / 2, height() / 2, 4 + 4 * breath.value(), Theme.of(this).primary());
}
}

A repeating transition asks for a frame on every refresh while it runs. Stop it when what it stands for is over; inside a hidden container it stops by itself and starts again when it is painted.

A transition goes from one value to another. For anything else, something that turns, falls, follows the pointer or runs a simulation, register a ticker with the scene. It is called once a frame with the seconds that went by since the last one, and it goes on being called for as long as it answers true:

/** A hand that goes round for as long as it is on screen: a turn every two seconds. */
public static final class Dial extends Widget<Dial> {
private static final double SECONDS_A_TURN = 2;
private double turn; // 0 to 1, one whole turn
private double held; // time kept back while the user asked for less motion
private boolean ticking;
/** @return where the hand is, as a share of a turn from 0 to 1 */
public double turn() {
return turn;
}
@Override
protected Size onMeasure(Constraints constraints) {
return constraints.constrain(48, 48);
}
@Override
protected void onPaint(Canvas canvas) {
start();
double angle = turn * 2 * Math.PI;
float cx = width() / 2;
float cy = height() / 2;
float reach = Math.min(cx, cy) - 4;
canvas.drawLine(cx, cy, cx + (float) (Math.sin(angle) * reach),
cy - (float) (Math.cos(angle) * reach), 2, Theme.of(this).primary());
}
// Started by the first paint, which is when the widget is in a scene and on screen.
private void start() {
Scene scene = scene();
if (ticking || scene == null) {
return;
}
ticking = true;
scene.addTicker(dt -> {
if (!isShowing()) {
ticking = false;
return false; // off screen: stop, and the next paint starts it again
}
// Every frame by its own length; a few times a second for a user who asked for
// less motion, by the time that went by since the last step.
double moved = Motion.stepped(dt, held);
held = moved > 0 ? 0 : held + dt;
if (moved > 0) {
turn = (turn + moved / SECONDS_A_TURN) % 1;
invalidate(); // the ticker moves the number; this is what repaints it
}
return true; // again next frame
});
}
}

Three things in that example are the contract. The ticker changes the number and then calls invalidate(), because a ticker repaints nothing by itself. It answers false when there is nothing left to animate, because a ticker that answers true keeps the window rendering sixty or more times a second for nothing. And the first call hands it zero seconds, and no call ever hands it more than a quarter of a second: a window that was covered or a machine that stalled does not make the hand leap when it comes back.

An animation repaints its widget and nothing else, so keep the widget that owns the transition as small as what moves: the indicator and not the whole strip. Where the part that moves is drawn by a large widget itself, a row’s highlight in a list, give the transition damages(() -> invalidate(x, y, width, height)) and each frame repaints that rectangle only. Performance says how to see what a frame repainted.

An animation must not change a size. Animate what is painted, an offset, an opacity, a colour, and leave markNeedsLayout() for when something really is a different size: a layout on every frame measures the screen on every frame. A part that has to move or grow on screen goes in a Transform, which moves the picture and lays nothing out.

A scene has a clock of its own that the application can stop or slow:

/** What the application may do to its own motion: stop it, slow it, start it again. */
static void freeze(Scene scene, boolean frozen) {
scene.setPaused(frozen); // tickers of the scene, and transitions that asked for scene time
}
static void slowMotion(Scene scene) {
scene.setTimeScale(0.25); // a quarter of the speed; 1 is the clock's own
}

A ticker registered with addTicker runs on that clock. A Transition does not, unless it is given sceneTime(true): a hover or a focus ring is the window answering the user, and a window that stops answering while the application paused its own motion looks hung. So content, the thing the application is showing move, goes on scene time, and feedback stays on the wall clock. addRealTimeTicker is the ticker for the second kind.

Every system has a setting for less motion, and the toolkit reads it. While it is on, a Transition and a ColorTransition jump to where they are sent, and menus, popups and dialogs open and close without a fade. Nothing in your code changes for that.

Motion that a ticker drives is yours to reduce. Motion that only decorates should stop: Motion.reduced() says whether to. Motion that says something, a spinner, a progress sweep, a dial, should go on saying it without being smooth: Motion.stepped(dt, held), used in the dial above, moves it four times a second instead of on every frame. Motion.duration(seconds) answers zero while motion is reduced, for a fade you time yourself.

A test moves the clock itself. Build the scene with one, new Scene(root, clock), where the clock is a supplier of nanoseconds the test holds, then advance it and draw a frame:

/** A test's way of looking at an animation part of the way through: it moves the clock. */
static boolean isPartWayAfter90Millis(Lamp lamp) {
long[] nanos = {0};
Scene scene = new Scene(lamp, () -> nanos[0]); // the scene's clock is the test's
limn.graphics.Canvas canvas = new limn.testing.NoopCanvas(200, 100);
scene.layoutPass(200, 100);
lamp.setOn(true);
scene.renderFrame(canvas); // the transition's first frame
nanos[0] += 90_000_000; // 90 ms later
scene.renderFrame(canvas);
return lamp.brightness() > 0 && lamp.brightness() < 1;
}

With no clock given, a scene’s animations run on the wall clock, and a test that waits for one has to wait in real time. Testing has the rest: the headless window, the driver, and how to hold a screen to asking for no frame once it has settled.