What this shows
The three animations, and the question that picks between them.
Motion is a statement about what just happened, so the kind follows the event. Something arrived or left — fade. Something moved from here to there — position. Something is ongoing with no known end — loop. Motion that answers none of those is decoration, and decoration is the first thing a user reads as the app being slow.
None of the three owns a component. Each produces a number over time and hands it to
you; what the number means is the screen's business. That is why the same LoopAnimation
drives a colour here and could drive a radius, a rotation or an alpha elsewhere.
Run it on your machine
Every example is a plain Window, so the launcher only needs its name.
gradlew lwjgl3:run --args="classname dev.cf.recipe.R22Animation"Java is compiled ahead of time, so the browser cannot rebuild an edit on its own — the code you see here was turned into JavaScript by TeaVM before it was published. Copy the file into a project and it runs unchanged on all four platforms; the command above is the whole setup.
What it uses
Framework types this example touches. The linked ones have a reference page.
The source
Complete and unabridged. This is the file, not an excerpt of it.
package dev.cf.recipe;
import dev.cf.Tones;
import dev.crossfly.ui.*;
import dev.crossfly.ui.animation.*;
import dev.crossfly.ui.color.*;
import dev.crossfly.ui.color.ColorUtils;
import dev.crossfly.ui.graphics.*;
import dev.crossfly.ui.layout.*;
import dev.crossfly.ui.style.*;
import static dev.crossfly.ui.Widgets.*;
/**
* The three animations, and the question that picks between them.
*
* <p>Motion is a statement about <b>what just happened</b>, so the kind follows the event. Something
* arrived or left — fade. Something moved from here to there — position. Something is ongoing with
* no known end — loop. Motion that answers none of those is decoration, and decoration is the first
* thing a user reads as the app being slow.</p>
*
* <p>None of the three owns a component. Each produces a <b>number</b> over time and hands it to
* you; what the number means is the screen's business. That is why the same {@link LoopAnimation}
* drives a colour here and could drive a radius, a rotation or an alpha elsewhere.</p>
*/
public class R22Animation extends Recipe
{
private Label fading, moving, pulsing;
private LoopAnimation loop;
public String title()
{
return "Animation";
}
public String note()
{
return "Styles.animationDuration is the house value — match it, so the app moves at one"
+ " speed. Anything the user waits through twice was too long the first time.";
}
public void build(Component body) throws Exception
{
head(body, 0xE65F, "Arriving and leaving", Tones.accent());
fading = label("I fade out and back", 1.5f, true, Styles.primaryColor, Position.START);
body.add(LAY_AUTO, outgap(fading, 0, 0, 0, .4f));
button(body, "Fade it", this::fade);
head(body, 0xE8D5, "Moving from here to there", Tones.support());
moving = label("I slide", 1.5f, true, Styles.textColor, Position.START);
moving.testTag("anim.slide");
body.add(LAY_AUTO, outgap(moving, 0, 0, 0, .4f));
button(body, "Slide it", this::slide);
head(body, 0xE627, "Ongoing, with no end", Tones.highlight());
pulsing = label("I pulse while something is happening", 1f, false, Styles.captionColor,
Position.START);
body.add(LAY_AUTO, outgap(pulsing, 0, 0, 0, .4f));
button(body, "Start / stop", this::togglePulse);
}
private void fade()
{
final AlphaAnimation a = new AlphaAnimation(1f, .1f, Styles.animationDuration);
a.onUpdate(() ->
{
// getValue() is the number the animation is at right now. Turning it into an alpha is
// this screen's decision, not the animation's.
fading.getStyle()
.textColor(ColorUtils.withAlpha(Styles.primaryColor.color(), a.getValue()));
Screen.repaint();
});
// Reversing on complete, so the label ends where it started and the demo can be run twice.
a.onComplete(() ->
{
final AlphaAnimation back = new AlphaAnimation(.1f, 1f, Styles.animationDuration);
back.onUpdate(() ->
{
fading.getStyle()
.textColor(ColorUtils.withAlpha(Styles.primaryColor.color(), back.getValue()));
Screen.repaint();
});
back.start();
});
a.start();
}
private void slide()
{
final PositionAnimation p = new PositionAnimation(0f, 8f, .45f);
p.onUpdate(() ->
{
// An outgap in font units — so the distance is the same on every density, which a
// distance in pixels is not.
moving.getStyle()
.outgap(Gap.ofFont(p.getValue(), 0, 0, 0));
// invalidateMeasure, not setValidLayout(false): the outgap is read by the PARENT's layout,
// and invalidating only the label left it where it was - "Slide it" did nothing (iPhone,
// 17/10/2026).
moving.invalidateMeasure();
Screen.repaint();
});
p.onComplete(() ->
{
moving.getStyle()
.outgap(Gap.ZERO);
moving.invalidateMeasure();
Screen.repaint();
});
p.start();
}
private void togglePulse()
{
if (loop != null && loop.isRunning())
{
loop.stop();
pulsing.getStyle()
.textColor(Styles.captionColor);
Screen.repaint();
return;
}
loop = new LoopAnimation(1.2f);
loop.onUpdate(() ->
{
pulsing.getStyle()
.textColor(ColorUtils.lerpColor(Styles.captionColor.color(),
Styles.primaryColor.color(), loop.getValue()));
Screen.repaint();
});
loop.start();
}
/**
* Stopping what this screen started.
*
* <p>A loop has no end of its own — it runs until told otherwise. Leaving one running when the
* screen closes gives you a component that repaints forever, holding its whole tree alive, and
* the symptom is a frame rate that drops a little more with every screen the user visits.</p>
*/
@Override
public void onRemove()
{
super.onRemove();
if (loop != null)
loop.stop();
}
private void button(Component body, String text, Runnable r) throws Exception
{
Button b = new Button(text);
b.onAction(c -> r.run());
body.add(LAY_AUTO, outgap(b, 0, 0, 0, 1.2f));
}
private void head(Component body, int icone, String t, PalColor tom) throws Exception
{
body.add(LAY_AUTO, Tones.head(icone, t, tom));
}
}package dev.cf.recipe;
import dev.cf.*;
import dev.crossfly.ui.*;
import dev.crossfly.ui.graphics.*;
import dev.crossfly.ui.layout.*;
import dev.crossfly.ui.skin.*;
import dev.crossfly.ui.style.*;
import static dev.crossfly.ui.Widgets.*;
/**
* Base of a <b>recipe</b>: one screen that shows one thing, and nothing else.
*
* <p>A recipe is not a sample. A sample shows a component with all its knobs; a recipe answers a
* question somebody actually has — "how do I put a filter sheet over a list?", "how do I make this
* follow the theme?" — in as few lines as it takes, so the answer can be read in one screen and
* copied without editing.</p>
*
* <p>That is the whole design constraint: <b>the boilerplate lives here, the substance lives in the
* subclass.</b> A recipe that spends twenty lines building a title bar teaches the title bar.</p>
*
* <p>Write one as:</p>
*
* <pre>
* public class MyRecipe extends Recipe
* {
* public String title() { return "What this shows"; }
* public String note() { return "The one sentence worth knowing."; }
*
* public void build(Component body) throws Exception
* {
* body.add(LAY_AUTO, new Button("Tap"));
* }
* }
* </pre>
*/
public abstract class Recipe extends SampleWindow implements Described
{
@Override
public abstract String title();
/**
* The one thing a reader should take away — printed above the recipe, in caption tone.
*
* @return the note, or null for none
*/
@Override
public String note()
{
return null;
}
/**
* Builds the recipe.
*
* @param body a vertical, scrollable container with sensible padding — add to it and stop
* @throws Exception when building fails
*/
public abstract void build(Component body) throws Exception;
@Override
public void onAdd(boolean firstTime) throws Exception
{
addTopBar(title());
// The page, not the surface. addTopBar just set fullscreenColor to the surface,
// and on a white page a white card has no edges — see Tones.page(). Re-derived on restyle so
// a dark-mode flip does not leave the light page behind.
fullscreenColor = Tones.page();
onRestyle(() -> fullscreenColor = Tones.page());
contents.setLayout(new VerticalLayout(Position.START, Position.START));
contents.getStyle()
.ingap(Gap.ofFont(1f, 1f, 1f, 1f));
String n = note();
if (n != null)
contents.add(LAY_AUTO, outgap(nota(n), 0, 0, 0, 1f));
Component body = new Component();
body.setLayout(new VerticalLayout(Position.START, Position.START));
body.setScrollable(ScrollDirection.VERTICAL);
body.testTag("recipe.body");
contents.add(LAY_FILL, body);
build(body);
if (pinned != null)
{
// Measured again once its labels have sizes: the band's AUTO height is taken in the first
// layout, before them, and on the iPhone it showed only its first line - the odometer's
// "No text recognizer on this device." was set and never seen (17/10/2026).
final Component band = pinned;
onUI(band::invalidateMeasure);
}
if (pinned != null)
body.add(LAY_AUTO, new Component()
{
@Override
public int getAutoHeight()
{
// Room for the last row to scroll clear of the floating band.
return pinned.getAutoHeight();
}
});
}
/**
* The note, as a tinted band rather than three lines of grey.
*
* <p>It is the one sentence the reader is meant to leave with, and it used to be the least
* legible thing on the screen: caption-coloured, caption-sized, indistinguishable from the
* captions further down that are only labels. A tone and a glyph cost nothing here — nobody has
* to read the band to know what it is.</p>
*
* @param n the note text
* @return the band
* @throws Exception when building fails
*/
/**
* Pins {@code c} to the bottom of the screen, in a band that FLOATS over the scrolling body: it
* stays on screen however far the body is scrolled, and the body scrolls underneath it. That is the
* place a recipe says what just happened. An answer at the top of the body went out of view as soon
* as the person scrolled down to the button that produced it (14/10/2026).
* <p>
* The band is 80% OPAQUE (the surface at alpha 0xCC), as the user asked; it floats rather than
* sitting below the body because a transparency with nothing behind it cannot be seen -- the first
* version had the band under the body, and the person saw a plain bar. The body gets a spacer as
* tall as the band at its end, so its last row can still scroll clear of it.
*
* @param c the component to pin, usually the answer label
* @return {@code c}, to chain
* @throws Exception if the layout refuses
*/
protected <T extends Component> T pin(T c) throws Exception
{
Component band = new Component();
band.setLayout(new VerticalLayout(Position.START, Position.START));
band.style(Skin.get()
.card()
.backColor(Styles.backColor.withAlpha(0xCC))
.borderColor(Styles.borderColor.withAlpha(0x33))
.borderRadius(0)
.shadow(0, 0, 0, 0)
.ingap(Gap.ofFont(1f, 1f, 1f, 1.2f))
.outgap(Gap.ofFont(0)));
band.add(LAY_AUTO, c);
// Floated: positioned at the bottom, full width, over whatever the body has there.
band.setRelativeBounds(Relative.START, Relative.END, Relative.FULL, Relative.AUTO);
contents.add(LAY_AUTO, band);
pinned = band;
return c;
}
/** The floating band {@link #pin} made, or null; the body ends in a spacer as tall as it. */
private Component pinned;
private Component nota(String n) throws Exception
{
Component band = new Component();
band.setLayout(new GridLayout(Position.START, Position.START));
band.style(Skin.get()
.card()
.backColor(Tones.accent().withAlpha(0x16))
.borderColor(Tones.accent().withAlpha(0x4A))
.shadow(0, 0, 0, 0)
.outgap(Gap.ofFont(0)));
// 0xE88E is the outlined "info". The glyph column hugs; the text takes the rest and wraps.
// The weights are GridLayout's own — 0f hugs, 1f takes what is left. LAY_FILL belongs to the
// vertical and horizontal layouts and means nothing here: it collapsed the text to nothing.
band.add(LAY_AUTO, 1f, matglyph(0xE88E, Tones.accent(), 1.05f),
outgap(label(n, 1f, false, secondary(), Position.START), .5f, 0, 0, 0));
return band;
}
}package dev.cf;
import dev.crossfly.ui.*;
import dev.crossfly.ui.graphics.*;
import dev.crossfly.ui.layout.*;
import dev.crossfly.ui.style.*;
import static dev.cf.sample.CrossFlySamplesColors.*;
/**
* Base class for all CrossFly sample windows.
* <p>
* Provides common infrastructure:
* <ul>
* <li>Shared color palette and styles from {@link dev.cf.sample.CrossFlySamplesColors}</li>
* <li>{@link #addTopBar(String, Button...)} — standard deep-indigo top bar with back button</li>
* <li>{@link #barButton(String, Runnable)} — helper to create icon buttons for the bar</li>
* </ul>
*
* <h3>Usage</h3>
* <pre>
* public class MySample extends SampleWindow {
* public void onAdd(boolean firstTime) throws Exception {
* if (!firstTime) return;
* initWindow();
* addTopBar("My Sample");
* // ... add content
* }
* }
* </pre>
*/
public abstract class SampleWindow extends Window
{
/** The main content area of the window, added by {@link #addTopBar(String, Button...)}, for subclasses to add their content into. */
protected Component contents;
// ─── Top bar ─────────────────────────────────────────────────────────────
/**
* Builds and adds a standard top bar with a back (←) button on the left,
* a centered title, and any optional extra buttons on the right.
*
* @param title the text shown in the bar
* @param rightButtons zero or more icon buttons placed aligned to the right
* @throws Exception if component construction fails
*/
protected void addTopBar(String title, Button... rightButtons) throws Exception
{
fullscreenColor = Styles.backColor.color();
setLayout(new VerticalLayout());
add(0f, buildTopBar(title, rightButtons));
add(LAY_FILL, contents = new Component());
contents.getStyle()
.ingap(Gap.ofFont(0, .5f, 0, .5f));
}
/**
* Same as {@link #addTopBar(String, Button...)}, but returns the bar component
* instead of adding it, for callers that need to position it manually.
*
* @param title the bar title
* @param rightButtons optional right-side icon buttons
* @return the assembled top-bar {@link Component}
* @throws Exception if component construction fails
*/
protected Component buildTopBar(String title, Button... rightButtons) throws Exception
{
Button btBack = barButton("\ue5c4", this::hide); // arrow_back
btBack.testTag("sample.back");
// Padded on every side, like the bar's right buttons: with none above and below, the target
// was the arrow itself, too small to hit on a small phone (iPhone 5s, 17/10/2026).
btBack.getStyle()
.ingap(Gap.ofFont(.75f))
.outgap(Gap.ofFont(0, 0, .25f, 0));
Component bar = new Component().style(barStyle);
bar.setLayout(new GridLayout(Position.START, Position.CENTER));
int n = 2 + rightButtons.length;
Object[] params = new Object[n * 2];
params[0] = 0f;
params[1] = 1f;
for (int i = 0; i < rightButtons.length; i++)
params[2 + i] = 0f;
params[n] = btBack;
params[n + 1] = new Label(title).style(barTitleStyle);
for (int i = 0; i < rightButtons.length; i++)
{
params[n + 2 + i] = rightButtons[i];
rightButtons[i].getStyle()
.ingap(Gap.ofFont(.75f));
}
bar.add(params);
return bar;
}
/**
* Creates a standard bar icon button (transparent, white icon, pressed feedback).
*
* @param materialIcon the Material Icon unicode codepoint string
* @param action the action to run when the button is tapped
* @return the configured {@link Button}
*/
protected Button barButton(String materialIcon, Runnable action)
{
return new Button(new MaterialIcon(materialIcon)).style(barButtonNormal)
.pressedStyle(barButtonPressed)
.onAction(e -> action.run());
}
}Machine-readable versions of this page, so a model can read the shelf instead of guessing at the API.