Listener Placement Adhering to the Traditional (non-mediator) MVC Pattern
Okay, first things first, Swing implements a form of the MVC already, albeit in the form of VC-M. This means that you shouldn't try and constrain Swing to a pure MVC directly, as you'll be very disappointed and spend a lot of time trying to make hacks where they shouldn't be.
Instead, you can wrap a MVC around Swing, allowing it to work around the API instead.
To my mind, a controller doesn't need to know, nor should it care, how the view or model are implemented, but it should only care how it can work with them (I've had too many developers get hold of UI components and do things to/with them that they shouldn't have, and broken the API when we've changed the implementation. It's best to hide that kind of detail)
In this vein, you can think of a view as self contained entity - it has controls and it does stuff independent of the controller. The controller doesn't care about the implementation specifics. What it does care about is getting information and been told when some event, described by the contract, has occurred. It shouldn't care about HOW it was generated.
For example, say you have a login view. The controller only wants to know the user name and password that the user entered and when it should validate that information.
Let's say you implement the view/controller to expose the JTextField
and JPasswordField
s to start with, but later on, your users want the user name selection to be restricted to a specific list (possibly provided by the model). Now you have implementation details stuck in your controller which are no longer applicable and you have to manually change or create a new MVC for this new use case.
What if, instead, you simply stated that the view has a getter for the user name and password and some kind event listener which would tell the controller when the user wanted the credentials verified? Well now, you'd only need to provide a new view, no need to modify the controller. The controller won't care HOW these values are generated.
As to the greater aspect of your question.
My program will be consisting of sub-containers and such. I am curious as to how the Controller is supposed to implement the logic behind defining and assigning all the listeners of the View.. or if the controller defining the listeners for every single View component is even practical?
It would seem that I would need a method in the View's top-level container to allow the Controller to invoke the view to add a Listener to the component in question? and so I would need a chain of methods, each passing down the listener from the top-level container, to the immediate container holding the component.. culminating with the container invoking addActionListener() on it.
Is this the proper way to handle listeners in MVC?
The general answer is, no, it's not the proper way.
Each sub view would become its own MVC, with it focusing on its own requirements. The parent MVC might use events or other functionality provided by the child MVC's to make updates or even modify the states of other child MVCs.
The important thing to remember here, is a view can act as a controller for other views, although, you might choose to have a series of controllers which the view would be allowed to manage.
Imagine something like a "wizard". It has a bunch of steps which collects various information from the user, each step needs to be valid before it can move on to the next step.
Now, you might be tempted to integrate navigation into this directly, but a better idea would be to separate the navigation details as its own MVC.
The wizard would, when asked, present a step to the user, the user would fill in the information, possibly triggering events. These events would then allow the navigation MVC to decide if the user can move to the next step or the previous step.
The two MVC's would be controlled by a third "master" MVC which would help manage the states (listening for events from the wizard and updating the state of the navigation)
Let's try an example with a question that gets asked way to much around here, a quiz!
A quiz has questions, each question has a prompt, a correct answer, a series of possible answers and we also want to store the resulting answer from the user.
The Quiz API
So, below we have the basic outline of the quiz MVC, we have a question, which is managed by a model, there is a controller and a view and series of observers (listeners)
The Contracts (interfaces)
public interface Question {
public String getPrompt();
public String getCorrectAnswer();
public String getUserAnswer();
public String[] getOptions();
public boolean isCorrect();
}
/**
* This is a deliberate choice to separate the update functionality
* No one but the model should ever actually -apply- the answer to the
* question
*/
public interface MutableQuestion extends Question {
public void setUserAnswer(String userAnswer);
}
public interface QuizModel {
public void addQuizObserver(QuizModelObserver observer);
public void removeQuizObserver(QuizModelObserver observer);
public Question getNextQuestion();
public Question getCurrentQuestion();
public int size();
public int getScore();
public void setUserAnswerFor(Question question, String answer);
}
public interface QuizModelObserver {
public void didStartQuiz(QuizModel quiz);
public void didCompleteQuiz(QuizModel quiz);
public void questionWasAnswered(QuizModel model, Question question);
}
public interface QuizView extends View {
public void setQuestion(Question question);
public boolean hasAnswer();
public String getUserAnswer();
public void addQuizObserver(QuizViewObserver observer);
public void removeQuizObserver(QuizViewObserver observer);
}
public interface QuizViewObserver {
public void userDidChangeAnswer(QuizView view);
}
public interface QuizController {
public QuizModel getModel(); // This is the model
public QuizView getView();
public void askNextQuestion();
}
I, personally, work to the principle of "code to interface (not implementation)", I've also deliberately gone overboard with the idea to demonstrate the point.
If you look closely, you will note that neither the view or model actually have any relationship to each other. This is all controlled via, the controller
One of the things I've done here is to provide the controller with a askNextQuestion
, because the controller doesn't know when that should occur (you might think about using the userDidChangeAnswer
, but that would mean that the user only gets a single attempt to answer the question, kind of mean)
The implementation
Now, normally, I like to have some abstract
implementations laying around to fill out the "common" functionality, I've forgone that for the most part and gone directly to the default implementation, this done mostly for demonstration purposes.
public class DefaultQuestion implements MutableQuestion {
private final String prompt;
private final String correctAnswer;
private String userAnswer;
private final String[] options;
public DefaultQuestion(String prompt, String correctAnswer, String... options) {
this.prompt = prompt;
this.correctAnswer = correctAnswer;
this.options = options;
}
@Override
public String getPrompt() {
return prompt;
}
@Override
public String getCorrectAnswer() {
return correctAnswer;
}
@Override
public String getUserAnswer() {
return userAnswer;
}
@Override
public String[] getOptions() {
List<String> list = new ArrayList<>(Arrays.asList(options));
Collections.shuffle(list);
return list.toArray(new String[list.size()]);
}
public void setUserAnswer(String userAnswer) {
this.userAnswer = userAnswer;
}
@Override
public boolean isCorrect() {
return getCorrectAnswer().equals(getUserAnswer());
}
}
public abstract class AbstractQuizModel implements QuizModel {
private List<QuizModelObserver> observers;
public AbstractQuizModel() {
observers = new ArrayList<>(25);
}
@Override
public void addQuizObserver(QuizModelObserver observer) {
observers.add(observer);
}
@Override
public void removeQuizObserver(QuizModelObserver observer) {
observers.remove(observer);
}
protected void fireDidStartQuiz() {
for (QuizModelObserver observer : observers) {
observer.didStartQuiz(this);
}
}
protected void fireDidCompleteQuiz() {
for (QuizModelObserver observer : observers) {
observer.didCompleteQuiz(this);
}
}
protected void fireQuestionWasAnswered(Question question) {
for (QuizModelObserver observer : observers) {
observer.questionWasAnswered(this, question);
}
}
}
public class DefaultQuizModel extends AbstractQuizModel {
private List<MutableQuestion> questions;
private Iterator<MutableQuestion> iterator;
private MutableQuestion currentQuestion;
private boolean completed;
private int score;
public DefaultQuizModel() {
questions = new ArrayList<>(50);
}
public void add(MutableQuestion question) {
questions.add(question);
}
public void remove(MutableQuestion question) {
questions.remove(question);
}
@Override
public Question getNextQuestion() {
if (!completed && iterator == null) {
iterator = questions.iterator();
fireDidStartQuiz();
}
if (iterator.hasNext()) {
currentQuestion = iterator.next();
} else {
completed = true;
iterator = null;
currentQuestion = null;
fireDidCompleteQuiz();
}
return currentQuestion;
}
@Override
public Question getCurrentQuestion() {
return currentQuestion;
}
@Override
public int size() {
return questions.size();
}
@Override
public int getScore() {
return score;
}
@Override
public void setUserAnswerFor(Question question, String answer) {
if (question instanceof MutableQuestion) {
((MutableQuestion) question).setUserAnswer(answer);
if (question.isCorrect()) {
score++;
}
fireQuestionWasAnswered(question);
}
}
}
public class DefaultQuizController implements QuizController {
private QuizModel model;
private QuizView view;
public DefaultQuizController(QuizModel model, QuizView view) {
this.model = model;
this.view = view;
}
@Override
public QuizModel getModel() {
return model;
}
@Override
public QuizView getView() {
return view;
}
@Override
public void askNextQuestion() {
Question question = getModel().getCurrentQuestion();
if (question != null) {
String answer = getView().getUserAnswer();
getModel().setUserAnswerFor(question, answer);
}
question = getModel().getNextQuestion();
getView().setQuestion(question);
}
}
public class DefaultQuizViewPane extends JPanel implements QuizView {
private final JLabel question;
private final JPanel optionsPane;
private final ButtonGroup bg;
private final List<JRadioButton> options;
private String userAnswer;
private final List<QuizViewObserver> observers;
private final AnswerActionListener answerActionListener;
private final GridBagConstraints optionsGbc;
protected DefaultQuizViewPane() {
setBorder(new EmptyBorder(4, 4, 4, 4));
question = new JLabel();
optionsPane = new JPanel(new GridBagLayout());
optionsPane.setBorder(new EmptyBorder(4, 4, 4, 4));
answerActionListener = new AnswerActionListener();
optionsGbc = new GridBagConstraints();
optionsGbc.gridwidth = GridBagConstraints.REMAINDER;
optionsGbc.weightx = 1;
optionsGbc.anchor = GridBagConstraints.WEST;
options = new ArrayList<>(25);
bg = new ButtonGroup();
observers = new ArrayList<>(25);
setLayout(new BorderLayout());
add(question, BorderLayout.NORTH);
add(optionsPane);
}
protected void reset() {
question.setText(null);
for (JRadioButton rb : options) {
rb.removeActionListener(answerActionListener);
bg.remove(rb);
optionsPane.remove(rb);
}
options.clear();
}
@Override
public void setQuestion(Question question) {
reset();
if (question != null) {
this.question.setText(question.getPrompt());
for (String option : question.getOptions()) {
JRadioButton rb = makeRadioButtonFor(option);
options.add(rb);
optionsPane.add(rb, optionsGbc);
}
optionsPane.revalidate();
revalidate();
repaint();
}
}
@Override
public void addQuizObserver(QuizViewObserver observer) {
observers.add(observer);
}
@Override
public void removeQuizObserver(QuizViewObserver observer) {
observers.remove(observer);
}
protected void fireUserDidChangeAnswer() {
for (QuizViewObserver observer : observers) {
observer.userDidChangeAnswer(this);
}
}
protected JRadioButton makeRadioButtonFor(String option) {
JRadioButton btn = new JRadioButton(option);
btn.addActionListener(answerActionListener);
bg.add(btn);
return btn;
}
@Override
public boolean hasAnswer() {
return userAnswer != null;
}
@Override
public String getUserAnswer() {
return userAnswer;
}
@Override
public JComponent getViewComponent() {
return this;
}
protected class AnswerActionListener implements ActionListener {
@Override
public void actionPerformed(ActionEvent e) {
userAnswer = e.getActionCommand();
fireUserDidChangeAnswer();
}
}
}
Really nothing fancy here. About the only thing of significant interest here is how the controller is managing the events between the model and the view
The Navigation API
The navigation API is pretty basic. It allows you to control if the user can actually navigate to the next or previous element (if the actions should be made available to the user) as well as disable either of the actions at any time
(Again, I've focused on a simple design, realistically, it would be nice to have some control over modifying the state of the model to change in which directions the navigation can work, but I've left this out on purpose to keep it simple)
The Contracts (interfaces)
public enum NavigationDirection {
NEXT, PREVIOUS;
}
public interface NavigationModel {
public boolean canNavigate(NavigationDirection direction);
public void addObserver(NavigationModelObserver observer);
public void removeObserver(NavigationModelObserver observer);
public void next();
public void previous();
}
public interface NavigationModelObserver {
public void next(NavigationModel view);
public void previous(NavigationModel view);
}
public interface NavigationController {
public NavigationView getView();
public NavigationModel getModel();
public void setDirectionEnabled(NavigationDirection navigationDirection, boolean b);
}
public interface NavigationView extends View {
public void setNavigatable(NavigationDirection direction, boolean navigtable);
public void setDirectionEnabled(NavigationDirection direction, boolean enabled);
public void addObserver(NavigationViewObserver observer);
public void removeObserver(NavigationViewObserver observer);
}
public interface NavigationViewObserver {
public void next(NavigationView view);
public void previous(NavigationView view);
}
The implementation
public static class DefaultNavigationModel implements NavigationModel {
private Set<NavigationDirection> navigatableDirections;
private List<NavigationModelObserver> observers;
public DefaultNavigationModel() {
this(true, true);
}
public DefaultNavigationModel(boolean canNavigateNext, boolean canNavigatePrevious) {
navigatableDirections = new HashSet<>(2);
observers = new ArrayList<>(25);
setCanNavigate(NavigationDirection.NEXT, canNavigateNext);
setCanNavigate(NavigationDirection.PREVIOUS, canNavigatePrevious);
}
public void setCanNavigate(NavigationDirection direction, boolean canNavigate) {
if (canNavigate) {
navigatableDirections.add(direction);
} else {
navigatableDirections.remove(direction);
}
}
@Override
public boolean canNavigate(NavigationDirection direction) {
return navigatableDirections.contains(direction);
}
@Override
public void addObserver(NavigationModelObserver observer) {
observers.add(observer);
}
@Override
public void removeObserver(NavigationModelObserver observer) {
observers.remove(observer);
}
protected void fireMoveNext() {
for (NavigationModelObserver observer : observers) {
observer.next(this);
}
}
protected void fireMovePrevious() {
for (NavigationModelObserver observer : observers) {
observer.previous(this);
}
}
@Override
public void next() {
fireMoveNext();
}
@Override
public void previous() {
fireMovePrevious();
}
}
public static class DefaultNavigationController implements NavigationController {
private final NavigationModel model;
private final NavigationView view;
public DefaultNavigationController(NavigationModel model, NavigationView view) {
this.model = model;
this.view = view;
view.setNavigatable(NavigationDirection.NEXT, model.canNavigate(NavigationDirection.NEXT));
view.setNavigatable(NavigationDirection.PREVIOUS, model.canNavigate(NavigationDirection.PREVIOUS));
view.addObserver(new NavigationViewObserver() {
@Override
public void next(NavigationView view) {
if (getModel().canNavigate(NavigationDirection.NEXT)) {
getModel().next();
}
}
@Override
public void previous(NavigationView view) {
if (getModel().canNavigate(NavigationDirection.PREVIOUS)) {
getModel().previous();
}
}
});
}
@Override
public NavigationView getView() {
return view;
}
@Override
public NavigationModel getModel() {
return model;
}
@Override
public void setDirectionEnabled(NavigationDirection navigationDirection, boolean enabled) {
getView().setDirectionEnabled(navigationDirection, enabled);
}
}
public static class DefaultNavigationViewPane extends JPanel implements NavigationView {
private final List<NavigationViewObserver> observers;
private final JButton btnNext;
private final JButton btnPrevious;
public DefaultNavigationViewPane() {
btnNext = new JButton("Next >");
btnNext.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
fireMoveNext();
}
});
btnPrevious = new JButton("< Previous");
btnPrevious.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
fireMovePrevious();
}
});
setLayout(new FlowLayout(FlowLayout.RIGHT));
add(btnPrevious);
add(btnNext);
observers = new ArrayList<>();
}
@Override
public void addObserver(NavigationViewObserver observer) {
observers.add(observer);
}
@Override
public void removeObserver(NavigationViewObserver observer) {
observers.remove(observer);
}
protected void fireMoveNext() {
for (NavigationViewObserver observer : observers) {
observer.next(this);
}
}
protected void fireMovePrevious() {
for (NavigationViewObserver observer : observers) {
observer.previous(this);
}
}
@Override
public JComponent getViewComponent() {
return this;
}
@Override
public void setNavigatable(NavigationDirection direction, boolean navigtable) {
switch (direction) {
case NEXT:
btnNext.setVisible(navigtable);
break;
case PREVIOUS:
btnPrevious.setVisible(navigtable);
break;
}
}
@Override
public void setDirectionEnabled(NavigationDirection direction, boolean enabled) {
switch (direction) {
case NEXT:
btnNext.setEnabled(enabled);
break;
case PREVIOUS:
btnPrevious.setEnabled(enabled);
break;
}
}
}
The Quiz Master
Now, these are two distinct APIs, they have nothing in common, so, we need some kind of controller to bridge them
The contracts (the interfaces)
public interface QuizMasterController {
public QuizController getQuizController();
public NavigationController getNavigationController();
public QuizMasterView getView();
}
public interface QuizMasterView extends View {
public NavigationController getNavigationController();
public QuizController getQuizController();
public void showScoreView(int score, int size);
public void showQuestionAndAnswerView();
}
Okay, so you're probably asking yourself the obvious question, where's the model? Well, it doesn't need one, it's just a bridge between the navigation and quiz APIs, it doesn't manage any data of it's own...
The implementations
public class DefaultQuizMasterController implements QuizMasterController {
private QuizController quizController;
private NavigationController navController;
private QuizMasterView view;
public DefaultQuizMasterController(QuizController quizController, NavigationController navController) {
this.quizController = quizController;
this.navController = navController;
view = new DefaultQuizMasterViewPane(quizController, navController);
// Setup the initial state
quizController.askNextQuestion();
navController.getModel().addObserver(new NavigationModelObserver() {
@Override
public void next(NavigationModel view) {
getQuizController().askNextQuestion();
getNavigationController().setDirectionEnabled(NavigationDirection.NEXT, false);
}
@Override
public void previous(NavigationModel view) {
// NOOP
}
});
quizController.getView().addQuizObserver(new QuizViewObserver() {
@Override
public void userDidChangeAnswer(WizeQuiz.QuizView view) {
getNavigationController().setDirectionEnabled(NavigationDirection.NEXT, true);
}
});
quizController.getModel().addQuizObserver(new QuizModelObserver() {
@Override
public void didStartQuiz(QuizModel quiz) {
getView().showQuestionAndAnswerView();
}
@Override
public void didCompleteQuiz(QuizModel quiz) {
getView().showScoreView(quiz.getScore(), quiz.size());
getNavigationController().setDirectionEnabled(NavigationDirection.NEXT, false);
}
@Override
public void questionWasAnswered(QuizModel model, Question question) {
}
});
navController.setDirectionEnabled(NavigationDirection.NEXT, false);
}
@Override
public QuizController getQuizController() {
return quizController;
}
@Override
public NavigationController getNavigationController() {
return navController;
}
@Override
public QuizMasterView getView() {
return view;
}
}
public class DefaultQuizMasterViewPane extends JPanel implements QuizMasterView {
private QuizController quizController;
private NavigationController navController;
private QuestionAndAnswerView qaView;
private ScoreView scoreView;
private CardLayout cardLayout;
public DefaultQuizMasterViewPane(QuizController quizController, NavigationController navController) {
this.quizController = quizController;
this.navController = navController;
quizController.getModel().addQuizObserver(new QuizModelObserver() {
@Override
public void didStartQuiz(QuizModel quiz) {
}
@Override
public void didCompleteQuiz(QuizModel quiz) {
}
@Override
public void questionWasAnswered(QuizModel model, Question question) {
qaView.updateScore();
}
});
scoreView = new ScoreView();
qaView = new QuestionAndAnswerView();
qaView.updateScore();
cardLayout = new CardLayout();
setLayout(cardLayout);
add(qaView, "view.qa");
add(scoreView, "view.score");
}
@Override
public JComponent getViewComponent() {
return this;
}
@Override
public NavigationController getNavigationController() {
return navController;
}
@Override
public QuizController getQuizController() {
return quizController;
}
@Override
public void showScoreView(int score, int size) {
scoreView.updateScore();
cardLayout.show(this, "view.score");
}
@Override
public void showQuestionAndAnswerView() {
cardLayout.show(this, "view.qa");
}
protected class QuestionAndAnswerView extends JPanel {
private JLabel score;
public QuestionAndAnswerView() {
setLayout(new BorderLayout());
add(getQuizController().getView().getViewComponent());
JPanel south = new JPanel(new BorderLayout());
south.add(getNavigationController().getView().getViewComponent(), BorderLayout.SOUTH);
score = new JLabel();
score.setHorizontalAlignment(JLabel.RIGHT);
south.add(score, BorderLayout.NORTH);
add(south, BorderLayout.SOUTH);
}
protected void updateScore() {
score.setText(getQuizController().getModel().getScore() + "/" + getQuizController().getModel().size());
}
}
protected class ScoreView extends JPanel {
private JLabel score;
public ScoreView() {
setLayout(new GridBagLayout());
score = new JLabel("You scored:");
add(score);
}
protected void updateScore() {
score.setText("You scored: " + getQuizController().getModel().getScore() + "/" + getQuizController().getModel().size());
}
}
}
Now, the implementation is interesting, it actually has two "states" or "views", the "question and answer" view and the "score view". This, again, is deliberate, because I really didn't want ANOTHER MVC. The Q&A view is already managing two MVCs any way :P
Basically, what this does is monitors the quiz API for when the user changes the answer to a question, it then tells the navigation API that it can move to the next question. It monitors the start and completed events as well, presenting the required view for those states.
It is also monitoring the navigation API for navigation events. In this example, we can only move in a single direction and even if the navigation API was configured to do otherwise, the quiz API does not provide that functionality
Put it together
Now, I've chosen to deliberately build each section separately, conceivably, you could have the QuizMasterController
build the Navigation
API itself, as it knows that the quiz API only allows for forward navigation, equally we could change the navigation API to allow those states to be modified via the model or the model changed, these are all viable solutions, I've just gone for a direct example.
NavigationModel navigationModel = new DefaultNavigationModel(true, false);
NavigationView navigationView = new DefaultNavigationViewPane();
NavigationController navigationController = new NavWiz.DefaultNavigationController(navigationModel, navigationView);
DefaultQuizModel quizModel = new DefaultQuizModel();
quizModel.add(new DefaultQuestion(
"Which pop duo was the first western band to play in The Peoples Republic of China?",
"Wham",
"Wham", "Simon and Garfunkel", "Chas and Dave", "Right Said Fred"));
quizModel.add(new DefaultQuestion(
"Timber selected from how many fully grown oak trees were needed to build a large 3 decker Royal Navy battle ship in the 18th century?",
"3,500",
"50", "500", "1,500", "3,500"));
quizModel.add(new DefaultQuestion(
"Speed skating originated in which country?",
"Netherlands",
"Russia", "Netherlands", "Canada", "Norway"));
quizModel.add(new DefaultQuestion(
"Off the coast of which country did the Amoco Cadiz sink?",
"France",
"South Africa", "France", "USA", "Spain"));
quizModel.add(new DefaultQuestion(
"The song 'An Englishman in New York' was about which man?",
"Quentin Crisp",
"Quentin Crisp", "Sting", "John Lennon", "Gordon Sumner"));
QuizView quizView = new DefaultQuizViewPane();
QuizController quizController = new DefaultQuizController(quizModel, quizView);
QuizMasterController quizMasterController = new DefaultQuizMasterController(quizController, navigationController);
JFrame frame = new JFrame("Testing");
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.add(quizMasterController.getView().getViewComponent());
frame.pack();
frame.setLocationRelativeTo(null);
frame.setVisible(true);
And finally we end up with something like...
This is nothing if not rough, but is designed to provide some ideas into how you might accomplish complex, compound MVCs