Interaktiv grafikk
- Model-View-Controller
- Første eksempel: en teller
- Eksempel med flere kontrollere: en flyttbar ball
- Eksempel på kontrollerte timere: en blinkende ball
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:
-
Modell. En modell er en samling med data som representerer tilstanden til programmet.
-
Visning. Den del av koden som tegner noe på skjermen, fortrinssvis basert på dataen i modellen. I denne delen av koden finner vi vanligvis metoder som har å gjøre med piksel-posisjoner, piksel-avstander, layout, fasonger og farger. For oss som benytter Swing-rammeverket i Java, er det metodene
paint
/paintComponent
som er inngangsporten til visningen; i tillegg inkluderer visningen hjelpemetoder og hjelpeklasser som benyttes av paintComponent. Felles for disse metodene er at de ikke skal endre på modellen, kun se på den. -
Kontroller. Den delen av koden som modifiserer modellen. Her finner vi koden som kjøres når programmet mottar tastetrykk, museklikk, klokkeslag fra timere, beskjeder over nettverk eller andre hendelser, og oppdaterer modellen på bakgrunn av dette. For oss som benytter Swing-rammeverket gjelder dette for eksempel klasser som implementerer KeyListener, MouseListener, ActionListener eller lignende, samt tilhørende metoder og annen kode som støtter opp under utførelsen av disse metodene.
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.
Model
inneholder instansvariabler som representerer modellen i programmet (her en enkel variabel for å telle antall tastetrykk). Klassen kan også ha metoder for hvordan instansvariablene kan endres.ViewableModel
er et grensesnitt som Model implementerer, men som ikke inneholder noen metoder som kan mutere modellen; den er en «read-only» -type for modellen. Den inneholder alle metoder som er nødvendig for at modellen skal kunne tegnes.View
er en klasse som utvider JPanel fra Swing-rammeverket. Det er i denne klassen vi finner kode knyttet til visningen. Merk at View har en instansvariabel med typen ViewableModel.Controller
håndterer input til programmet, og har mulighet til å modifisere modellen. Kontrolleren har også en begrenset tilgang til visningen, og kan for eksempel instruere den til å tegne seg selv på nytt etter at modellen er endret, eller få tilgang til størrelsen på lerretet.Main
er klassen som konstruerer objekter og binder dem sammen.
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-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.
For å få til dette bruker vi fire ulike kontrollere:
ControllerKeyPressed
håndterer tastetrykkControllerMouseClicked
håndterer museklikkControllerResize
håndterer endring av vindusstørrelseControllerTimer
håndterer periodiske hendelser
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 */ }
}
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.
- For at en visuell komponent i det hele tatt skal være mulig å fokusere på, må man kalle
setFocusable(true)
på komponenten (se konstruktøren til ControllerKeyPressed over). - Et kontroller-objekt for tastetrykk må være i en klasse som implementerer KeyListener-grensesnittet.
- For å installere et kontroller-objekt på en visuell komponent, må man kalle
addKeyListener
på den visuelle komponenten (se for eksempel konstruktøren i ControllerKeyPressed over).
Metodene en tastetrykk-kontroller må implementere er keyPressed, keyReleased og keyTyped.
keyPressed
kalles når en tast trykkes ned. Avhengig av operativsystem kan det være den kalles flere ganger med litt mellomrom når tasten er nedtrykket lenge.keyReleased
kalles når en tast slippes opp.keyTyped
kalles når en tastekombinasjon har resultert i et symbol/unicode-tegn. Denne metoden er egnet til bruk der brukeren skal skrive tekst.
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.
e.getKeyCode()
returnerer et tall som representerer hvilken tast som ble trykket/sluppet. Mulige returverdier er konstantene i klassen KeyEvent som begynner med VK (se dokumentasjonen). Se eksempel på hvordan vi sjekker hvilken tast som ble trykket i keyPressed -metoden i ControllerKeyPressed over.e.getKeyChar()
returnerer en char som representerer hvilken tastekombinasjon som ble trykket. Benyttes stort sett i keyTyped.
Et alternativ til å benytte KeyListener er å benytte key bindings.
Museklikk
- Et kontroller-objekt for museklikk må være i en klasse som implementerer MouseListener-grensesnittet.
- For å installere en museklikk-kontroller på en visuell komponent, må man kalle
addMouseListener
på den visuelle komponenten (se for eksempel konstruktøren i ControllerMousePressed over).
Metodene en museklikk-kontroller må implemenetere er mouseClicked, mousePressed, mouseReleased, mouseEntered og mouseExited.
mouseClicked
kalles når musen har blitt trykked ned på og blitt sluppet opp igjen over den visuelle komponenten.mousePressed
kalles når musen klikkes ned på den visuelle komponenten.mouseReleased
kalles når musen slippes opp over den visuelle komponenten.mouseEntered
kalles når musen kommer inn over den visuelle komponenten.mouseExited
kalles når musen forlater den visuelle komponenten.
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.
e.getX()
gir x-koordinatet til musen.e.getY()
gir y-koordinatet til musen.e.getClickCount()
angir om det er f. eks. enkelklikk eller dobbelklikk (relevant kun for mouseClicked).e.getButton()
angir om det er venstre-klikk eller høyreklikk.
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.
- En timer-kontroller må være i en klasse som importerer ActionListener-grensesnittet.
- Opprett et Timer-objekt og angi hvor mange millisekunder det skal gå mellom hvert kall til actionPerformed, samt angi på hvilket objekt actionPerformed skal kalles. Se eksempel i konstruktøren til ControllerTimer over.
- Timeren må startes med start-metoden, se konstruktøren i ControllerTimer 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
- Et kontroller-objekt for reaksjoner på endret vindusstørrelse må være i en klasse som implementerer
ComponentListener
-grensesnittet. - For å installere kontroller-objektet, kall på
addComponentListener
på visningen.
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.
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 ControllerBlink implements KeyListener {
private final Model model;
private final View view;
private final Timer timer;
/** Create controller that makes a ball blink. */
public ControllerBlink(Model model, View view) {
this.model = model;
this.view = view;
// Timer is ticking every 500 ms (2 times per second)
this.timer = new Timer(1000 / 2, this::toggleBlink);
this.timer.setInitialDelay(0);
// Capture keyboard input when the view is in focus and send it here
this.view.setFocusable(true);
this.view.addKeyListener(this);
}
private void toggleBlink(ActionEvent e) {
this.model.toggleBlink();
this.view.repaint();
}
@Override
public void keyReleased(KeyEvent e) {
if (e.getKeyCode() == KeyEvent.VK_SPACE) {
if (this.timer.isRunning()) {
this.timer.stop();
} else {
this.timer.start();
}
}
}
@Override public void keyPressed(KeyEvent e) { /* ignore */ }
@Override public void keyTyped(KeyEvent e) { /* ignore */ }
}
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:
-
Når en timer opprettes, kan man gi den en metode med ActionEvent -parameter i stedet for å gi den et objekt som implementerer ActionListener (se hvordan konstruktøren til ControllerBlink angir
this::toggleBlink
som argument når Timer-objektet opprettes). -
Det er viktigere å dele opp kontroller-klassene basert på hva de skal kontrollere rent logisk snarere enn hvilken form for brukerinput de skal håndtere.