Saltar al contenido
CrossFly es 100% open sourceSíguenos en GitHub
CrossFlyCrossFly
Empezar
Todos los ejemplos
AplicacionesFormulariosNavegación1017 líneas · 7 archivos

Order entry — a wizard

A linear flow that validates per step, lets the reader walk back freely, and ends on a review built from the same data the steps collected.

Qué muestra esto

Order entry — the wizard shape: a linear flow with per-step validation and a review.

gradlew lwjgl3:run --args="classname dev.cf.app.wizard.WizardApp"

The shape

One host Window that owns the trail, the footer and the current step; the steps themselves are plain objects (WizardStep) that draw into a body the host provides. Not screens, not windows — because the host has to be able to rebuild any of them at any time, in either direction, and a stack of Windows cannot do that.

The four rules that make a wizard bearable

  1. All state lives in one object (Order), never in the steps. This is what makes going backwards work at all — see the Order javadoc for what happens otherwise.
  2. Backwards is always free. back() never validates. Blocking a user from returning to fix something because the step they are on is incomplete is the single most infuriating thing a wizard can do, and it is usually an accident of putting one validate call in the wrong place.
  3. Forwards validates, and says what and where. problem() returns a sentence, which the footer shows. "Please complete all fields" is what a wizard says when nobody decided who owns the rule.
  4. The trail is navigation, not decoration. Tapping a dot goes there — subject to the same forward rule, so you can always jump back and only jump forward through completed steps. StepTrail draws it; the gating is the app's business, which is why the widget does not attempt it.

What is deliberately missing: saving a half-finished order and resuming it later. It is a real requirement in real order entry, it is a persistence problem rather than a wizard problem, and the seam for it is exactly the Order object — serialise that one thing and you have it.

Ejecutarlo en tu máquina

Cada ejemplo es una Window normal, así que el lanzador solo necesita su nombre.

gradlew lwjgl3:run --args="classname dev.cf.app.wizard.WizardApp"
Editarlo

Java se compila antes de ejecutarse, así que el navegador no puede reconstruir una edición por su cuenta: el código que ves aquí se convirtió en JavaScript con TeaVM antes de publicarse. Copia el archivo a un proyecto y funciona sin cambios en las cuatro plataformas; el comando de arriba es toda la preparación.

Ejecutándose aquíCrossFly 0.1.32
Iniciando la aplicación…

Compilado a JavaScript por TeaVM: es el mismo código, no un vídeo de él. · Todos los ejemplos

Qué usa

Tipos del framework que toca este ejemplo. Los enlazados tienen página de referencia.

AlertButtonCardComponentDebugDropdownGapGridLayoutGroupHintLabelMaterialIconOptionScreenStepTrailStylesSwitchTextFieldVerticalLayoutWidgetsWindow

El código

Completo, sin recortes. Es el archivo, no un fragmento.

Crudo
dev/cf/app/wizard/WizardApp.java
package dev.cf.app.wizard;

import dev.cf.Described;
import dev.cf.Tones;
import dev.cf.app.AppBar;
import dev.cf.app.AppTheme;

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 java.util.ArrayList;
import java.util.List;

import static dev.crossfly.ui.Widgets.*;

/**
 * <b>Order entry</b> — the wizard shape: a linear flow with per-step validation and a review.
 *
 * <pre>
 * gradlew lwjgl3:run --args="classname dev.cf.app.wizard.WizardApp"
 * </pre>
 *
 * <h2>The shape</h2>
 *
 * <p>One host Window that owns the trail, the footer and the current step; the steps themselves are
 * plain objects ({@link WizardStep}) that draw into a body the host provides. Not screens, not
 * windows — because the host has to be able to rebuild any of them at any time, in either
 * direction, and a stack of Windows cannot do that.</p>
 *
 * <h3>The four rules that make a wizard bearable</h3>
 *
 * <ol>
 *   <li><b>All state lives in one object</b> ({@link Order}), never in the steps. This is what makes
 *       going backwards work at all — see the {@code Order} javadoc for what happens otherwise.</li>
 *   <li><b>Backwards is always free.</b> {@link #back()} never validates. Blocking a user from
 *       returning to fix something because the step they are on is incomplete is the single most
 *       infuriating thing a wizard can do, and it is usually an accident of putting one validate
 *       call in the wrong place.</li>
 *   <li><b>Forwards validates, and says what and where.</b> {@link WizardStep#problem()} returns a
 *       sentence, which the footer shows. "Please complete all fields" is what a wizard says when
 *       nobody decided who owns the rule.</li>
 *   <li><b>The trail is navigation, not decoration.</b> Tapping a dot goes there — subject to the
 *       same forward rule, so you can always jump back and only jump forward through completed
 *       steps. {@link StepTrail} draws it; the gating is the app's business, which is why the
 *       widget does not attempt it.</li>
 * </ol>
 *
 * <p>What is deliberately missing: saving a half-finished order and resuming it later. It is a real
 * requirement in real order entry, it is a persistence problem rather than a wizard problem, and
 * the seam for it is exactly the {@link Order} object — serialise that one thing and you have it.</p>
 */
public class WizardApp extends Window implements Described
{
   private final Order order = new Order();
   private final List<WizardStep> steps = new ArrayList<>();

   private int current;

   private StepTrail trail;
   private Component body;
   private Label problem;
   private Button back, next;

   @Override
   public String title()
   {
      return "Order entry — a wizard";
   }

   @Override
   public String note()
   {
      return "A linear flow that validates per step, lets the reader walk back freely, and ends"
         + " on a review built from the same data the steps collected.";
   }

   /** Hands the palette back to whatever was in force before this app opened. */
   @Override
   public void onRemove()
   {
      super.onRemove();
      AppTheme.leave();
   }

   @Override
   public void onAdd(boolean firstTime) throws Exception
   {
      if (!firstTime)
         return;
      // The brand FIRST: fullscreenColor below reads a token, and reading it before the palette
      // is declared bakes in the skin's neutral grey for the life of the window.
      AppTheme.enter();
      // The PAGE, not the surface. Cards, buttons and fields are all drawn on the surface, so a
      // page painted the same colour makes every one of their edges disappear. Read through
      // onRestyle so a light/dark flip re-derives it instead of keeping the old mode's value.
      fullscreenColor = AppTheme.pageColor();
      onRestyle(() -> fullscreenColor = AppTheme.pageColor());
      setLayout(new VerticalLayout(Position.START, Position.START));

      steps.add(new CustomerStep(order));
      steps.add(new ItemsStep(order));
      steps.add(new DeliveryStep(order));
      // The review is built over the OTHER steps, so it is added holding a list that excludes it.
      steps.add(new ReviewStep(order, new ArrayList<>(steps), this));

      add(0f, new AppBar("New order", isRoot() ? null : this::hide));

      trail = new StepTrail();
      for (int i = 0; i < steps.size(); i++)
      {
         final int idx = i;
         WizardStep s = steps.get(i);
         trail.step(s.title(), s.icon(), () -> goToStep(idx));
      }
      trail.testTag("wizard.trail");
      trail.getStyle()
         .ingap(Gap.ofFont(1f, .6f, 1f, .6f));
      add(0f, trail);

      body = new Component();
      body.setScrollable(ScrollDirection.VERTICAL);
      body.getStyle()
         .ingap(Gap.ofFont(1f, 1f, 1f, 1f));
      body.testTag("wizard.body");
      add(LAY_FILL, body);

      add(0f, footer());
      showStep(0);
   }

   private Component footer() throws Exception
   {
      Component f = new Component();
      f.setLayout(new VerticalLayout(Position.START, Position.START));
      f.getStyle()
         .backColor(Styles.backColor)
         .shadow(0, -.06f, .25f, Styles.borderColor)
         .ingap(Gap.ofFont(1f, .6f, 1f, .6f));

      // The message sits ABOVE the buttons, next to the thing it is about — not in a dialog. A
      // validation error in a modal makes the user dismiss it before they can act on it.
      problem = label("", 1f, false, secondary(), Position.START);
      problem.testTag("wizard.problem");
      problem.setVisible(false);
      f.add(LAY_AUTO, outgap(problem, 0, 0, 0, .5f));

      Component row = new Component();
      row.setLayout(new GridLayout(Position.START, Position.CENTER));

      back = new Button("Back");
      back.testTag("wizard.back");
      back.onAction(c -> back());

      next = Tones.action("Next");
      next.testTag("wizard.next");
      next.onAction(c -> next());

      row.add(.5f, .5f, back, next);
      f.add(LAY_AUTO, row);
      return f;
   }

   /**
    * Jumps to a step.
    *
    * <p>Backwards is unconditional. Forwards runs the same gate {@link #next()} does, one step at a
    * time, so the trail cannot be used to skip a validation that the Next button enforces.</p>
    *
    * @param index the step to go to
    */
   public void goToStep(int index)
   {
      if (index == current)
         return;
      if (index < current)
      {
         steps.get(current)
            .save();
         showStep(index);
         return;
      }
      while (current < index)
         if (!next())
            return;
   }

   /**
    * Advances one step if the current one is complete.
    *
    * @return whether it moved
    */
   private boolean next()
   {
      WizardStep s = steps.get(current);
      s.save();
      String p = s.problem();
      if (p != null)
      {
         say(p);
         return false;
      }
      if (current == steps.size() - 1)
      {
         confirm();
         return false;
      }
      showStep(current + 1);
      return true;
   }

   /** Goes back one step. Saves on the way out, and never validates. */
   private void back()
   {
      if (current == 0)
         return;
      steps.get(current)
         .save();
      showStep(current - 1);
   }

   private void showStep(int index)
   {
      try
      {
         current = index;
         say(null);

         body.removeAll();
         // removeAll() takes the layout with it.
         body.setLayout(new VerticalLayout(Position.START, Position.START));
         steps.get(index)
            .build(body);

         trail.current(index);
         trail.header((index + 1) + "/" + steps.size(), steps.get(index)
            .title()
            .toUpperCase(), "Total", Order.money(order.totalCents()));

         back.setEnabled(index > 0);
         next.setText(index == steps.size() - 1 ? "Place order" : "Next");

         setValidLayout(false);
         Screen.repaint();
      }
      catch (Exception e)
      {
         dev.crossfly.util.Debug.handle(e);
      }
   }

   private void say(String message)
   {
      problem.setText(message == null ? "" : message);
      problem.setVisible(message != null);
      problem.setValidLayout(false);
      if (message != null)
         Screen.repaint();
   }

   /**
    * The end of the flow.
    *
    * <p>It asks once, naming the total, and it is the only confirmation in the whole wizard. A
    * wizard that confirms each step teaches the user to dismiss confirmations, which is exactly the
    * habit you do not want them to have when they reach this one.</p>
    */
   private void confirm()
   {
      Alert a = new Alert("Place this order?",
         "Total " + Order.money(order.totalCents()) + " for " + order.customer + ".", "Cancel",
         "Place order");
      a.buttonAction(1, c ->
      {
         Hint.show("Order placed", 2500);
         hide();
      });
      a.show();
   }
}
Para una IA

Versiones de esta página legibles por máquina, para que un modelo lea el estante en vez de adivinar la API.

Este ejemplo, en JSONEste ejemplo, en Markdown
AnteriorFieldwork — capturing from the device