Using a State-Machine to Control a Wizard
To create my interface using Java Swing, I am going to create three panels for contents, one panel to provide the navigation controls, and a frame to hold it all together. It will be a simple example, but one you could easily expand should you have the need to create your own wizard.
The Frame
The frame is the application window. I will not go to much into details about the Java JFrame class, as I assume you have some basic knowledge of Java and Java Swing. If you don't much about Java Swing, I suggest you visit this Oracle tutorial on how to create frames.
Main.java
public class Main extends JFrame
{
private CardsPanel cards = new CardsPanel();
private NavigationPanel buttons = new NavigationPanel(context);
public Main()
{
setSize(400, 400);
setVisible(true);
setLocationRelativeTo(null);
setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
add(cards, BorderLayout.CENTER);
add(buttons, BorderLayout.SOUTH);
setTitle("Simple Wizard Demo");
}
public static void main(String[] args)
{
SwingUtilities.invokeLater(new Runnable()
{
@Override
public void run()
{
createAndShowGUI();
}
});
}
private static void createAndShowGUI()
{
new Main();
}
}
The Panels
Notice that the frame contains the panel that contains the navigation controls (NavigationPanel) and something called CardsPanel. This panel contains the three pages of the wizard. I called it like that because it uses Java's CardLayout as the layout manager. A layout manager is an object that implements the LayoutManager interface and determines the size and position of the components within a container. This layout manager gives the appearance that the component it contains are stacked on top of each other like a deck of cards. When cards are laid on top of each other, you can only see the face of a single card at a time. This type of behavior is similar to how we need out of the wizard: to show a single view or panel at a time when the navigation controls are clicked. You can learn more about this layout managers and others here. We are using the state object's simple name (class name) to provide a unique identification for the panel being added to the card panel.CardsPanel.java
public class CardsPanel extends JPanel
{
public CardsPanel()
{
setLayout(new CardLayout());
setBorder(new LineBorder(Color.BLACK));
add(new FirstPanel(), FirstState.INSTANCE.getName());
add(new SecondPanel(), SecondState.INSTANCE.getName());
add(new ThirdPanel(), ThirdState.INSTANCE.getName());
}
}
NOTE: It is a good programming practice for classes to be as simple as possible, as they should do a single job. This is known as the Single Responsibility Principle.
Basically, the CardLayout layout manager requires each component uniquely identified in order to recall them when needed. This can be accomplished by indexing each component or by associating a unique object to them. For this example, I have chosen to assign each panel a unique String value.
The three panel classes for this example are even simpler.
FirstPanel.java
public class FirstPanel extends JPanel
{
public FirstPanel()
{
add(new JLabel("First Panel"));
}
}
SecondPanel.java
public class SecondPanel extends JPanel
{
public SecondPanel()
{
add(new JLabel("Second Panel"));
}
}
ThirdPanel.java
public class ThirdPanel extends JPanel
{
public ThirdPanel()
{
add(new JLabel("Third Panel"));
}
}
For simplicity, the NavigationPanel contains an anonymous ActionListener class to handle the button actions.
NavigationPanel.java
public class NavigationPanel extends JPanel
{
private JButton nextBtn = new JButton("Next");
private JButton prevBtn = new JButton("Prev");
public NavigationPanel()
{
setBorder(new LineBorder(Color.BLACK));
add(prevBtn);
add(nextBtn);
init();
}
private void init()
{
prevBtn.addActionListener(new ActionListener()
{
@Override
public void actionPerformed(ActionEvent arg0)
{
// Navigate to previous page
}
});
nextBtn.addActionListener(new ActionListener()
{
@Override
public void actionPerformed(ActionEvent e)
{
// Navigate to next page
}
});
}
}
You have probably noticed by now that these Java Swing classes are not tied up in any way to the state-machine I created in my previous article. You probably also noticed that the navigation buttons don't do anything yet. It is safe to assume that the navigation buttons provide the interface the user will need to switch between pages in the wizard, and that the state-machine will the the entity responsible for controlling the navigation. Therefore, we must do some modification to these classes.
Connecting the Wizard Interface to the State-Machine
In order for the Context class to interact with the Wizard, they must be able to communicate in some fashion. One of the simplest ways to "connect" these two entities is by using the Observer Pattern. Java contains a basic implementation of this design pattern. The main problem that the Observer Pattern solves is defining a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically. This is also known as Publish-Subscribe.Because users can't interact with the Context directly, it must do so though the navigation controls. Therefore, the NavigationPanel must interact with the context and force it to call the next() and previous() methods. To accomplish this, I will pass an instance of Context to the NavigationButton class constructor. It can then use this instance to call the appropriate methods when a user action occurs.
NavigationPanel.java
public class NavigationPanel extends JPanel
{
...
private Context context;
public NavigationPanel(Context context)
{
...
this.context = context;
init();
}
private void init()
{
prevBtn.addActionListener(new ActionListener()
{
@Override
public void actionPerformed(ActionEvent arg0)
{
context.previous();
}
});
nextBtn.addActionListener(new ActionListener()
{
@Override
public void actionPerformed(ActionEvent e)
{
context.next();
}
});
}
}
The buttons on the navigation panel now provide the user the ability to interact with the state-machine. However, this is not enough. The CardsPanel is oblivious to the user clicking buttons or the state-machine changing states.
The Observable Interface
The observable entity is the one that knows of the state change. As you probably guessed, the Context class is the holder of the current state. In the state-machine, it triggers the State object so it could change states as requested. To make the Context an Observable object, it must:- Extend the Observable class.
- Notify all observers that something has changed.
Context.java
public final class Context implements Observable
{
...
public void setState(State current)
{
this.current = current;
setChanged();
notifyObservers();
}
}
When the controller receives a new State, it notifies all observers that a change (in state) has taken place. What is the process that leads to this notification to be sent?
- The user clicks one of the navigation buttons.
- The context object triggers the current state.
- The state changes to the next or previous state depending on which button is pressed
- The context caches the new state and notifies observers
The Observer
The observers are the parties interested in the observable object. If you recall, the application Frame (Main.java) contains the CardsPanel and the NavigationPanel. The NavigationPanel gives the user access to the context object in order to trigger a state change. The CardsPanel contains all the pages of the wizard. In this article, I am going to designate the Frame as the Observer. However, the CardsPanel can be the observer. In my opinion, it is a bit simpler for the main container to be the observer. In order to make the Frame the observer it must:- Implement the Observer interface.
- Override all methods (only one for this interface).
- Be registered with the Observable object as an observer.
Main.java
public class Main extends JFrame implements Observer
{
...
Context context;
public Main()
{
...
context = new Context();
context.addObserver(this);
}
...
@Override
public void update(Observable o, Object arg)
{
if(context == o)
{
((CardLayout) cards.getLayout()).show(cards, context.getCurrentState().getName());
}
}
}
The application Frame is now an Observer, is registered with the Context as an observer, and upon receiving an update, it tells the CardsPanel to switch views.
Controlling Availability of the Navigation Buttons
I added default methods to the State interface to determine if a state has a previous or next state, but never used it. I am going to these methods to control the availability of the navigation buttons. This means I will have to make a slight change to the NavigationPanel class.NavigationPanel.java
public class NavigationPanel extends JPanel
{
...
private void init()
{
setButtonAvailability();
prevBtn.addActionListener(new ActionListener()
{
@Override
public void actionPerformed(ActionEvent arg0)
{
context.previous();
setButtonAvailability();
}
});
nextBtn.addActionListener(new ActionListener()
{
@Override
public void actionPerformed(ActionEvent e)
{
context.next();
setButtonAvailability();
}
});
}
private void setButtonAvailability()
{
prevBtn.setEnabled(context.getCurrentState().hasPrevious());
prevBtn.setEnabled(context.getCurrentState().hasNext());
}
}
When the navigation panel is initialized, the current state is the FirstState; which has no previous state. Therefore, the button to navigate to the previous state will be disabled. With each click on one of the navigation buttons, their availability is reassessed by asking the current state if it has a previous and next states. The buttons are then enabled or disabled based on current state's response.
I hope you enjoyed this article (and the rest on the series). Leave your comments below and share your ideas for new articles with me.... and don't forget to subscribe to my blog. To download this example, go to my GitHub State Pattern With Enums project. The current version in GitHub has been modified to take a command-line argument which specifies which state (and by extension, panel) will be set as the initial state, by passing the enum's simple name. For example, to start the application with the second panel visible, "SecondState" should be passed as the command-line argument.
Comments
Post a Comment