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();
}
}