Search code examples
javaswingtimerstopwatchchronometer

Creating a GUI-based Chronometer (or Stopwatch)


What I took some time to work on is a program showing time elapsed, or time remaining, from where the user clicks the start button, much like a stopwatch or a chronometer which measures time until you stop and reset. Other examples of measuring time elapsed are those lap times in racing games and time limits, with milliseconds, in other games.

I'm running into some trouble, though, because my own stopwatch is not running at the same rate as actual time. It takes longer than one second for my timer to run one second down or up.

The code is right here: (the GUI works perfectly; I'm more concerned about how to control the values to show the time elapsed in a way that for every second passed, the time displayed on the JLabel is one second less. I cannot modify the argument that's passed into Thread.sleep because it will make the timer much worse.)

import javax.swing.*;

import java.awt.event.*;
import java.awt.*;
import java.text.DecimalFormat;
import java.util.concurrent.*;

public class StopwatchGUI3 extends JFrame 
{

    private static final long serialVersionUID = 3545053785228009472L;

    // GUI Components
    private JPanel panel;
    private JLabel timeLabel;

    private JPanel buttonPanel;
    private JButton startButton;
    private JButton resetButton;
    private JButton stopButton;

    // Properties of Program.
    private byte centiseconds = 0;
    private byte seconds = 30;
    private short minutes = 0;

    private Runnable timeTask;
    private Runnable incrementTimeTask;
    private Runnable setTimeTask;
    private DecimalFormat timeFormatter;
    private boolean timerIsRunning = true;

    private ExecutorService executor = Executors.newCachedThreadPool();

    public StopwatchGUI3()
    {
        panel = new JPanel();
        panel.setLayout(new BorderLayout());

        timeLabel = new JLabel();
        timeLabel.setFont(new Font("Consolas", Font.PLAIN, 13));
        timeLabel.setHorizontalAlignment(JLabel.CENTER);
        panel.add(timeLabel);


        buttonPanel = new JPanel();
        buttonPanel.setLayout(new FlowLayout(FlowLayout.CENTER));

        startButton = new JButton("Start");
        startButton.addActionListener(new ActionListener(){
            public void actionPerformed(ActionEvent e)
            {
                if (!timerIsRunning)
                    timerIsRunning = true;

                executor.execute(timeTask);
            }
        });
        buttonPanel.add(startButton);

        resetButton = new JButton("Reset");
        resetButton.addActionListener(new ActionListener(){
            public void actionPerformed(ActionEvent e)
            {
                timerIsRunning = false;

                centiseconds = 0;
                seconds = 30;
                minutes = 0;

                timeLabel.setText(timeFormatter.format(minutes) + ":" 
                        + timeFormatter.format(seconds) + "." 
                        + timeFormatter.format(centiseconds));
            }
        });

        buttonPanel.add(resetButton);

        stopButton = new JButton("Stop");
        stopButton.addActionListener(new ActionListener(){
            public void actionPerformed(ActionEvent e)
            {
                timerIsRunning = false;
            }
        });

        buttonPanel.add(stopButton);


        panel.add(buttonPanel, BorderLayout.SOUTH);


        timeFormatter = new DecimalFormat("00");

        timeTask = new Runnable(){
            public void run()
            {
                while(timerIsRunning)
                {
                    executor.execute(incrementTimeTask);

                    try
                    {
                        Thread.sleep(10);
                    }
                    catch (InterruptedException ex)
                    {
                        ex.printStackTrace();
                    }
                 }
            }
        };

        incrementTimeTask = new Runnable(){
            public void run()
            {
                if (centiseconds > 0)
                    centiseconds--;
                else
                {
                    if (seconds == 0 && minutes == 0)
                        timerIsRunning = false;
                    else if (seconds > 0)
                    {
                        seconds--;
                        centiseconds = 99;
                    }
                    else if (minutes > 0)
                    {
                        minutes--;
                        seconds = 59;
                        centiseconds = 99;
                    }
                }

                executor.execute(setTimeTask);
            }
        };

        setTimeTask = new Runnable(){
            public void run()
            {
                timeLabel.setText(timeFormatter.format(minutes) + ":" 
                        + timeFormatter.format(seconds) + "." 
                        + timeFormatter.format(centiseconds));
            }
        };

        timeLabel.setText(timeFormatter.format(minutes) + ":" 
                + timeFormatter.format(seconds) + "." 
                + timeFormatter.format(centiseconds));

        add(panel);

        setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        setLocationRelativeTo(null);
        setTitle("StopwatchGUI.java");

        pack();
        setVisible(true);
    }

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

There's gotta be another way to make the timer in sync with real time, just like a real stopwatch, instead of having to rely on three separate threads, which I think are too many for such a large programming project, but okay at entry level for now. (oh, by the way, the DecimalFormat class is to format the numbers properly like a real stopwatch, though there are no decimal values to round. It's only until now, at the time I posted this, that there exists a text class called SimpleDateFormat.)

In other words, I want this program to be just a real stopwatch. If this is not the case, then how do you create, or use, a stopwatch, in Java games, for example?


Solution

  • The biggest problem you would face is being able to get the various Runnables to run at a consistent rate. Basically, there's no real way to know when the Executor will actual execute the task you provide it, as it has it's own over heads.

    In this particular case, I would recommend reducing the number of active Threads to one, this reduces any additional overheads involved in the creation and execution of other Threads and provides you with the best control over getting things to work as close to the time you want as possible.

    Instead of using a Thread, I would instead, use a javax.swing.Timer, primary because it's simple and is executed within the context of the EDT which makes it safer to update the UI from within, for example

    import java.awt.BorderLayout;
    import java.awt.EventQueue;
    import java.awt.FlowLayout;
    import java.awt.Font;
    import java.awt.event.ActionEvent;
    import java.awt.event.ActionListener;
    import java.text.DecimalFormat;
    import javax.swing.JButton;
    import javax.swing.JFrame;
    import javax.swing.JLabel;
    import javax.swing.JPanel;
    import javax.swing.Timer;
    import javax.swing.UIManager;
    import javax.swing.UnsupportedLookAndFeelException;
    
    public class StopwatchGUI3 extends JFrame {
    
        private static final long serialVersionUID = 3545053785228009472L;
    
        // GUI Components
        private JPanel panel;
        private JLabel timeLabel;
    
        private JPanel buttonPanel;
        private JButton startButton;
        private JButton resetButton;
        private JButton stopButton;
    
        // Properties of Program.
        private byte centiseconds = 0;
        private byte seconds = 30;
        private short minutes = 0;
    
        private DecimalFormat timeFormatter;
    
        private Timer timer;
    
        public StopwatchGUI3() {
            panel = new JPanel();
            panel.setLayout(new BorderLayout());
    
            timeLabel = new JLabel();
            timeLabel.setFont(new Font("Consolas", Font.PLAIN, 13));
            timeLabel.setHorizontalAlignment(JLabel.CENTER);
            panel.add(timeLabel);
    
            buttonPanel = new JPanel();
            buttonPanel.setLayout(new FlowLayout(FlowLayout.CENTER));
    
            startButton = new JButton("Start");
            startButton.addActionListener(new ActionListener() {
                public void actionPerformed(ActionEvent e) {
    
                    timer.start();
    
                }
            });
            buttonPanel.add(startButton);
    
            resetButton = new JButton("Reset");
            resetButton.addActionListener(new ActionListener() {
                public void actionPerformed(ActionEvent e) {
    
                    timer.stop();
    
                    centiseconds = 0;
                    seconds = 30;
                    minutes = 0;
    
                    timeLabel.setText(timeFormatter.format(minutes) + ":"
                            + timeFormatter.format(seconds) + "."
                            + timeFormatter.format(centiseconds));
                }
            });
    
            buttonPanel.add(resetButton);
    
            stopButton = new JButton("Stop");
            stopButton.addActionListener(new ActionListener() {
                public void actionPerformed(ActionEvent e) {
                    timer.stop();
                }
            });
    
            buttonPanel.add(stopButton);
    
            panel.add(buttonPanel, BorderLayout.SOUTH);
    
            timeFormatter = new DecimalFormat("00");
    
            timer = new Timer(10, new ActionListener() {
                @Override
                public void actionPerformed(ActionEvent e) {
                    if (centiseconds > 0) {
                        centiseconds--;
                    } else {
                        if (seconds == 0 && minutes == 0) {
                            timer.stop();
                        } else if (seconds > 0) {
                            seconds--;
                            centiseconds = 99;
                        } else if (minutes > 0) {
                            minutes--;
                            seconds = 59;
                            centiseconds = 99;
                        }
                    }
                    timeLabel.setText(timeFormatter.format(minutes) + ":"
                            + timeFormatter.format(seconds) + "."
                            + timeFormatter.format(centiseconds));
                }
            });
    
            timeLabel.setText(timeFormatter.format(minutes) + ":"
                    + timeFormatter.format(seconds) + "."
                    + timeFormatter.format(centiseconds));
    
            add(panel);
    
            setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
            setLocationRelativeTo(null);
            setTitle("StopwatchGUI.java");
    
            pack();
            setVisible(true);
        }
    
        public static void main(String[] args) {
            EventQueue.invokeLater(new Runnable() {
                @Override
                public void run() {
                    try {
                        UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
                    } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | UnsupportedLookAndFeelException ex) {
                    }
    
                    new StopwatchGUI3();
                }
            });
        }
    }
    

    I would also stop "guessing" at the time. There simply is no guarantee that the amount of time passed between "updates" is accurate.

    Instead, I would grab the current time when the stop watch is started and on each tick of the Timer, subtract it from the current time, giving you the amount of time that has passed. You can then use that to determine what the current value of the stop watch should be...

    For example...

    Adding the following instance fields...

    private long startTime;
    private long runTime = 30000; // 30 seconds...
    

    Updating the startButton to include capturing the start time...

    startButton.addActionListener(new ActionListener() {
        public void actionPerformed(ActionEvent e) {
    
            startTime = System.currentTimeMillis();
            timer.start();
    
        }
    });
    

    And then updating the Timer as follows...

    timer = new Timer(10, new ActionListener() {
        @Override
        public void actionPerformed(ActionEvent e) {
    
            long now = System.currentTimeMillis();
            long dif = now - startTime;
            if (dif >= runTime) {
    
                timer.stop();
                dif = runTime;
    
            }
    
            dif = runTime - dif;
    
            long minutes = dif / (60 * 1000);
            dif = Math.round(dif % (60 * 1000));
            long seconds = dif / 1000;
            dif = Math.round(dif % 1000);
            long centiseconds = dif / 10;
    
            timeLabel.setText(timeFormatter.format(minutes) + ":"
                    + timeFormatter.format(seconds) + "."
                    + timeFormatter.format(centiseconds));
        }
    });
    

    Take a look at Concurrency in Swing for more details