19
\$\begingroup\$

Working on a project, I encountered a need to create a wizard and when I ran into the need for a second, slightly different wizard, I generalized into these classes.

I originally based it off of this tutorial and attempt to adhere to these design considerations:

  • Allow traversing the pages in a flexible manner as needed.
  • Allow the model to specify when to enable and disable buttons.
  • Persist the data across the pages.

The purpose is to provide an easy to use way for creating wizards that are very simple (At minimum, the model needs to be able to tell the wizard it's state, give it the pages associated with that state and when it's complete) and not hinder more complex functionality.

I would like to know if there is any obviously expected functionality a Wizard is had that is missing and if the documentation and API make it easy to understand how to understand how to use and what everything does.

Top-level Wizard class. Used to create the wizard, inject the model and start and finish the wizard.

package com.thinoza.wizards;

import java.awt.BorderLayout;
import java.awt.CardLayout;
import java.awt.FlowLayout;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.util.Map.Entry;
import java.util.function.Consumer;

import javax.swing.JButton;
import javax.swing.JDialog;
import javax.swing.JPanel;
import javax.swing.border.EmptyBorder;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;

import com.thinoza.wizards.models.WizardModel;
import com.thinoza.wizards.models.events.WizardModelChangeEvent;

    /**
     * Class for simple JDialog-based wizard.
     * 
     * This class handles the "outside facing" functionality of displaying and
     * traversing the pages and passing the final result out of itself.
     *
     * Custom functionality is provided by passing an implementation of {@link
     * WizardModel} to the constructor.
     * 
     * This class provides the following functionality: <br>
     * - Provides buttons to advance or retreat the wizard, which are disabled or
     * enabled based on whether the model can go forward or back at any point, and a
     * cancel button to end the wizard without completing it. <br>
     * - Change the displayed {@link WizardPage} when the model changes state.
     * 
     * @param <T>
     *            the type of the result the wizard will generate
     *
     */
    public class Wizard<T> implements ActionListener, ChangeListener {
        private final WizardModel<T> model;
        private final JDialog dialog = new JDialog();
        private final JPanel contentPanel = new JPanel();
        private final CardLayout layout;
        private final JButton nextButton;
        private final JButton backButton;
        private final Consumer<T> completionCallback;
        private boolean started = false;

        /**
         * Public constructor.
         * 
         * 
         * @param model
         *            the WizardModel to use
         * @param completionCallback
         *            called when the wizard completes
         */
        public Wizard(WizardModel<T> model, Consumer<T> completionCallback) {
            dialog.setBounds(100, 100, 450, 300);
            dialog.getContentPane().setLayout(new BorderLayout());
            this.layout = new CardLayout();
            contentPanel.setLayout(layout);
            contentPanel.setBorder(new EmptyBorder(5, 5, 5, 5));
            this.model = model;
            model.registerModelListener(this);
            dialog.getContentPane().add(contentPanel, BorderLayout.CENTER);
            {
                JPanel buttonPane = new JPanel();
                buttonPane.setLayout(new FlowLayout(FlowLayout.RIGHT));
                dialog.getContentPane().add(buttonPane, BorderLayout.SOUTH);
                {
                    JButton cancelButton = new JButton("Cancel");
                    cancelButton.setActionCommand("Cancel");
                    buttonPane.add(cancelButton);
                    cancelButton.addActionListener(this);
                }
                {
                    backButton = new JButton("Back");
                    backButton.setActionCommand("Back");
                    backButton.setEnabled(false);
                    buttonPane.add(backButton);
                    dialog.getRootPane().setDefaultButton(backButton);
                    backButton.addActionListener(this);
                }
                {
                    nextButton = new JButton("Next");
                    nextButton.setActionCommand("Next");
                    buttonPane.add(nextButton);
                    dialog.getRootPane().setDefaultButton(nextButton);
                    nextButton.addActionListener(this);
                }
            }
            dialog.setModal(true);
            dialog.setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE);

            this.completionCallback = completionCallback;
        }

        @Override
        public void actionPerformed(ActionEvent e) {
            switch (e.getActionCommand()) {
            case "Next":
                model.forward();
                break;
            case "Cancel":
                dialog.dispose();
                break;
            case "Back":
                model.back();
                break;
            case "Finish":
                complete();
                break;
            }
        }


    /**
     * Starts the wizard and displays it.
     * 
     * A wizard may only be started once.
     */
    public void startWizard() {
        if (started) {
            throw new IllegalStateException("Tried to start a wizard that had already started.");
        }
        for (Entry<String, WizardPage> entry : model.getAllPages().entrySet()) {
            layout.addLayoutComponent(entry.getValue(), entry.getKey());
            contentPanel.add(entry.getValue());
        }
        layout.show(dialog, model.getInitialState());
        dialog.setVisible(true);
        started = true;
    }

    /**
     * Returns whether or not this wizard can be completed.
     * 
     * @return true when completable
     */
    public boolean isComplete() {
        return model.completable();
    }

    /**
     * Finish the wizard. The callback method is applied and the
     * JDialog is disposed.
     */
    private void complete() {
        if (!model.completable()) {
            throw new IllegalStateException("Attempted to complete an unfinished model.");
        }
        completionCallback.accept(model.complete());
        dialog.dispose();
    }

    @Override
    public void stateChanged(ChangeEvent e) {
        if (e instanceof WizardModelChangeEvent) {
            if (model.canGoForward()) {
                nextButton.setEnabled(true);
            } else {
                nextButton.setEnabled(false);
            }

            if (model.canGoBack()) {
                backButton.setEnabled(true);
            } else {
                backButton.setEnabled(false);
            }

            if (model.completable()) {
                nextButton.setText("Finish");
            } else {
                nextButton.setText("Next");
            }
            nextButton.setActionCommand(nextButton.getText());
            layout.show(contentPanel, ((WizardModelChangeEvent) e).getNewState());
        }
    }
}

Custom Jpanel, which provides hook methods called when on events related to displaying and hiding each given page:

package com.thinoza.wizards;

import javax.swing.JPanel;

/**
 * Abstract class for a page to be displayed by a {@link Wizard}.
 * 
 * @author Damien
 *
 */
@SuppressWarnings("serial")
public abstract class WizardPage extends JPanel {

    /**
     * Method called before the page is about to be displayed.
     * 
     * Override this method to "set up" the page.
     */
    public void onPageAboutToDisplay() {

    };

    /**
     * Method called when the page is hidden.
     * 
     * Override this method for whatever is needed if a page is finished.
     */
    public void onPageHidden() {

    };

    /**
     * Method called when the page is made visible.
     */
    public void onPageShown() {

    }
}

Interface for models:

package com.thinoza.wizards.models;

import java.util.Collection;
import java.util.Map;

import javax.swing.event.ChangeListener;

import com.thinoza.wizards.Wizard;
import com.thinoza.wizards.WizardPage;
import com.thinoza.wizards.models.events.WizardModelChangeEvent;

/**
 * A data model for a {@link Wizard}. The model is responsible for maintaining
 * both the data and the state of the wizard as it works and the pages it uses.
 * 
 * A properly implemented model must give each of it's possible states a String
 * identifier and a {@link WizardPage} to be displayed while it is in that
 * state.
 *
 * @param <T>
 *            the type of the result value
 */
public interface WizardModel<T> {
    /**
     * Returns true if the model can be "completed" and a usable result
     * retrieved from it.
     * 
     * @return true when model is finished
     */
    boolean completable();

    /**
     * Returns the result of finishing the Wizard.
     * 
     * This method can only be successfully called when completable() returns
     * true; otherwise an IllegalStateException will be thrown.
     * 
     * @return the result of the Wizard
     * @throws IllegalStateException
     *             if called while incomplete
     */
    T complete() throws IllegalStateException;

    /**
     * Tells the model to go directly to the given state and returns the identifier of 
     * that state
     * 
     * This is an optional operation; some models may wish to only allow their
     * state to change through the traversal order they establish with the forward()
     * and back() methods.
     * 
     * @param identifier
     *            the identifier
     * @return the identifier
     */
    default String goToState(String identifier) {
        throw new UnsupportedOperationException();
    }

    /**
     * Returns an unmodifiable Map containing each of the {@link WizardPage} in
     * the model and the String identifiers of the states associated with them.
     * 
     * @return the pages and their states
     */
    Map<String, WizardPage> getAllPages();

    /**
     * Returns a collection containing all of the current states of the model.
     * 
     * @return the identifier of the current page
     */
    Collection<String> getCurrentState();

    /**
     * Tells the model to advance to the next state, if able, and returns the
     * String identifier of the new current state.
     * 
     * @return the identifier of the new state
     */
    String forward();

    /**
     * Returns true if the model can advance it's state.
     * 
     * @return if the model can advance or not
     */
    boolean canGoForward();

    /**
     * Tells the model to return to the previous state, if able, and returns the
     * String identifier of the new current state.
     * 
     * @return the identifier of the new state
     */
    String back();

    /**
     * Returns true if the model can go back to a previous page.
     * 
     * @return if the model can go back or not
     */
    boolean canGoBack();

    /**
     * Returns the String identifier of the starting state of this model.
     * 
     * @return the first state
     */
    String getInitialState();

    /**
     * Registers a listener that wishes to receive
     * {@link WizardModelChangeEvent} from this model.
     * 
     * @param l
     *            the listener
     */
    void registerModelListener(ChangeListener l);

    /**
     * Unregisters a listener so that it will no longer receive events from this
     * model.
     * 
     * @param l
     *            the listener
     */
    void unregisterModelListener(ChangeListener l);
}

Simple Event for being informed of state changes in the model:

package com.thinoza.wizards.models.events;

import javax.swing.event.ChangeEvent;

import com.thinoza.wizards.models.WizardModel;

/**
 * Event generated whenever the state of a {@link WizardModel} changes in a way
 * that external listeners should be informed of.
 * 
 * @author Damien
 *
 */
@SuppressWarnings("serial")
public class WizardModelChangeEvent extends ChangeEvent {
    private final String newState;
    private final String oldState;

    /**
     * Get the new state of the model.
     * 
     * @return the new state
     */
    public String getNewState() {
        return newState;
    }

    /**
     * Get the previous state of the model
     * 
     * @return the old state
     */
    public String getOldState() {
        return oldState;
    }

    /**
     * Public constructor.
     * 
     * @param source
     *            the model that generated the event
     * @param newState
     *            the state that the model entered
     * @param oldState
     *            the state that the model left
     */
    public WizardModelChangeEvent(WizardModel source, String newState,
            String oldState) {
        super(source);
        this.newState = newState;
        this.oldState = oldState;
    }

    /**
     * Get the source WizardModel.
     * 
     * @return the event source
     */
    public WizardModel getSource() {
        return (WizardModel) super.getSource();
    }
}
\$\endgroup\$
2
  • 2
    \$\begingroup\$ Would you comment more on the scope of your ideal review? It would help both you and the answerer if you point out anything you'd specifically like to address. This is a bit daunting and it would help if you specify the especially relevant portions. Also, a small description of what you intend to accomplish with the program as a header would help generate interest in the code that follows it. \$\endgroup\$
    – Legato
    Mar 16 '15 at 5:16
  • \$\begingroup\$ What you can and cannot do after receiving answers \$\endgroup\$
    – Pimgd
    Apr 20 '15 at 10:04
2
\$\begingroup\$
        dialog.getContentPane().add(contentPanel, BorderLayout.CENTER);
        {
            JPanel buttonPane = new JPanel();
            buttonPane.setLayout(new FlowLayout(FlowLayout.RIGHT));
            dialog.getContentPane().add(buttonPane, BorderLayout.SOUTH);
            {
                JButton cancelButton = new JButton("Cancel");
                cancelButton.setActionCommand("Cancel");
                buttonPane.add(cancelButton);
                cancelButton.addActionListener(this);
            }
            {
                backButton = new JButton("Back");
                backButton.setActionCommand("Back");
                backButton.setEnabled(false);
                buttonPane.add(backButton);
                dialog.getRootPane().setDefaultButton(backButton);
                backButton.addActionListener(this);
            }
            {
                nextButton = new JButton("Next");
                nextButton.setActionCommand("Next");
                buttonPane.add(nextButton);
                dialog.getRootPane().setDefaultButton(nextButton);
                nextButton.addActionListener(this);
            }
        }

What's going on here?

Those braces just ... don't make sense. I had to check to make sure, but basically they're only used for scoping. You don't need that scoping here. You could very well make a function that takes a String, creates a JButton, sets its actionCommand to that string, and adds this as actionListener to the button. Then you'd have less repeated lines too.

Personally I feel that you should use blank lines for signaling separate "parts" of a function, and that quite possibly, each of these parts could be a separate function themselves.

        if (model.canGoForward()) {
            nextButton.setEnabled(true);
        } else {
            nextButton.setEnabled(false);
        }

        if (model.canGoBack()) {
            backButton.setEnabled(true);
        } else {
            backButton.setEnabled(false);
        }

If true, set true, if false, set false. Replace with set value instead:

nextButton.setEnabled(model.canGoForward());
backButton.setEnabled(model.canGoBack());

Oh, and I think model.completable() should be model.isCompletable() instead, to signal it returns a boolean. Function names should be actionable, if they do something, or a statement, if they test/ask something.

\$\endgroup\$
2
  • 1
    \$\begingroup\$ The gui was built using SwingBuilder in Eclipse so a lot of it was automatically generated with me adding the function calls to make it do stuff. Good point about the second part, that was a brain fart on my end, and the last is a good idea. \$\endgroup\$ Apr 20 '15 at 9:46
  • \$\begingroup\$ I also never noticed that it apparently sets the default button twice. That's silly. \$\endgroup\$ Apr 20 '15 at 9:55

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.