What this shows
The four states every screen has, and the three nobody designs.
A screen is drawn once, for the case where the data arrived and there is plenty of it. Then it ships, and users meet the other three: nothing yet, nothing matching, and something went wrong. All three render as an empty rectangle unless somebody decided otherwise, and an empty rectangle says only one thing to the person looking at it — this is broken.
The distinction that matters most is the one between the first two. "You have not added anything yet" invites an action and is a good first impression. "Nothing matches that search" is an answer to a question the user asked. Showing the first when the second is true tells a long-standing customer their account is empty.
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.R30Empty"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.color.*;
import dev.crossfly.ui.graphics.*;
import dev.crossfly.ui.layout.*;
import dev.crossfly.ui.style.*;
import static dev.crossfly.ui.Widgets.*;
/**
* The four states every screen has, and the three nobody designs.
*
* <p>A screen is drawn once, for the case where the data arrived and there is plenty of it. Then it
* ships, and users meet the other three: nothing yet, nothing matching, and something went wrong.
* All three render as an empty rectangle unless somebody decided otherwise, and an empty rectangle
* says only one thing to the person looking at it — <b>this is broken</b>.</p>
*
* <p>The distinction that matters most is the one between the first two. "You have not added
* anything yet" invites an action and is a good first impression. "Nothing matches that search" is
* an answer to a question the user asked. Showing the first when the second is true tells a
* long-standing customer their account is empty.</p>
*/
public class R30Empty extends Recipe
{
private enum State
{
LOADED, FIRST_RUN, NO_MATCH, FAILED
}
private State state = State.FIRST_RUN;
private Component stage;
public String title()
{
return "Empty, loading and broken";
}
public String note()
{
return "Three of these are usually left to render as blank space. Blank space reads as a bug,"
+ " never as an answer.";
}
public void build(Component body) throws Exception
{
Component switcher = new Component();
switcher.setLayout(new HorizontalLayout(Position.START, Position.CENTER));
for (final State s : State.values())
{
Chip c = new Chip(s.name()
.toLowerCase()
.replace('_', ' '));
c.setSelected(s == state);
c.getStyle()
.outgap(Gap.ofFont(0, 0, .4f, 0));
c.onAction(e ->
{
state = s;
rebuild();
Screen.repaint();
});
switcher.add(LAY_AUTO, c);
}
body.add(LAY_AUTO, outgap(switcher, 0, 0, 0, 1f));
stage = new Component();
stage.setLayout(new VerticalLayout(Position.START, Position.START));
stage.testTag("recipe.states");
body.add(LAY_AUTO, stage);
rebuild();
}
private void rebuild()
{
try
{
stage.removeAll();
// removeAll() takes the layout with it.
stage.setLayout(new VerticalLayout(Position.START, Position.START));
switch (state)
{
case LOADED:
for (int i = 1; i <= 4; i++)
{
Card c = new Card();
c.setLayout(new GridLayout(Position.START, Position.CENTER));
c.getStyle()
.outgap(Gap.ofFont(0, 0, 0, .5f));
Component t = new Component();
t.setLayout(new VerticalLayout(Position.START, Position.START));
t.add(LAY_AUTO, label("Order " + (4180 + i), 1.2f, true, Styles.textColor,
Position.START));
t.add(LAY_AUTO, label("Placed this week", 1f, false, Styles.captionColor,
Position.START));
c.add(0f, 1f, Tones.tile(0xE8CC, Tones.cycle(i)), outgap(t, .6f, 0, 0, 0));
stage.add(LAY_AUTO, c);
}
break;
case FIRST_RUN:
// An invitation, with the action right there. This is a first impression, and the
// one empty state worth spending a sentence on.
// An invitation is not a problem: it gets the tone of the thing being offered.
stage.add(LAY_AUTO, state(0xE8CC, Tones.accent(), "No orders yet",
"When you place one, it will show up here.", "Place your first order"));
break;
case NO_MATCH:
// An ANSWER to what the user typed, not an invitation. Offering "add your first
// order" to somebody who searched for one they know exists reads as being ignored.
// Nothing is broken — the search simply has no answer. Not red.
stage.add(LAY_AUTO, state(0xE8B6, Tones.support(), "Nothing matches “ironvale”",
"Check the spelling, or clear the search to see everything.",
"Clear the search"));
break;
case FAILED:
// What happened, and what they can do. Not the exception text: it names something
// the user has no way to act on, and it frightens them.
// The only one of the three that actually failed, and the only one in the error
// tone. Three identical grey glyphs said all three were the same event.
stage.add(LAY_AUTO, state(0xE000, Tones.danger(), "We could not load your orders",
"Nothing was lost. Check your connection and try again.", "Try again"));
break;
default:
break;
}
stage.setValidLayout(false);
}
catch (Exception e)
{
dev.crossfly.util.Debug.handle(e);
}
}
/** One shape for all three: a glyph, a sentence, a reason, and exactly one way forward. */
private Component state(int glyph, PalColor tom, String title, String detail, String action)
throws Exception
{
Component c = new Component();
c.setLayout(new VerticalLayout(Position.CENTER, Position.CENTER));
c.getStyle()
.ingap(Gap.ofFont(1f, 2f, 1f, 2f));
c.add(LAY_AUTO, outgap(matglyph(glyph, tom, 3f), 0, 0, 0, .8f));
c.add(LAY_AUTO, outgap(label(title, 1.5f, true, Styles.textColor, Position.CENTER), 0, 0, 0,
.3f));
c.add(LAY_AUTO, outgap(label(detail, 1f, false, Styles.captionColor, Position.CENTER), 0, 0,
0, 1f));
Button b = Tones.action(action, tom);
b.onAction(e -> Hint.show(action, 1500));
c.add(LAY_AUTO, b);
return c;
}
}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.