Pular para o conteúdo
CrossFly é 100% open sourceSiga no GitHub
CrossFlyCrossFly
Começar
Todos os exemplos
AplicativosFormuláriosNavegação1017 linhas · 7 arquivos

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.

O que isto mostra

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.

Rodar na sua máquina

Todo exemplo é uma Window comum, então o launcher só precisa do nome dela.

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

Java é compilado antes de rodar, então o navegador não consegue reconstruir uma edição sozinho — o código que você vê aqui virou JavaScript pelo TeaVM antes de ser publicado. Copie o arquivo para um projeto e ele roda sem mudanças nas quatro plataformas; o comando acima é toda a preparação.

Rodando aquiCrossFly 0.1.32
Iniciando o aplicativo…

Compilado para JavaScript pelo TeaVM — é o mesmo código, não um vídeo dele. · Todos os exemplos

O que ele usa

Tipos do framework que este exemplo toca. Os que têm link têm página de referência.

AlertButtonCardComponentDebugDropdownGapGridLayoutGroupHintLabelMaterialIconOptionScreenStepTrailStylesSwitchTextFieldVerticalLayoutWidgetsWindow

O código

Completo, sem cortes. É o arquivo, não um trecho dele.

Bruto
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 uma IA

Versões desta página legíveis por máquina, para um modelo ler a prateleira em vez de adivinhar a API.

Este exemplo, em JSONEste exemplo, em Markdown
AnteriorFieldwork — capturing from the device