Animering

Marcus skriver en animerad progress bar med vågmönster.

Det är ganska enkelt att skapa enkla animeringar i Java, men det finns lite olika vägar att gå som är mer eller mindre bra. Jag har skapat en animerad progress bar och tänkte peka på några viktiga steg i hur man skriver animerade komponenter. Hela koden för komponenten finns här.

Den progress bar som jag har skapat visar hur långt den aktuella processen (vad det nu må vara) har kommit genom ett vågmönster som flyter i komponenten. Vågmönstret rör sig hela tiden och flyttar sig dessutom längre och längre åt höger ju mer "progress" som sker. Komponenten har i sig ingen aning om vilken process det är som den illustrerar, utan förlitar sig på att dess metod setP anropas med ett tal mellan 0 och 1 som säger hur mycket "progress" som har skett, så det blir upp till koden i den aktuella applikationen att anropa komponenten tillräckligt ofta och med rätt värde.

Dessutom kommer komponenten att med siffror visa när viktiga "milstolpar" har passerats, såsom att 1/4 är gjort eller 1/5 är kvar. Dessa dyker upp och fadeas bort.

Som alltid när man ritar ska ritkoden in i metoden paintComponent. För att åstadkomma animering måste man göra två saker:

  1. Se till att anropa repaint tillräckligt ofta.
  2. Se till att paintComponent ritar olika från gång till gång, t ex baserat på tid eller någon uppräknad instansvariabel.

Jag tänkte gå igenom lite olika tekniker för att åstadkomma dessa två punkter.

Var ska man anropa repaint?

När man ropar på repaint registrerar Swing att den aktuella komponenten behöver ritas om, och så snart den hinner gör den de nödvändiga anropen för att rita om den, och bland annat resulterar detta i ett anrop till paintComponent. Notera här att repaint-anropet i sig inte leder direkt till paintComponent, utan anropet till paintComponent kommer från en egen tråd - Swing-tråden. Även om man gör anropet till repaint från Swing-tråden själv resulterar det inte i ett direkt anrop till paintComponent, utan Swing-tråden är en liten snurra som i en särskild fas tar hand om alla eventuella repaint-anrop som har kommit in, och även kan slå samman och effektivisera dem om det går.

Detta leder till den absolut enklaste tekniken, och som också är den som jag har använt i WavyProgressBar, nämligen att placera ett nytt anrop till repaint inuti själva paintComponent (företrädelsevis sist i densamma). Dvs, den ritar om komponenten och omedelbart notifierar den Swing-tråden att den vill rita om den igen så snart som möjligt, vilket förr eller senare resulterar i att paintComponent blir anropad igen. Låt oss titta på koden från WavyProgressBar:

    @Override
    protected void paintComponent(final Graphics g) {
        super.paintComponent(g);
        final Graphics2D g2 = (Graphics2D) g;
        g2.setRenderingHint(
                RenderingHints.KEY_ANTIALIASING,
                RenderingHints.VALUE_ANTIALIAS_ON);
        g2.setRenderingHint(
                RenderingHints.KEY_TEXT_ANTIALIASING,
                RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
        final long now = System.currentTimeMillis();
        if (p >= 1) {
            g.setColor(getForeground());
            g.fillRect(0, 0, getWidth(), getHeight());
        } else if (p > 0) {
            final int iw = getWidth();
            final int ih = getHeight();
            final double w = iw;
            final double h = ih;
            final double wp = w * p;
            final double t = -0.001 * (now - startTime);
            final GeneralPath gp = new GeneralPath();
            gp.moveTo(-10, h + 10);
            for (int i = 0; i < iw; i++) {
                final double x = i + 0.5;
                final double d = w * Math.min(p, 1 - p);
                final double f = h / d * x + h / 2 - h * wp / d;
                final double xHat = x / h;
                final double s =
                        (Math.cos(L1 * xHat + L2 * t)
                                * Math.sin(L3 * xHat + L4 * t) + Math
                                .cos(L5 * xHat + L6 * t)
                                * Math.sin(L7 * xHat + L8 * t)) / 2;
                final double y = f + h / 2 * s;
                gp.lineTo(x, y);
            }
            gp.lineTo(w + 10, h + 10);
            gp.closePath();
            for (int i = 10; i > 0; i -= 2) {
                g2.setColor(brighter(getForeground(), i / 10.0));
                g2.fill(new BasicStroke(i).createStrokedShape(gp));
            }
            g2.setColor(getForeground());
            g2.fill(gp);
            repaint();
        }
        final long sinceQ = now - qTime;
        if (sinceQ > 0
                && sinceQ < 1000
                && getQ() > -1000
                && getQ() < 1000) {
            final double opacity = 1 - sinceQ / 1000.0;
            final String s = "1/" + Math.abs(getQ());
            g2.setFont(getFont().deriveFont((float) getHeight()));
            final TextLayout textLayout = getTextLayout(g2, s);
            final float textWidth = textLayout.getVisibleAdvance();
            final double pos;
            if (getQ() < 0) {
                pos = 1 - 1.0 / -getQ();
            } else {
                pos = 1.0 / getQ();
            }
            final double centerX =
                    textWidth / 2.0 + pos * (getWidth() - textWidth);
            final AffineTransform tx =
                    AffineTransform
                            .getTranslateInstance(
                                    centerX - textWidth / 2,
                                    getHeight()
                                            / 2.0
                                            + (textLayout.getAscent() - textLayout
                                                    .getDescent())
                                            / 2);
            final Shape outline = textLayout.getOutline(tx);
            final Color outlineColor;
            final Color fillColor;
            if (getQ() < 0) {
                outlineColor = getForeground();
                fillColor = getBackground();
            } else {
                outlineColor = getBackground();
                fillColor = getForeground();
            }
            g2.setColor(setOpacity(outlineColor, opacity));
            g2.fill(new BasicStroke(4).createStrokedShape(outline));
            g2.fill(new BasicStroke(2).createStrokedShape(outline));
            g2.setColor(setOpacity(fillColor, opacity));
            g2.fill(outline);
            repaint();
        }
    }

Notera anropen till repaint på raderna 45 och 89. Jag har lagt dem där istället för ett enda anrop i slutet av metoden för att undvika onödig omritning när komponenten ändå är i ett statiskt läge, t ex när progress är 100% och komponenten är helt fylld. Animationen stannar alltså, men den kan gå igång igen så snart det kommer ett repaint-anrop från någon annanstans, i detta fallet ligger det ett sådant i metoden setP.

Att det potentiellt kan bli två anrop är inte heller det något problem, eftersom Swing-tråden slår ihop flera repaint-anrop till en enda omritning om de kommer under samma varv i Swing-trådens snurra. Eftersom inte repaint heller leder till ett direkt anrop till paintComponent riskerar vi inte att skapa en oändlig rekursion genom att göra så här, vilket ju annars kan kännas som en risk om man inte har lite koll på hur Swing fungerar internt.

Vad är då nackdelarna med den här tekniken? Jo, att det kan bli omritningar onödigt ofta, vilket tar av datorns processorkraft och kan göra att den går på högvarv i onödan, eller kanske till och med konkurrerar ut andra processer. Det mänskliga ögat kan inte uppfatta mer än sisådär 25 olika bilder per sekund, och därför kan det vara onödigt att rita om oftare än så. Vi ser helt enkelt inte skillnaden.

Då kan man istället starta en separat tråd, vars enda ansvar är att anropa repaint för vår komponent 25 gånger i sekunden, dvs med jämna tidsintervall på 40 ms. En lämplig placering av en sådan tråd är (sist) i konstruktorn:

        new Thread() {
            
            @Override
            public void run() {
                while (!isInterrupted()) {
                    try {
                        repaint();
                        sleep(40);
                    } catch (final InterruptedException e) {
                    }
                }
            }
        }.start();

Ett annat alternativ för att åstadkomma samma sak är klassen Timer i Swing.

Den riktigt uppmärksamme noterar här att det inte blir exakt 25 anrop i sekunden, eftersom koden som körs mellan sleep-anropen också tar lite tid. Det går att lösa även detta genom att använda t ex System.currentTimeMillis:

        new Thread() {
            
            @Override
            public void run() {
                while (!isInterrupted()) {
                    try {
                        final long frameStartTime =
                                System.currentTimeMillis();
                        repaint();
                        final long sleepTime =
                                frameStartTime
                                        + 40
                                        - System.currentTimeMillis();
                        if (sleepTime > 0) {
                            sleep(sleepTime);
                        }
                    } catch (final InterruptedException e) {
                    }
                }
            }
        }.start();

(Och ja, jag är medveten om att inte heller detta är helt perfekt, men det lämnar jag som en övning till läsaren.)

Skillnad från bildruta till bildruta

Exakt hur man gör själva ritkoden beror naturligtvis på vad det är man animerar. Ibland kanske man tar färdiga bildfiler och bläddrar igenom dem, och vid andra tillfällen, som i fallet med WavyProgressBar, byggs bilden upp av matematik, som bör påverkas av någon form av variabel som driver processen framåt. Även här finns det ett par olika bra tekniker, samt en riktigt dålig, som jag tänkte beröra. Låt oss börja med den dåliga som ett exempel på hur man inte ska göra.

Det kan kännas naturligt att helt enkelt ha en instansvariabel som man räknar upp ett steg inuti paintComponent varje gång den körs. På så vis blir varje bildruta annorlunda gentemot den föregående. Men så bör man inte göra! Anledningen till detta är att man inte har full kontroll över när och hur ofta paintComponent körs. Om man kör den första tekniken som jag föreslog ovan blir animationshastigheten beroende av datorns kapacitet. Och även om man kör den andra med en tråd som anropar repaint regelbundet kan man ändå inte vara säker. Ytterligare anrop till repaint kan komma ifrån andra metoder, och systemet själv kan även välja att anropa repaint när t ex ett fönster som har varit dolt kommer fram. Dessutom kan Swing-tråden, som nämnts tidigare, välja att kombinera flera repaint-anrop till en enda omritning om den inte hinner med, så anropen till paintComponent riskerar alltså att bli både fler och färre än vad man har tänkt sig, och dessutom kan de komma ojämnt.

Istället bör man använda någon form av yttre tillstånd, som inte sätts i paintComponent utan bara läses. Min favorit är den enklaste, att utnyttja den redan existerande System.currentTimeMillis, vilket jag också har gjort i den här komponenten. Låt oss titta på vilka delar av koden som blir inblandade. Först deklarerar jag en instansvariabel:

    private final long startTime;

Sedan sätter jag den till ett initialvärde i konstruktorn:

        startTime = System.currentTimeMillis();

Detta värde använder jag för att kunna räkna ut hur mycket tid som har gått sedan komponenten skapades, genom att ta skillnaden mellan aktuell tid och initialvärdet, och sedan basera ritandet på det. I paintComponent:

        startTime = System.currentTimeMillis();

Och:

            final double t = -0.001 * (now - startTime);

Värdet t använder jag sedan i mina beräkningar. Det hade även gått bra att skippa initialvärdet och använda värdet från System.currentTimeMillis (som returnerar hur många millisekunder som passerat sedan midnatt den 1 januari 1970) direkt, men det blir så stora siffror, så jag undvek detta.

Den andra tekniken kan man göra om man har en separat tråd som körs och anropar repaint. Då kan man även låta denna räkna upp en instansvariabel (av typen int eller long) och använda denna i paintComponent:

        new Thread() {
            
            @Override
            public void run() {
                while (!isInterrupted()) {
                    try {
                        final long frameStartTime =
                                System.currentTimeMillis();
                        frame++;
                        repaint();
                        final long sleepTime =
                                frameStartTime
                                        + 40
                                        - System.currentTimeMillis();
                        if (sleepTime > 0) {
                            sleep(sleepTime);
                        }
                    } catch (final InterruptedException e) {
                    }
                }
            }
        }.start();

Det var lite olika tekniker som man kan använda när man skapar animeringar. Vid tillfälle ska jag prova på att göra progress bars som ser ut som en lavalampa eller ett timglas med rinnande sand.

Animering

Om bloggaren

Marcus Björkander

Marcus Björkander

Schlagernörd och småbarnspappa med tvångstankar som jobbar som utvecklare på Westbahr i Göteborg. Favoritspråket är Java, som han tidigare har undervisat i vid Chalmers i många år.

 

Nyckelord/tag moln