Skip to content
CrossFly is 100% open sourceFollow us on GitHub
CrossFlyCrossFly
Get started
All examples
RecipesDrawing119 lines · 1 files

Painting your own

A component is a rectangle that can draw. width/height are yours, getAutoHeight says how much room you want, and onPaint puts something in it.

What this shows

Painting something the framework does not have — and when not to.

Reach for onPaint when the thing you need is a drawing: a gauge, a sparkline, a seat map, a signature. Do not reach for it to make a card look different — that is a ComponentStyle, and a card painted by hand loses the skin, the theme, the press state and the shadow, all of which you then rebuild worse.

Three rules hold for everything drawn here. Sizes come from Styles.fontHeight, so the drawing scales with density instead of shrinking on a good screen. Colours come from tokens, so it follows the theme. And onPaint runs every frame — it may compute, but it must not allocate, which is why the shapes below are fields and not locals.

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.R23Painting"
Editing it

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.

Running hereCrossFly 0.1.32
Starting the app…

Compiled to WebAssembly-era JavaScript by TeaVM — the same code, not a video of it. · All examples

What it uses

Framework types this example touches. The linked ones have a reference page.

CardColorUtilsComponentGRectPaintStylesWidgets

The source

Complete and unabridged. This is the file, not an excerpt of it.

Raw
dev/cf/recipe/R23Painting.java
package dev.cf.recipe;

import dev.cf.Tones;

import dev.crossfly.ui.*;
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.*;

/**
 * Painting something the framework does not have — and when not to.
 *
 * <p>Reach for {@code onPaint} when the thing you need is <b>a drawing</b>: a gauge, a sparkline, a
 * seat map, a signature. Do not reach for it to make a card look different — that is a
 * {@code ComponentStyle}, and a card painted by hand loses the skin, the theme, the press state and
 * the shadow, all of which you then rebuild worse.</p>
 *
 * <p>Three rules hold for everything drawn here. Sizes come from {@code Styles.fontHeight}, so the
 * drawing scales with density instead of shrinking on a good screen. Colours come from tokens, so
 * it follows the theme. And {@code onPaint} runs every frame — it may compute, but it must not
 * allocate, which is why the shapes below are fields and not locals.</p>
 */
public class R23Painting extends Recipe
{
   public String title()
   {
      return "Painting your own";
   }

   public String note()
   {
      return "A component is a rectangle that can draw. width/height are yours, getAutoHeight says"
         + " how much room you want, and onPaint puts something in it.";
   }

   public void build(Component body) throws Exception
   {
      body.add(LAY_AUTO, outgap(label("A bar with a target line — the kind of thing no widget set"
         + " has, because the target is your idea.", 1f, false, Styles.captionColor,
         Position.START), 0, 0, 0, .8f));

      for (int i = 0; i < 4; i++)
      {
         Card c = new Card();
         // The bar is NOT toned: the class above reasons that over-target must read as more of the
         // same hue, and four hues would take that away. The name carries the colour instead.
         c.add(LAY_AUTO, label(new String[] { "North", "South", "East", "West" }[i], 1.2f, true,
            Tones.accent(), Position.START));
         Meter m = new Meter(new float[] { .82f, .41f, 1.12f, .67f }[i]);
         c.add(LAY_AUTO, outgap(m, 0, .3f, 0, 0));
         body.add(LAY_AUTO, outgap(c, 0, 0, 0, .5f));
      }
   }

   /**
    * A bar against a target: filled to {@code value}, with a line at 100% and the excess in a
    * different tone.
    */
   private static class Meter extends Component
   {
      /** Shapes as FIELDS: onPaint runs every frame, and allocating there is how you make garbage. */
      private final GRect track = new GRect(), fill = new GRect(), mark = new GRect();

      private final float value;

      Meter(float value)
      {
         this.value = value;
      }

      /**
       * How tall this wants to be. Expressed in font heights, so it grows with the type rather than
       * staying a fixed number of pixels that is right on exactly one screen.
       */
      @Override
      public int getAutoHeight()
      {
         return Math.round(Styles.fontHeight * 1.1f);
      }

      @Override
      public void onPaint(Paint paint) throws Exception
      {
         int r = height / 2;
         int reached = Math.round(Math.min(value, 1f) * width);

         track.clear()
            .bounds(0, 0, width, height)
            .radius(r)
            .fill(ColorUtils.lerpColor(Styles.backColor.color(), Styles.textColor.color(), .1f));
         paint.rect(track);

         // Over target gets its own tone. A bar that just stops at 100% hides the best news on the
         // screen, and one that keeps growing past the edge hides the worst.
         fill.clear()
            .bounds(0, 0, reached, height)
            .radius(r)
            // Over target gets a DEEPER shade of the same hue, not a different colour: the reader
            // should see "more of the same thing", and tertiary() on a blue seed resolves to a
            // muted tone that reads as less rather than more.
            .fill(value > 1f
               ? ColorUtils.lerpColor(Styles.primaryColor.color(), Styles.textColor.color(), .4f)
               : Styles.primaryColor.color());
         paint.rect(fill);

         // The target line. One pixel is invisible on a dense screen — measure it in font units.
         int t = Math.max(2, Math.round(Styles.fontHeight * .08f));
         mark.clear()
            .bounds(width - t, -t, t, height + t * 2)
            .fill(Styles.textColor.color());
         paint.rect(mark);
      }
   }
}
For an AI assistant

Machine-readable versions of this page, so a model can read the shelf instead of guessing at the API.

This example, as JSONThis example, as Markdown
PreviousAnimationNextFloating action button