Java and GUI - Where do ActionListeners belong according to MVC pattern?

Solution 1:

That's a very difficult question to answer with Swing, as Swing is not a pure MVC implementation, the view and controller are mixed.

Technically, a model and controller should be able to interact and the controller and view should be able to interact, but the view and model should never interact, which clearly isn't how Swing works, but that's another debate...

Another issue is, you really don't want to expose UI components to anybody, the controller shouldn't care how certain actions occur, only that they can.

This would suggest that the ActionListeners attached to your UI controls should be maintained by the view. The view should then alert the controller that some kind of action has occurred. For this, you could use another ActionListener, managed by the view, to which the controller subscribes to.

Better yet, I would have a dedicated view listener, which described the actions that this view might produce, for example...

public interface MainViewListener {
    public void didPerformClose(MainView mainView);
}

The controller would then subscribe to the view via this listener and the view would call didPerformClose when (in this case) the close button is pressed.

Even in this example, I would be tempted to make a "main view" interface, which described the properties (setters and getters) and actions (listeners/callbacks) that any implementation is guaranteed to provide, then you don't care how these actions occur, only that when they do, you are expected to do something...

At each level you want to ask yourself, how easy would it be to change any element (change the model or the controller or the view) for another instance? If you find yourself having to decouple the code, then you have a problem. Communicate via interfaces and try and reduce the amount of coupling between the layers and the amount that each layer knows about the others to the point where they are simply maintaining contracts

Updated...

Let's take this for an example...

Login

There are actually two views (discounting the actual dialog), there is the credentials view and the login view, yes they are different as you will see.

CredentialsView

The credentials view is responsible for collecting the details that are to be authenticated, the user name and password. It will provide information to the controller to let it know when those credentials have been changed, as the controller may want to take some action, like enabling the "login" button...

The view will also want to know when authentication is about to take place, as it will want to disable it's fields, so the user can't update the view while the authentication is taking place, equally, it will need to know when the authentication fails or succeeds, as it will need to take actions for those eventualities.

public interface CredentialsView {

    public String getUserName();
    public char[] getPassword();

    public void willAuthenticate();
    public void authenticationFailed();
    public void authenticationSucceeded();

    public void setCredentialsViewController(CredentialsViewController listener);

}

public interface CredentialsViewController {

    public void credientialsDidChange(CredentialsView view);

}

CredentialsPane

The CredentialsPane is the physical implementation of a CredentialsView, it implements the contract, but manages it's own internal state. How the contract is managed is irrelevent to the controller, it only cares about the contract been upheld...

public class CredentialsPane extends JPanel implements CredentialsView {

    private CredentialsViewController controller;

    private JTextField userNameField;
    private JPasswordField passwordField;

    public CredentialsPane(CredentialsViewController controller) {
        setCredentialsViewController(controller);
        setLayout(new GridBagLayout());
        userNameField = new JTextField(20);
        passwordField = new JPasswordField(20);

        GridBagConstraints gbc = new GridBagConstraints();
        gbc.gridx = 0;
        gbc.gridy = 0;
        gbc.insets = new Insets(2, 2, 2, 2);
        gbc.anchor = GridBagConstraints.EAST;
        add(new JLabel("Username: "), gbc);

        gbc.gridy++;
        add(new JLabel("Password: "), gbc);

        gbc.gridx = 1;
        gbc.gridy = 0;
        gbc.anchor = GridBagConstraints.WEST;
        gbc.fill = GridBagConstraints.HORIZONTAL;
        add(userNameField, gbc);
        gbc.gridy++;
        add(passwordField, gbc);

        DocumentListener listener = new DocumentListener() {
            @Override
            public void insertUpdate(DocumentEvent e) {
                getCredentialsViewController().credientialsDidChange(CredentialsPane.this);
            }

            @Override
            public void removeUpdate(DocumentEvent e) {
                getCredentialsViewController().credientialsDidChange(CredentialsPane.this);
            }

            @Override
            public void changedUpdate(DocumentEvent e) {
                getCredentialsViewController().credientialsDidChange(CredentialsPane.this);
            }
        };

        userNameField.getDocument().addDocumentListener(listener);
        passwordField.getDocument().addDocumentListener(listener);

    }

    @Override
    public CredentialsViewController getCredentialsViewController() {
        return controller;
    }

    @Override
    public String getUserName() {
        return userNameField.getText();
    }

    @Override
    public char[] getPassword() {
        return passwordField.getPassword();
    }

    @Override
    public void willAuthenticate() {
        userNameField.setEnabled(false);
        passwordField.setEnabled(false);
    }

    @Override
    public void authenticationFailed() {
        userNameField.setEnabled(true);
        passwordField.setEnabled(true);

        userNameField.requestFocusInWindow();
        userNameField.selectAll();

        JOptionPane.showMessageDialog(this, "Authentication has failed", "Error", JOptionPane.ERROR_MESSAGE);
    }

    @Override
    public void authenticationSucceeded() {
        // Really don't care, but you might want to stop animation, for example...
    }

    public void setCredentialsViewController(CredentialsViewController controller){
        this.controller = controller;
    }

}

LoginView

The LoginView is responsible for managing a CredentialsView, but also for notifying the LoginViewController when authentication should take place or if the process was cancelled by the user, via some means...

Equally, the LoginViewController will tell the view when authentication is about to take place and if the authentication failed or was successful.

public interface LoginView {

    public CredentialsView getCredentialsView();

    public void willAuthenticate();
    public void authenticationFailed();
    public void authenticationSucceeded();

    public void dismissView();

    public LoginViewController getLoginViewController();

}

public interface LoginViewController {

    public void authenticationWasRequested(LoginView view);
    public void loginWasCancelled(LoginView view);

}

LoginPane

The LoginPane is kind of special, it is acting as the view for the LoginViewController, but it is also acting as the controller for the CredentialsView. This is important, as there is nothing saying that a view can't be a controller, but I would be careful about how you implement such things, as it might not always make sense to do it this way, but because the two views are working together to gather information and manage events, it made sense in this case.

Because the LoginPane will need to change it's own state based on the changes in the CredentialsView, it makes sense to allow the LoginPane to act as the controller in this case, otherwise, you'd need to supply more methods that controlled that state of the buttons, but this starts to bleed UI logic over to the controller...

public static class LoginPane extends JPanel implements LoginView, CredentialsViewController {

    private LoginViewController controller;
    private CredentialsPane credientialsView;

    private JButton btnAuthenticate;
    private JButton btnCancel;

    private boolean wasAuthenticated;

    public LoginPane(LoginViewController controller) {
        setLoginViewController(controller);
        setLayout(new BorderLayout());
        setBorder(new EmptyBorder(8, 8, 8, 8));

        btnAuthenticate = new JButton("Login");
        btnCancel = new JButton("Cancel");

        JPanel buttons = new JPanel();
        buttons.add(btnAuthenticate);
        buttons.add(btnCancel);

        add(buttons, BorderLayout.SOUTH);

        credientialsView = new CredentialsPane(this);
        add(credientialsView);

        btnAuthenticate.addActionListener(new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent e) {
                getLoginViewController().authenticationWasRequested(LoginPane.this);
            }
        });
        btnCancel.addActionListener(new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent e) {
                getLoginViewController().loginWasCancelled(LoginPane.this);
                // I did think about calling dispose here,
                // but's not really the the job of the cancel button to decide what should happen here...
            }
        });

        validateCreientials();

    }

    public static boolean showLoginDialog(LoginViewController controller) {

        final LoginPane pane = new LoginPane(controller);

        JDialog dialog = new JDialog();
        dialog.setTitle("Login");
        dialog.setModal(true);
        dialog.add(pane);
        dialog.pack();
        dialog.setLocationRelativeTo(null);
        dialog.setDefaultCloseOperation(JDialog.DO_NOTHING_ON_CLOSE);
        dialog.addWindowListener(new WindowAdapter() {
            @Override
            public void windowClosing(WindowEvent e) {
                pane.getLoginViewController().loginWasCancelled(pane);
            }
        });
        dialog.setVisible(true);

        return pane.wasAuthenticated();

    }

    public boolean wasAuthenticated() {
        return wasAuthenticated;
    }

    public void validateCreientials() {

        CredentialsView view = getCredentialsView();
        String userName = view.getUserName();
        char[] password = view.getPassword();
        if ((userName != null && userName.trim().length() > 0) && (password != null && password.length > 0)) {

            btnAuthenticate.setEnabled(true);

        } else {

            btnAuthenticate.setEnabled(false);

        }

    }

    @Override
    public void dismissView() {
        SwingUtilities.windowForComponent(this).dispose();
    }

    @Override
    public CredentialsView getCredentialsView() {
        return credientialsView;
    }

    @Override
    public void willAuthenticate() {
        getCredentialsView().willAuthenticate();
        btnAuthenticate.setEnabled(false);
    }

    @Override
    public void authenticationFailed() {
        getCredentialsView().authenticationFailed();
        validateCreientials();
        wasAuthenticated = false;
    }

    @Override
    public void authenticationSucceeded() {
        getCredentialsView().authenticationSucceeded();
        validateCreientials();
        wasAuthenticated = true;
    }

    public LoginViewController getLoginViewController() {
        return controller;
    }

    public void setLoginViewController(LoginViewController controller) {
        this.controller = controller;
    }

    @Override
    public void credientialsDidChange(CredentialsView view) {
        validateCreientials();
    }

}

Working example

import java.awt.BorderLayout;
import java.awt.EventQueue;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.util.Random;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.swing.JButton;
import javax.swing.JDialog;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JPasswordField;
import javax.swing.JTextField;
import javax.swing.SwingUtilities;
import javax.swing.UIManager;
import javax.swing.UnsupportedLookAndFeelException;
import javax.swing.border.EmptyBorder;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import sun.net.www.protocol.http.HttpURLConnection;

public class Test {

    protected static final Random AUTHENTICATION_ORACLE = new Random();

    public static void main(String[] args) {
        new Test();
    }

    public interface CredentialsView {
        public String getUserName();
        public char[] getPassword();
        public void willAuthenticate();
        public void authenticationFailed();
        public void authenticationSucceeded();
        public CredentialsViewController getCredentialsViewController();
    }

    public interface CredentialsViewController {
        public void credientialsDidChange(CredentialsView view);
    }

    public interface LoginView {
        public CredentialsView getCredentialsView();
        public void willAuthenticate();
        public void authenticationFailed();
        public void authenticationSucceeded();
        public void dismissView();
        public LoginViewController getLoginViewController();
    }

    public interface LoginViewController {
        public void authenticationWasRequested(LoginView view);
        public void loginWasCancelled(LoginView view);
    }

    public Test() {
        EventQueue.invokeLater(new Runnable() {
            @Override
            public void run() {
                try {
                    UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
                } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | UnsupportedLookAndFeelException ex) {
                    ex.printStackTrace();
                }

                LoginViewController controller = new LoginViewController() {

                    @Override
                    public void authenticationWasRequested(LoginView view) {
                        view.willAuthenticate();
                        LoginAuthenticator authenticator = new LoginAuthenticator(view);
                        authenticator.authenticate();
                    }

                    @Override
                    public void loginWasCancelled(LoginView view) {

                        view.dismissView();

                    }
                };

                if (LoginPane.showLoginDialog(controller)) {

                    System.out.println("You shell pass");

                } else {

                    System.out.println("You shell not pass");

                }

                System.exit(0);

            }
        });
    }

    public class LoginAuthenticator {

        private LoginView view;

        public LoginAuthenticator(LoginView view) {
            this.view = view;
        }

        public void authenticate() {

            Thread t = new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(2000);
                    } catch (InterruptedException ex) {
                        Logger.getLogger(Test.class.getName()).log(Level.SEVERE, null, ex);
                    }
                    SwingUtilities.invokeLater(new Runnable() {
                        @Override
                        public void run() {
                            if (AUTHENTICATION_ORACLE.nextBoolean()) {
                                view.authenticationSucceeded();
                                view.dismissView();
                            } else {
                                view.authenticationFailed();
                            }
                        }
                    });
                }
            });
            t.start();

        }

    }

    public static class LoginPane extends JPanel implements LoginView, CredentialsViewController {

        private LoginViewController controller;
        private CredentialsPane credientialsView;

        private JButton btnAuthenticate;
        private JButton btnCancel;

        private boolean wasAuthenticated;

        public LoginPane(LoginViewController controller) {
            setLoginViewController(controller);
            setLayout(new BorderLayout());
            setBorder(new EmptyBorder(8, 8, 8, 8));

            btnAuthenticate = new JButton("Login");
            btnCancel = new JButton("Cancel");

            JPanel buttons = new JPanel();
            buttons.add(btnAuthenticate);
            buttons.add(btnCancel);

            add(buttons, BorderLayout.SOUTH);

            credientialsView = new CredentialsPane(this);
            add(credientialsView);

            btnAuthenticate.addActionListener(new ActionListener() {
                @Override
                public void actionPerformed(ActionEvent e) {
                    getLoginViewController().authenticationWasRequested(LoginPane.this);
                }
            });
            btnCancel.addActionListener(new ActionListener() {
                @Override
                public void actionPerformed(ActionEvent e) {
                    getLoginViewController().loginWasCancelled(LoginPane.this);
                    // I did think about calling dispose here,
                    // but's not really the the job of the cancel button to decide what should happen here...
                }
            });

            validateCreientials();

        }

        public static boolean showLoginDialog(LoginViewController controller) {

            final LoginPane pane = new LoginPane(controller);

            JDialog dialog = new JDialog();
            dialog.setTitle("Login");
            dialog.setModal(true);
            dialog.add(pane);
            dialog.pack();
            dialog.setLocationRelativeTo(null);
            dialog.setDefaultCloseOperation(JDialog.DO_NOTHING_ON_CLOSE);
            dialog.addWindowListener(new WindowAdapter() {
                @Override
                public void windowClosing(WindowEvent e) {
                    pane.getLoginViewController().loginWasCancelled(pane);
                }
            });
            dialog.setVisible(true);

            return pane.wasAuthenticated();

        }

        public boolean wasAuthenticated() {
            return wasAuthenticated;
        }

        public void validateCreientials() {

            CredentialsView view = getCredentialsView();
            String userName = view.getUserName();
            char[] password = view.getPassword();
            if ((userName != null && userName.trim().length() > 0) && (password != null && password.length > 0)) {

                btnAuthenticate.setEnabled(true);

            } else {

                btnAuthenticate.setEnabled(false);

            }

        }

        @Override
        public void dismissView() {
            SwingUtilities.windowForComponent(this).dispose();
        }

        @Override
        public CredentialsView getCredentialsView() {
            return credientialsView;
        }

        @Override
        public void willAuthenticate() {
            getCredentialsView().willAuthenticate();
            btnAuthenticate.setEnabled(false);
        }

        @Override
        public void authenticationFailed() {
            getCredentialsView().authenticationFailed();
            validateCreientials();
            wasAuthenticated = false;
        }

        @Override
        public void authenticationSucceeded() {
            getCredentialsView().authenticationSucceeded();
            validateCreientials();
            wasAuthenticated = true;
        }

        public LoginViewController getLoginViewController() {
            return controller;
        }

        public void setLoginViewController(LoginViewController controller) {
            this.controller = controller;
        }

        @Override
        public void credientialsDidChange(CredentialsView view) {
            validateCreientials();
        }

    }

    public static class CredentialsPane extends JPanel implements CredentialsView {

        private CredentialsViewController controller;

        private JTextField userNameField;
        private JPasswordField passwordField;

        public CredentialsPane(CredentialsViewController controller) {
            setCredentialsViewController(controller);
            setLayout(new GridBagLayout());
            userNameField = new JTextField(20);
            passwordField = new JPasswordField(20);

            GridBagConstraints gbc = new GridBagConstraints();
            gbc.gridx = 0;
            gbc.gridy = 0;
            gbc.insets = new Insets(2, 2, 2, 2);
            gbc.anchor = GridBagConstraints.EAST;
            add(new JLabel("Username: "), gbc);

            gbc.gridy++;
            add(new JLabel("Password: "), gbc);

            gbc.gridx = 1;
            gbc.gridy = 0;
            gbc.anchor = GridBagConstraints.WEST;
            gbc.fill = GridBagConstraints.HORIZONTAL;
            add(userNameField, gbc);
            gbc.gridy++;
            add(passwordField, gbc);

            DocumentListener listener = new DocumentListener() {
                @Override
                public void insertUpdate(DocumentEvent e) {
                    getCredentialsViewController().credientialsDidChange(CredentialsPane.this);
                }

                @Override
                public void removeUpdate(DocumentEvent e) {
                    getCredentialsViewController().credientialsDidChange(CredentialsPane.this);
                }

                @Override
                public void changedUpdate(DocumentEvent e) {
                    getCredentialsViewController().credientialsDidChange(CredentialsPane.this);
                }
            };

            userNameField.getDocument().addDocumentListener(listener);
            passwordField.getDocument().addDocumentListener(listener);

        }

        @Override
        public CredentialsViewController getCredentialsViewController() {
            return controller;
        }

        @Override
        public String getUserName() {
            return userNameField.getText();
        }

        @Override
        public char[] getPassword() {
            return passwordField.getPassword();
        }

        @Override
        public void willAuthenticate() {
            userNameField.setEnabled(false);
            passwordField.setEnabled(false);
        }

        @Override
        public void authenticationFailed() {
            userNameField.setEnabled(true);
            passwordField.setEnabled(true);

            userNameField.requestFocusInWindow();
            userNameField.selectAll();

            JOptionPane.showMessageDialog(this, "Authentication has failed", "Error", JOptionPane.ERROR_MESSAGE);
        }

        @Override
        public void authenticationSucceeded() {
            // Really don't care, but you might want to stop animation, for example...
        }

        public void setCredentialsViewController(CredentialsViewController controller) {
            this.controller = controller;
        }

    }

}

Solution 2:

They are associated with the control, but they don't have to be a direct part of the control. For instance, please see the code posted below that I was preparing for another question, one on anonymous inner classes and coupling, here I give all my buttons anonymous inner Actions (which are ActionListeners, of course), and then use the Actions to change the GUI state. Any listeners to the GUI (the control) will be notified of this change, and can then act accordingly.

import java.awt.*;
import java.awt.event.*; 
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;

import javax.swing.*;
import javax.swing.event.SwingPropertyChangeSupport;

public class AnonymousInnerEg2 {
   private static void createAndShowUI() {
      GuiModel2 model = new GuiModel2();
      GuiPanel2 guiPanel = new GuiPanel2();
      GuiControl2 guiControl = new GuiControl2();
      guiControl.setGuiPanel(guiPanel);
      guiControl.setGuiModel(model);
      try {
         guiControl.init();
      } catch (GuiException2 e) {
         e.printStackTrace();
         System.exit(-1);
      }

      JFrame frame = new JFrame("AnonymousInnerEg");
      frame.getContentPane().add(guiPanel);
      frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
      frame.pack();
      frame.setLocationRelativeTo(null);
      frame.setVisible(true);
   }

   public static void main(String[] args) {
      java.awt.EventQueue.invokeLater(new Runnable() {
         public void run() {
            createAndShowUI();
         }
      });
   }
}

enum GuiState {
   BASE("Base"), START("Start"), END("End");
   private String name;

   private GuiState(String name) {
      this.name = name;
   }

   public String getName() {
      return name;
   }

}

class GuiModel2 {
   public static final String STATE = "state";
   private SwingPropertyChangeSupport support = new SwingPropertyChangeSupport(this);
   private GuiState state = GuiState.BASE;

   public GuiState getState() {
      return state;
   }

   public void setState(GuiState state) {
      GuiState oldValue = this.state;
      GuiState newValue = state;
      this.state = state;
      support.firePropertyChange(STATE, oldValue, newValue);
   }
   
   public void addPropertyChangeListener(PropertyChangeListener l) {
      support.addPropertyChangeListener(l);
   }

   public void removePropertyChangeListener(PropertyChangeListener l) {
      support.removePropertyChangeListener(l);
   }
}

@SuppressWarnings("serial")
class GuiPanel2 extends JPanel {
   public static final String STATE = "state";
   private String state = GuiState.BASE.getName();
   private JLabel stateField = new JLabel("", SwingConstants.CENTER);

   public GuiPanel2() {

      JPanel btnPanel = new JPanel(new GridLayout(1, 0, 5, 0));
      for (final GuiState guiState : GuiState.values()) {
         btnPanel.add(new JButton(new AbstractAction(guiState.getName()) {
            {
               int mnemonic = (int) getValue(NAME).toString().charAt(0);
               putValue(MNEMONIC_KEY, mnemonic);
            }

            @Override
            public void actionPerformed(ActionEvent e) {
               String name = getValue(NAME).toString();
               setState(name);
            }
         }));
      }
      
      setLayout(new BorderLayout());
      add(stateField, BorderLayout.PAGE_START);
      add(btnPanel, BorderLayout.CENTER);
   }

   public String getState() {
      return state;
   }

   public void setState(String state) {
      String oldValue = this.state;
      String newValue = state;
      this.state = state;
      firePropertyChange(STATE, oldValue, newValue);
   }
   
   public void setStateField(String name) {
      stateField.setText(name);
   }

}

class GuiControl2 {
   private GuiPanel2 guiPanel;
   private GuiModel2 model;
   private boolean allOK = false;

   public void setGuiPanel(GuiPanel2 guiPanel) {
      this.guiPanel = guiPanel;
      guiPanel.addPropertyChangeListener(GuiPanel2.STATE,
            new GuiPanelStateListener());
   }
   
   public void init() throws GuiException2 {
      if (model == null) {
         throw new GuiException2("Model is null");
      }
      if (guiPanel == null) {
         throw new GuiException2("GuiPanel is null");
      }
      allOK = true;
      guiPanel.setStateField(model.getState().getName());
   }

   public void setGuiModel(GuiModel2 model) {
      this.model = model;
      model.addPropertyChangeListener(new ModelListener());
   }

   private class GuiPanelStateListener implements PropertyChangeListener {
      @Override
      public void propertyChange(PropertyChangeEvent evt) {
         if (!allOK) {
            return;
         }
         if (GuiPanel2.STATE.equals(evt.getPropertyName())) {
            String text = guiPanel.getState();
            model.setState(GuiState.valueOf(text.toUpperCase()));
         }
      }
   }
   
   private class ModelListener implements PropertyChangeListener {
      @Override
      public void propertyChange(PropertyChangeEvent evt) {
         if (!allOK) {
            return;
         }
         if (GuiModel2.STATE.equals(evt.getPropertyName())) {
            GuiState state = (GuiState) evt.getNewValue();
            guiPanel.setStateField(state.getName());
         }
      }
   }
}

@SuppressWarnings("serial")
class GuiException2 extends Exception {

   public GuiException2() {
      super();
   }

   public GuiException2(String message) {
      super(message);
   }
}

Note in warning though: I am not a professional coder or even a university trained coder, so please take this as just my opinion only.

Solution 3:

Introduction

The model / view / controller (MVC) pattern can be applied to Swing in the following way.

  1. The view reads information from the model.
  2. The view may not update the model.
  3. The controller will update the model and repaint / revalidate the view.

Now, in Swing, there's usually no master controller to "rule them all". Each ActionListener and AbstractAction is its own controller, responsible for the actions of that particular JButton or keyboard key binding.

Here's an example of a Swing GUI coded with MVC in mind.

Ripples

This GUI draws ever-widening circles where you left-click on the drawing JPanel. Like ripples in a pond. The GUI is simple enough to explain in a Stack Overflow answer but complex enough to serve as a good MVC illustration.

Explanation

The MVC pattern is called the MVC pattern because you create the model first, then the view, and finally the controllers. Now, this can be an iterative process. Often, I find I need something in the model when I'm constructing the view.

Starting with the view, or worse, the controllers, usually leads to a mess that can't be tested or debugged. Sure, sometimes you need the view to verify the model. But I'm not so good a developer that I can write dozens of lines of working code.

I write a little, test a lot, and usually find that I've caused problems for myself. Since I write a little, I only have a little bit of code to debug at a time.

Model

The model consists of two classes, the RipplesModel class and the Circle class. The Circle class is a plain Java getter / setter class that holds the center java.awt.Point, the int radius, and a java.awt.Color for the outline color. Yes, I'm using java.awt classes in the model. These awt classes are meant to hold information for drawing.

The RipplesModel class is a plain Java getter / setter class that holds a java.util.List of Circle instances. This List will be used by the drawing JPanel to draw the Circle instances.

Generally, your Swing model will consist of one or more plain Java getter / setter classes.

View

The view consists of a JFrame and a drawing JPanel. The JFrame code contains a WindowListener so that I can stop the animation Thread. The WindowListener is one of the controller classes. It's pretty simple, so I made it an anonymous class.

The controller code can reside inside one of the view classes. The MVC model doesn't dictate where the code resides. The MVC model dictates where the code is executed.

The drawing JPanel draws the Circle instances on the drawing JPanel. Period. The controllers are responsible for changing the radius of the Circle instances and running the animation.

I use a JFrame. I extend a JPanel because I override the paintComponent method. The only time you should extend a Swing component is when you want to override one or more of the class methods.

Controller

There are three controller classes in this GUI. I've already mentioned the anonymous WindowListener class that stops the animation Thread.

The RipplesListener class is a MouseListener that listens for a mouse press. The listener creates a Circle instance where you left-click.

The Animation Runnable increments the radius of each Circle instance, and calls for a repaint of the drawing JPanel. The JFrame class contains a repaint method, which in turn calls the drawing JPanel repaint method.

The controller classes don't have to know how the view works. They just have to know that the JFrame (main view) class has a repaint method. This helps to enforce a separation of concerns, which is one of the main reasons you use the MVC pattern.

The Animation Runnable runs in a separate Thread, so that the GUI Thread, the Event Dispatch Thread, is not blocked. Today, I'd probably use a Swing Timer, but when I wrote this, I was used to writing my own animation Runnable.

Code

Here's the complete runnable code. I made all the classes inner classes so I could post this code as one block.

import java.awt.BasicStroke;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Point;
import java.awt.RenderingHints;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;

import javax.swing.JFrame;
import javax.swing.JPanel;
import javax.swing.SwingUtilities;

public class Ripples implements Runnable {

    public static void main(String[] args) {
        SwingUtilities.invokeLater(new Ripples());
    }

    private Animation animation;

    private DrawingPanel drawingPanel;

    private RipplesModel model;

    public Ripples() {
        model = new RipplesModel();
    }

    @Override
    public void run() {
        JFrame frame = new JFrame("Ripples");
        frame.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
        frame.addWindowListener(new WindowAdapter() {
            @Override
            public void windowClosing(WindowEvent event) {
                stopAnimation();
                frame.dispose();
                System.exit(0);
            }
        });

        drawingPanel = new DrawingPanel(model);
        frame.add(drawingPanel, BorderLayout.CENTER);

        frame.pack();
        frame.setLocationByPlatform(true);
        frame.setVisible(true);

        animation = new Animation(this, model);
        new Thread(animation).start();
    }

    public void repaint() {
        drawingPanel.repaint();
    }

    private void stopAnimation() {
        if (animation != null) {
            animation.setRunning(false);
        }
    }

    public class DrawingPanel extends JPanel {

        private static final long serialVersionUID = 1L;

        private RipplesModel model;

        public DrawingPanel(RipplesModel model) {
            this.model = model;
            setBackground(Color.BLACK);
            setPreferredSize(new Dimension(500, 500));
            addMouseListener(new RipplesListener(model));
        }

        @Override
        public void paintComponent(Graphics g) {
            super.paintComponent(g);

            Graphics2D g2 = (Graphics2D) g;
            g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
                    RenderingHints.VALUE_ANTIALIAS_ON);
            g2.setStroke(new BasicStroke(5f));

            List<Circle> circles = model.getCircles();
            for (Circle circle : circles) {
                Point p = circle.getCenter();
                int radius = circle.getRadius();
                g2.setColor(circle.getColor());
                g2.drawOval(p.x - radius, p.y - radius,
                        2 * radius, 2 * radius);
            }
        }

    }

    public class RipplesListener extends MouseAdapter {

        private Random random;

        private RipplesModel model;

        public RipplesListener(RipplesModel model) {
            this.model = model;
            this.random = new Random();
        }

        @Override
        public void mousePressed(MouseEvent event) {
            model.addCircle(new Circle(event.getPoint(),
                    createColor()));
        }

        private Color createColor() {
            int r = random.nextInt(128) + 128;
            int g = random.nextInt(128) + 128;
            int b = random.nextInt(128) + 128;
            return new Color(r, g, b);
        }
    }

    public class Animation implements Runnable {

        private volatile boolean running;

        private Ripples frame;

        private RipplesModel model;

        public Animation(Ripples frame, RipplesModel model) {
            this.frame = frame;
            this.model = model;
            this.running = true;
        }

        @Override
        public void run() {
            while (running) {
                sleep(20L);
                incrementRadius();
                repaint();
            }
        }

        private void incrementRadius() {
            List<Circle> circles = model.getCircles();
            for (Circle circle : circles) {
                circle.incrementRadius();
            }
        }

        private void sleep(long delay) {
            try {
                Thread.sleep(delay);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        private void repaint() {
            SwingUtilities.invokeLater(new Runnable() {
                @Override
                public void run() {
                    frame.repaint();
                }
            });
        }

        public synchronized void setRunning(boolean running) {
            this.running = running;
        }

    }

    public class RipplesModel {

        private List<Circle> circles;

        public RipplesModel() {
            this.circles = new ArrayList<>();
        }

        public void addCircle(Circle circle) {
            this.circles.add(circle);
        }

        public List<Circle> getCircles() {
            return circles;
        }

    }

    public class Circle {

        private int radius;

        private final Color color;

        private final Point center;

        public Circle(Point center, Color color) {
            this.center = center;
            this.color = color;
            this.radius = 10;
        }

        public void incrementRadius() {
            radius = (++radius > 200) ? 10 : radius;
        }

        public Color getColor() {
            return color;
        }

        public int getRadius() {
            return radius;
        }

        public Point getCenter() {
            return center;
        }

    }

}