Interaktiv grafikk


Model-View-Controller

Når man skriver interaktive grafiske programmer, kan koden fort bli rotete og uoversiktelig. For å hjelpe oss å skrive oversiktelig kode det er mulig å feilsøke, benytter vi oss av et prinssipp som kalles model-view-controller (MVC). I dette paradigmet er det tre sentrale begreper:

Prinsippet om model-view-controller

Prinsippet for oss som følger MVC -prinsippet er:

  • Visningskode kan se på men ikke mutere modellen.
  • Visningskode skal ikke forholde seg til kontrolleren.

I tillegg til modellen, visningen og kontrolleren, er det ofte en del av koden som benyttes for å definere layout i visningen, knytte sammen ulike deler av programmet og konfigurere når bruker-interaksjoner med elementer i visningen starter kontroller-metodene. Dette er som regel kode som befinner seg i main-metoden og konstruktørene til klasser tilhørende visningen og kontrolleren, og som er ferdig utført før programmet faktisk vises på skjermen.

Første eksempel: en teller

Vi viser her et første eksempel på en applikasjon basert på rammeverket Swing innebygget i Java. Eksempelet består av fire klasser og ett grensesnitt, og er ment å gi et eksempel på bruk av model-view-controller.

Illustrasjon av teller-applikasjon

import javax.swing.JFrame;

public class Main {
  public static void main(String[] args) {
    Model model = new Model();
    View view = new View(model);
    new Controller(model, view);

    JFrame frame = new JFrame();
    frame.setTitle("INF101");
    frame.setContentPane(view);
    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    frame.pack();
    frame.setVisible(true);
  }
}
public class Model implements ViewableModel {
  private int count = 0;

  @Override
  public int getCount() {
    return this.count;
  }
  
  /** Increment the counter. */
  public void increment() {
    this.count++;
  }
}
public interface ViewableModel {
  /** Get the current count. */
  int getCount();
}
import javax.swing.JPanel;
import java.awt.Dimension;
import java.awt.Graphics;

public class View extends JPanel {
  private final ViewableModel model;

  public View(ViewableModel model) {
    this.model = model;
    this.setPreferredSize(new Dimension(150, 50));
  }

  @Override
  protected void paintComponent(Graphics g) {
    super.paintComponent(g);
    g.drawString("" + this.model.getCount(), this.getWidth()/2, this.getHeight()/2);
  }
}
import java.awt.Component;
import java.awt.event.KeyEvent;
import java.awt.event.KeyListener;

public class Controller implements KeyListener {
  private final Model model;
  private final Component view;

  public Controller(Model model, Component view) {
    this.model = model;
    this.view = view;

    // Capture keyboard input when the view is in focus and send it here
    this.view.setFocusable(true);
    this.view.addKeyListener(this);
  }

  @Override
  public void keyPressed(KeyEvent e) {
    this.model.increment();
    this.view.repaint();
  }

  @Override
  public void keyTyped(KeyEvent e) {
    // ignore
  }

  @Override
  public void keyReleased(KeyEvent e) {
    // ignore
  }
}

UML klassediagram for første eksempel

UML-diagram som viser relasjonene mellom klassene i første eksempel. Klassene JPanel og Component kommer fra Java sitt standardbibliotek.

Eksempel med flere kontrollere: en flyttbar ball

I dette eksempelet lager vi en ball som flytter seg på skjermen. Langs x-aksen flytter ballen seg automatisk med en timer, langs y-asken kan man kontrollere ballens posisjon med pil opp og pil ned. Dersom man klikker med musen, endrer ballen både x- og y-posisjon, og dersom man endrer vinduets størrelse flyttes ballen relativt til sin posisjon i vinduet.

Illustrasjon av ball-applikasjon

For å få til dette bruker vi fire ulike kontrollere:

import javax.swing.JFrame;
import java.awt.Color;
import java.awt.Dimension;

public class Main {
  public static void main(String[] args) {
    // Create model and view
    BallModel model = new BallModel(Color.RED, 200, 75, 50);
    BallView view = new BallView(model, new Dimension(400, 150));

    // Create controllers
    new ControllerTimer(model, view);
    new ControllerKeyPressed(model, view);
    new ControllerMouseClicked(model, view);
    new ControllerResize(model, view);

    // Put application in a window
    JFrame frame = new JFrame();
    frame.setTitle("INF101");
    frame.setContentPane(view);
    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    frame.pack();
    frame.setVisible(true);
  }
}
import java.awt.Color;

public class BallModel implements ViewableBallModel {
  private final Color color;
  private double x;
  private double y;
  private double radius;

  public BallModel(Color color, double x, double y, double radius) {
    this.color = color;
    this.x = x;
    this.y = y;
    this.radius = radius;
  }

  @Override
  public Color getColor() {
    return this.color;
  }

  @Override
  public double getX() {
    return this.x;
  }

  @Override
  public double getY() {
    return this.y;
  }

  @Override
  public double getRadius() {
    return this.radius;
  }

  /** Set the x position of the ball. */
  public void setX(double x) {
    this.x = x;
  }

  /** Set the y position of the ball. */
  public void setY(double y) {
    this.y = y;
  }
}
import java.awt.Color;

public interface ViewableBallModel {

  /** Get the color of the ball. */
  Color getColor();

  /** Get the x-coordinate of the ball. */
  double getX();

  /** Get the y-coordinate of the ball. */
  double getY();

  /** Get the radius of the ball. */
  double getRadius();
}
import javax.swing.JPanel;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.geom.Ellipse2D;

public class BallView extends JPanel {

  private final ViewableBallModel model;

  public BallView(ViewableBallModel model, Dimension preferredSize) {
    this.model = model;
    this.setPreferredSize(preferredSize);
  }

  @Override
  protected void paintComponent(Graphics g) {
    super.paintComponent(g);
    Graphics2D g2 = (Graphics2D) g;

    g2.setColor(this.model.getColor());
    Ellipse2D ball = new Ellipse2D.Double(
        this.model.getX() - this.model.getRadius(),
        this.model.getY() - this.model.getRadius(),
        model.getRadius() * 2,
        model.getRadius() * 2
    );
    g2.fill(ball);
  }
}
import java.awt.Component;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import javax.swing.Timer;

public class ControllerTimer implements ActionListener {

  private final BallModel model;
  private final Component view;
  private final Timer timer;

  public ControllerTimer(BallModel model, Component view) {
    this.model = model;
    this.view = view;
    this.timer = new Timer(1000/30, this); // 30 times per second
    this.timer.start();
  }

  @Override
  public void actionPerformed(ActionEvent e) {
    this.model.setX(this.model.getX() + 2);
    if (this.model.getX() > this.view.getWidth()) {
      this.model.setX(0);
    }
    this.view.repaint();
  }
}
import java.awt.Component;
import java.awt.event.KeyEvent;
import java.awt.event.KeyListener;

public class ControllerKeyPressed implements KeyListener {

  private final BallModel model;
  private final Component view;

  public ControllerKeyPressed(BallModel model, Component view) {
    this.model = model;
    this.view = view;

    // Prepare for keyboard input
    this.view.setFocusable(true);
    this.view.addKeyListener(this);
  }

  @Override
  public void keyPressed(KeyEvent e) {
    // Move the ball
    if (e.getKeyCode() == KeyEvent.VK_UP) {
      this.model.setY(this.model.getY() - 10);
    }
    elif (e.getKeyCode() == KeyEvent.VK_DOWN) {
      this.model.setY(this.model.getY() + 10);
    }
    this.view.repaint();
  }

  @Override public void keyTyped(KeyEvent e) { /* ignore */ }
  @Override public void keyReleased(KeyEvent e) { /* ignore */}
}
import java.awt.Component;
import java.awt.event.MouseEvent;
import java.awt.event.MouseListener;

public class ControllerMouseClicked implements MouseListener {
  private final BallModel model;
  private final Component view;

  public ControllerMouseClicked(BallModel model, Component view) {
    this.model = model;
    this.view = view;

    // Configure view for mouse input
    this.view.addMouseListener(this);
  }

  @Override
  public void mouseClicked(MouseEvent e) {
    // Move the ball
    this.model.setX(e.getX());
    this.model.setY(e.getY());
    this.view.repaint();
  }

  @Override public void mousePressed(MouseEvent e) { /* ignore */ }
  @Override public void mouseReleased(MouseEvent e) { /* ignore */ }
  @Override public void mouseEntered(MouseEvent e) { /* ignore */ }
  @Override public void mouseExited(MouseEvent e) { /* ignore */ }
}
import java.awt.Component;
import java.awt.Dimension;
import java.awt.event.ComponentEvent;
import java.awt.event.ComponentListener;

public class ControllerResize implements ComponentListener {
  private final BallModel model;
  private final Component view;
  private Dimension currentDimension;

  public ControllerResize(BallModel model, Component view) {
    this.model = model;
    this.view = view;

    // Prepare for handling resize events
    // Assuming the ball is initiated with position relative to preferred size
    this.currentDimension = this.view.getPreferredSize();
    this.view.addComponentListener(this);
  }

  @Override
  public void componentResized(ComponentEvent e) {
    double oldWidth = this.currentDimension.getWidth();
    double oldHeight = this.currentDimension.getHeight();
    this.currentDimension = this.view.getSize();

    double newWidth = this.currentDimension.getWidth();
    double newHeight = this.currentDimension.getHeight();

    // Move the ball to maintain relative position on the screen
    this.model.setX(this.model.getX() * newWidth / oldWidth);
    this.model.setY(this.model.getY() * newHeight / oldHeight);
  }

  @Override public void componentMoved(ComponentEvent e) { /* ignore */ }
  @Override public void componentShown(ComponentEvent e) { /* ignore */ }
  @Override public void componentHidden(ComponentEvent e) { /* ignore */ }
}

UML-diagagram av ball-applikasjon

Tastetrykk

Swing legger opp til at ulike kontrollere håndterer tastetrykk avhengig av hvilken visuell komponent (i.e. hvilket Component -objekt) som til en hver tid er i fokus.

Metodene en tastetrykk-kontroller må implementere er keyPressed, keyReleased og keyTyped.

De tre metodene over får et KeyEvent -objekt som argument når de kalles. I dette objektet er det informasjon om hvilken tast som ble trykket og eventuelt hvilket symbol det reperesenterer.

Et alternativ til å benytte KeyListener er å benytte key bindings.

Museklikk

Metodene en museklikk-kontroller må implemenetere er mouseClicked, mousePressed, mouseReleased, mouseEntered og mouseExited.

Alle metodene over får et MouseEvent -objekt som argument når de kalles. I dette objektet er det informasjon om hvor musen befinner seg, og hvilken museknapp som ble trykket.

Les mer i dokumentasjonen.

Andre mulige kontrollere relatert til musepekeren:

Timer

Det finnes ulike Timer-klasser. Pass på at du importerer den du vil ha! Det enkleste er å benytte javax.swing.Timer som vi gjør i eksempelet med ball-applikasjonen over.

Metoden actionPerformed kalles med jevne mellomrom så lenge timeren er aktiv. Metoden får et ActionEvent -objekt som argument hver gang den kalles, men informasjonen den inneholder er vanligvis lite ikke så relevant i konteksten av en timer.

Dersom du insisterer på å benytte en annen timer, for eksempel java.util.Timer eller ScheduledExecutorService, må du passe på at all kode som muterer modellen din blir utført gjennom SwingUtilities sin invokeLater-metode, ellers kan programmet plutselig krasje.

Endring av vindusstørrelse

Se eksempel i ControllerResize over.

Eksempel på kontrollerte timere: en blinkende ball

I dette eksempelet har vi kombinert tastatur-kontrollere med timer-kontrollere, slik at timeren styres av tastaturet. Brukeren flytter prikken ved å holde inne pil-tastene, og slår av og på automatisk blinking ved å trykke på mellomrom.

Det er én timer som har ansvar for blinkingen, mens en annen timer har ansvaret for flyttingen av ballen med en jevn bevegelse så lenge piltasten er nedtrykket. Siden disse to oppførslene er uavhengig av hverandre, er det best om det er ulike klasser som håndterer dem.

Illustrasjon av blinkende ball

import javax.swing.JFrame;

public class Main {
  public static void main(String[] args) {
    Model model = new Model();
    View view = new View(model);
    new ControllerMove(model, view);
    new ControllerBlink(model, view);

    JFrame frame = new JFrame();
    frame.setTitle("INF101");
    frame.setContentPane(view);
    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    frame.pack();
    frame.setVisible(true);
  }
}
public class Model implements ViewableModel {

  private double radius;
  private double x;
  private double y;
  private boolean blink;

  /** Create a new model of a blinking ball. */
  public Model() {
    this.x = 0.5;
    this.y = 0.5;
    this.radius = 0.1;
    this.blink = false;
  }

  /** Toggle the blink state of the ball. */
  public void toggleBlink() {
    this.blink = !this.blink;
  }
  
  /** Move the ball along the x axis. */
  public void move(double dx) {
    this.x += dx;
    if (this.x < 0) {
      this.x = 0;
    }
    if (this.x > 1 - this.radius) {
      this.x = 1 - this.radius;
    }
  }

  @Override
  public double getRadius() {
    return this.radius;
  }

  @Override
  public double getX() {
    return this.x;
  }

  @Override
  public double getY() {
    return this.y;
  }

  @Override
  public boolean isBlinking() {
    return this.blink;
  }
}
public interface ViewableModel {

  /** Get the radius of the ball. */
  double getRadius();

  /** Get the x position of the ball. */
  double getX();

  /** Get the y position of the ball. */
  double getY();

  /** Get whether the ball is in a blink state. */
  boolean isBlinking();
}
import javax.swing.JPanel;
import java.awt.Color;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.geom.Ellipse2D;

public class View extends JPanel {

  private final ViewableModel model;

  /** Create a new view for blinking ball. */
  public View(ViewableModel model) {
    this.model = model;
    this.setPreferredSize(new java.awt.Dimension(150, 150));
  }

  @Override
  protected void paintComponent(Graphics g) {
    super.paintComponent(g);
    Graphics2D g2 = (Graphics2D) g;

    Color color = this.model.isBlinking() ? Color.RED : Color.BLACK;
    g2.setColor(color);

    Ellipse2D ball = new Ellipse2D.Double(
        (this.model.getX() - this.model.getRadius()) * this.getWidth(),
        (this.model.getY() - this.model.getRadius()) * this.getHeight(),
        this.model.getRadius() * 2 * this.getWidth(),
        this.model.getRadius() * 2 * this.getHeight()
    );
    g2.fill(ball);
  }
}
import javax.swing.Timer;
import java.awt.event.ActionEvent;
import java.awt.event.KeyEvent;
import java.awt.event.KeyListener;

public class ControllerMove implements KeyListener, ActionListener {

  private final Timer timer;
  private final Model model;
  private final View view;

  private boolean leftIsPressed;
  private boolean rightIsPressed;

  /** Create controller that moves the ball smoothly */
  public ControllerMove(Model model, View view) {
    this.model = model;
    this.view = view;

    // Timer to move the ball smoothly (30 times per second)
    this.timer = new Timer(1000 / 30, this);
    this.timer.start();

    // Capture keyboard input when the view is in focus and send it here
    this.view.setFocusable(true);
    this.view.addKeyListener(this);
  }

  @Override
  public void actionPerformed(ActionEvent e) {
    double dx = 0;
    if (this.leftIsPressed) {
      dx -= 0.02;
    }
    if (this.rightIsPressed) {
      dx += 0.02;
    }
    if (dx != 0) {
      this.model.move(dx);
      this.view.repaint();
    }
  }

  @Override
  public void keyPressed(KeyEvent e) {
    if (e.getKeyCode() == KeyEvent.VK_LEFT) {
      this.leftIsPressed = true;
    } else if (e.getKeyCode() == KeyEvent.VK_RIGHT) {
      this.rightIsPressed = true;
    }
  }

  @Override
  public void keyReleased(KeyEvent e) {
    if (e.getKeyCode() == KeyEvent.VK_LEFT) {
      this.leftIsPressed = false;
    } else if (e.getKeyCode() == KeyEvent.VK_RIGHT) {
      this.rightIsPressed = false;
    }
  }

  @Override
  public void keyTyped(KeyEvent e) { /* ignore */ }
}

Noen ting å legge merke til: