CollectCircles 3 - Timing, best time, and game completion


SystemClock, Runnable, SharedPreferences, and a completion dialog Credit to Yoram for the original idea and lessons 1–3 on sensors and Canvas in MAUI

Hebrew version · English chapter index

Previous chapter · Next chapter

On a touch screen, swipe right for the next chapter and left for the previous chapter.

This is the final stage. We continue from the draggable game in CollectCircles 2, adding restarting, timing, a saved best time, and a completion message. By the end of the lesson, the app is complete.

In order for comments in Hebrew combined with English to be displayed in a readable way, select Right-To-Left display, then Never show again (the prompt keeps appearing at the top until you make this choice). In professional code, English comments are recommended. The original lesson uses Hebrew comments to make them easier to read.

What will we complete?

  • A Start button that creates a game and starts timing.
  • Another click starts a new game and resets the time.
  • Time measurement using a monotonic clock.
  • Refreshing the time label in the UI without using the number of ticks as time.
  • Stop when the last circle is collected.
  • Saving the best time in SharedPreferences.
  • Removing scheduled updates when the screen is inactive.

Until now the circle rules worked, but there was no clear framework for a game round. In this chapter we will add three states: before starting, active game and finished game.

State Board Timer Next action
Before starting Empty 0.000 Start creates a game
Active game Five circles to move and collect Running Restart from zero, or finish on the last collection
Finished Empty Stopped at the final time Restart creates a new round

The gameRunning variable represents these states from the Activity’s point of view. The list of circles represents the progress of the game itself.

1. Start becomes the starting point

In lesson 1 we temporarily disabled the button, because there was no game to start yet. Now remove this line from onCreate:

binding.startButton.setEnabled(false);

After removal the button returns to its default, enabled = true, and can receive clicks. The OnClickListener that we will connect later in the lesson will then call startGame(). If you leave the line, the button remains gray and disabled even if we connected a proper listener to it.

Do not replace the line with setEnabled(true) elsewhere. At this stage there is no longer a situation where the button should be disabled, so it is better to remove the temporary disablement and not leave conflicting instructions in the code.

In the previous version GameBoardView created a game in onSizeChanged, because we needed a playable version before building the final screen. Now remove this onSizeChanged implementation from GameBoardView and add:

/**
 * Starts a new round using the game board’s current size.
 * If the board has not been measured yet, the method leaves the game unchanged.
 */
public void startNewGame() {
    if (getWidth() == 0 || getHeight() == 0) {
        return;
    }

    float targetRadius = Math.min(
            dp(TARGET_RADIUS_DP),
            Math.min(getWidth(), getHeight()) / 5f
    );
    game = new Game(
            getWidth(),
            getHeight(),
            targetRadius,
            color(R.color.target_red),
            color(R.color.target_cross),
            dp(2f),
            color(R.color.circle_green)
    );
    invalidate();
}

Opening the app now shows the controls and an empty board. Just pressing Start creates five circles. The same method will also be used for Restart.

The width and height check prevents game creation before Android has finished measuring the View. We no longer automatically create a game in onSizeChanged, because resizing a board is a display event and not a request to start a round. Now only an explicit user action changes the state of the game from “before starting” to “active”.

2. Notify the Activity when the game ends (still in GameBoardView)

The View knows when the list becomes empty, but MainActivity is the natural place to update labels, save preferences, and show a dialog. In GameBoardView, we will store a Runnable supplied by the Activity:

private Runnable onGameFinished;

/**
 * Sets a callback to run after the last circle is collected.
 *
 * @param listener the callback to run when the game ends, or {@code null} to remove it
 */
public void setOnGameFinishedListener(Runnable listener) {
    onGameFinished = listener;
}

In GameBoardView, replace the entire existing ACTION_UP case with the following code. Replace it rather than appending to it: on release, move the circle to the finger’s final position, then call releaseSelectedCircle() exactly once and store its result:

case MotionEvent.ACTION_UP:
    game.moveSelectedCircle(event.getX(), event.getY());
    boolean collected = game.releaseSelectedCircle();
    getParent().requestDisallowInterceptTouchEvent(false);
    invalidate();
    performClick();
    if (collected
            && game.isFinished()
            && onGameFinished != null) {
        onGameFinished.run();
    }
    return true;

The three conditions are important:

  1. This release actually collected a circle.
  2. No circles remain.
  3. The Activity has supplied a callback to run.

This keeps the View independent of SharedPreferences and dialogs, and the Activity does not need to inspect the circle list on every movement.

This is a small callback: GameBoardView announces “game finished” without deciding what that means for the interface. MainActivity chooses to stop the timer, save the best time, and show a message. Runnable fits because no data needs to be passed: the Activity can calculate the final time from its own clock.

3. Add strings to the final screen

Add to strings.xml:

<string name="elapsed_time_format">Time: %1$.3f s</string>
<string name="best_time_format">Best: %1$.3f s</string>
<string name="restart">Restart</string>
<string name="completion_title">Game complete!</string>
<string name="completion_message">You collected every circle in %1$.3f seconds.</string>
<string name="ok">OK</string>

%1$.3f means: take the first parameter and display it as a decimal number with three digits after the point. The text is in the resources, so it can be translated in the future without editing Java.

4. Prepare the clock and the best time

In MainActivity add imports:

import android.content.SharedPreferences;
import android.os.SystemClock;

import com.google.android.material.dialog.MaterialAlertDialogBuilder;

Add constants and fields:

private static final String PREFERENCES_NAME =
        "collect_circles_preferences";
private static final String BEST_TIME_KEY = "best_time_millis";
private static final long TIMER_REFRESH_MILLIS = 16;
private static final long NO_BEST_TIME = Long.MAX_VALUE;

private SharedPreferences preferences;
private boolean gameRunning;
private long gameStartTimeMillis;
private long bestTimeMillis;

We keep time in milliseconds inside long. The value Long.MAX_VALUE is a marker meaning “no best time yet”. Any real game time will be less than that, so the first result automatically becomes the best time.

The names end in Millis on purpose. The unit is part of a value’s meaning: comparing milliseconds with seconds would compile but produce an incorrect result. Convert to seconds only at the display boundary, when formatting text for the user.

Why SystemClock.elapsedRealtime()?

System.currentTimeMillis() represents time and date and can change if the user or the network adjusts the device clock. To measure duration we will use:

SystemClock.elapsedRealtime()

This is a monotonic clock: it measures forward from device boot and does not jump when the clock setting changes. The duration of the game is the difference between two calls:

elapsed = currentElapsedRealtime - gameStartElapsedRealtime

We do not keep “how many times the Runnable ran”. The operating system does not guarantee that each refresh will occur exactly on time; the UI can get busy and even freeze when the app is in the background. The difference between two clock readings stays correct even if refreshes are skipped.

5. Refresh the label with Runnable

Before the code: What is Runnable?

Runnable is a small Java interface representing an action that can be run. It has only one method, run():

public interface Runnable {
    /**
     * runs the method that the object represents.
     */
    void run();
}

An object of type Runnable can be thought of as a note that says “When asked to run, this is the code I will execute”. Creating the object does not run the code, nor does it create a new thread. Someone else needs to run run() or hand off Runnable to a mechanism that will run it later.

For example:

Runnable sayHello = new Runnable() {
    /**
     * writes a greeting message to the log for Runnable demonstration.
     */
    @Override
    public void run() {
        Log.d("CollectCircles", "Hello");
    }
};

sayHello.run();

new Runnable() { ... } creates an object of an anonymous class implementing the interface. The code inside run() is the action stored in that object; only sayHello.run() executes it. Later in the course, you will be able to write short actions using lambdas, but this full form helps show exactly what is created and what is executed.

In Android, the two ways we will run it have an important difference:

  • timerUpdate.run() executes the action immediately on the current thread.
  • someView.postDelayed(timerUpdate, delay) puts a request in the queue of the UI thread, to execute it after at least the requested delay has elapsed.

postDelayed does not stop the application and wait. It registers work for the future and returns immediately, so the UI can continue handling drawing and clicks. Since the work is sent through View, it will run in the UI thread and there it is allowed to update TextView.

In this chapter the same idea appears in two roles: onGameFinished is a method that is saved as a callback and is run once when the game ends; timerUpdate is a method that schedules itself repeatedly as long as the game is active.

Add a field:

private final Runnable timerUpdate = new Runnable() {
    /**
     * refreshes the displayed time and schedules another refresh as long as the game is active.
     */
    @Override
    public void run() {
        if (!gameRunning) {
            return;
        }

        showElapsedTime(
                SystemClock.elapsedRealtime() - gameStartTimeMillis
        );
        binding.elapsedTimeText.postDelayed(
                this,
                TIMER_REFRESH_MILLIS
        );
    }
};

When calling postDelayed, the word this is inside run() of the anonymous class, so it points to the Runnable object itself, that is, to timerUpdate. In each run the method updates the label and queues its next run with a delay of about 16 ms. This creates a refresh chain that corresponds approximately to a rate of 60 refreshes per second.

At the beginning of each run gameRunning is checked. When it is false, the return command ends the run without scheduling another run. Therefore the chain continues only as long as the game is active.

The value 16 is a requested display interval, not guaranteed accuracy of 16 milliseconds. There is no point in updating TextView hundreds or thousands of times per second, and the final time is taken directly from the clock anyway. This is how we separate measurement accuracy from display smoothness.

The Runnable is not the clock. It only reminds us to refresh the screen. The exact time is always recalculated from the monotonic clock. Therefore a small delay in refreshing does not add up to a time error.

6. Connect the screen in onCreate

Inside onCreate, find the entire insets block that the Android Studio template created. Do not paste right after the line that calculates systemBars. Continue until the line });, which closes the lambda and the call to setOnApplyWindowInsetsListener, and add the initialization code after it. The following diff shows the existing code and the exact insertion point:

         binding = ActivityMainBinding.inflate(getLayoutInflater());
         setContentView(binding.getRoot());

         ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> {
             Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
             v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
             return insets;
         }); // This is where the insets listener ends
+     
+        // continue inside onCreate, but outside the listener of the insets.
+        preferences = getSharedPreferences(
+                PREFERENCES_NAME,
+                MODE_PRIVATE
+        );
+        bestTimeMillis = preferences.getLong(
+                BEST_TIME_KEY,
+                NO_BEST_TIME
+        );
+        showBestTime();
+
+        binding.startButton.setOnClickListener(view -> startGame());
+        binding.gameBoard.setOnGameFinishedListener(this::finishGame);
     } // This is where onCreate ends
    

Only the systemBars calculation, the padding update and the return insets command should remain inside the insets callback. If getSharedPreferences, showBestTime() or the button connections are before return insets, they were pasted inside the callback by mistake. Move them under });. This check matters even if the app still runs: the callback belongs to the screen layout updates and may run again, while the screen initialization should be performed once directly from onCreate.

Here too View Binding gives direct and type-safe access to startButton, gameBoard and labels.

this::finishGame is a method reference: when the View announces that the game is over, the Activity’s finishGame() method runs.

7. Start or restart a game

Add:

/**
 * Creates a new round, resets the start time, and starts a single refresh chain.
 */
private void startGame() {
    binding.gameBoard.startNewGame();
    gameStartTimeMillis = SystemClock.elapsedRealtime();
    gameRunning = true;

    binding.startButton.setText(R.string.restart);
    // Removes every queued execution of this exact Runnable from the View's message queue.
    // This stops future timer updates but does not delete or disable timerUpdate,
    // so the same Runnable can be started again later with timerUpdate.run().
    binding.elapsedTimeText.removeCallbacks(timerUpdate);
    timerUpdate.run();
}

removeCallbacks prevents a situation where a quick click on Restart leaves two update chains active. timerUpdate.run() then immediately updates and starts one new chain.

The order of operations forms a small, clear sequence: first you change the content of the board, then you set a start time and mark that the game is active, and finally you start a single refresh. From that moment every time calculation belongs to the new round only.

8. Finish, compare and save (work on MainActivity.java)

8.1 Small display methods

Add at the end of the class:

/**
 * shows in the time label the duration of the rotation in milliseconds.
 *
 * @param elapsedMillis duration of rotation in milliseconds
 */
private void showElapsedTime(long elapsedMillis) {
    binding.elapsedTimeText.setText(getString(
            R.string.elapsed_time_format,
            millisecondsToSeconds(elapsedMillis)
    ));
}

/**
 * displays the saved best time, or the default value if there is no best time yet.
 */
private void showBestTime() {
    if (bestTimeMillis == NO_BEST_TIME) {
        binding.bestTimeText.setText(R.string.best_time_initial);
        return;
    }

    binding.bestTimeText.setText(getString(
            R.string.best_time_format,
            millisecondsToSeconds(bestTimeMillis)
    ));
}

/**
 * converts duration from milliseconds to seconds for display.
 *
 * @param milliseconds Duration in milliseconds
 * @return same duration in seconds
 */
private double millisecondsToSeconds(long milliseconds) {
    return milliseconds / 1000.0;
}

Saving the time in long and converting it to double just for display prevents us from mixing units. The parameter name milliseconds states which unit the method expects.

8.2 finishGame calls the small helper methods

Add this above the small methods:

/**
 * stops the active round, updates best time if necessary and displays an end message.
 */
private void finishGame() {
    if (!gameRunning) {
        return;
    }

    long finalTimeMillis =
            SystemClock.elapsedRealtime() - gameStartTimeMillis;
    gameRunning = false;
    binding.elapsedTimeText.removeCallbacks(timerUpdate);
    showElapsedTime(finalTimeMillis);

    if (finalTimeMillis < bestTimeMillis) {
        bestTimeMillis = finalTimeMillis;
        preferences.edit()
                .putLong(BEST_TIME_KEY, bestTimeMillis)
                .apply();
        showBestTime();
    }

    new MaterialAlertDialogBuilder(this)
            .setTitle(R.string.completion_title)
            .setMessage(getString(
                    R.string.completion_message,
                    millisecondsToSeconds(finalTimeMillis)
            ))
            .setPositiveButton(R.string.ok, null)
            .show();
}

Save a result only if it is lower than the previous best time. SharedPreferences saves the value in a private file of the application, so it remains even after closing and reopening. apply() updates the object immediately and writes to storage without blocking the UI thread.

The test if (!gameRunning) return makes finishGame safe even if a callback arrives twice by mistake: only the first call stops the timer, saves the result, and shows the dialog. After gameRunning = false, the refresh chain stops and the displayed time is fixed on the final result.

We chose apply() and not commit(): no need to stop the UI until the disk write is finished. The value in memory is updated immediately, and the small write is done in the background.

Running for testing The code at this point should compile and run.

9. Lifecycle cleanup

When the user switches to another app, there is no point updating a label that is not visible. Add:

/**
 * restarts the time refresh when the activity returns to the screen.
 */
@Override
protected void onStart() {
    super.onStart();
    if (gameRunning) {
        binding.elapsedTimeText.removeCallbacks(timerUpdate);
        timerUpdate.run();
    }
}

/**
 * removes scheduled time updates when activity is not visible.
 */
@Override
protected void onStop() {
    binding.elapsedTimeText.removeCallbacks(timerUpdate);
    super.onStop();
}
What is the lifecycle, and when is each method called?

Android manages the Activity according to its visibility and state. Android calls the lifecycle methods; we do not call onStart() or onStop() directly.

  • On the first launch, Android calls onCreate(), onStart(), then onResume(). The screen is visible and the user can interact with it.
  • When you temporarily switch to another screen, Android first calls onPause(). If the Activity becomes completely invisible—for example, after pressing Home or switching to another app—it also calls onStop().
  • When returning to a stopped Activity, Android calls onRestart(), onStart(), then onResume(). This means onStart() can run many times on the same object, rather than only on its first launch.
  • If Android recreates the Activity, for example after a configuration change, it starts again with onCreate(). Ordinary fields return to their initial values unless we saved their state using a suitable mechanism.

In our game onStop() only stops the time label updates, because there is no need to draw a non-visible screen. The starting value gameStartTimeMillis does not change, so time itself continues to pass. When you return, onStart() activates timerUpdate again, and the calculation against SystemClock.elapsedRealtime() immediately shows the current time.

Do not wait for onDestroy() to perform this cleanup. Android doesn’t guarantee that it will be called before the process is killed, and anyway onStop() is the point where the screen is no longer visible. Information that should survive closure of the process must be kept in appropriate storage; The best time is therefore saved in SharedPreferences, while the current state of the game is still temporary at this point.

onStop removes the scheduled update and thus leaves no job holding the activity behind the scenes. If you return to the same activity while the game is active, onStart restarts the refresh. The elapsed time is still correct, because we are not counting ticks - we are reading the monotonic clock again.

This is also a game decision: time continues to be counted when the application is in the background, and only the label drawing is stopped. To pause the game instead, we would need to store accumulated time and update it at lifecycle transitions. This policy should be an explicit choice rather than a side effect of stopping UI updates.

In this teaching version, an active game does not survive Activity recreation, such as after a configuration change. The best time persists in SharedPreferences, but the current round’s state is temporary. Supporting this with ViewModel or savedInstanceState is a possible extension, not a requirement for understanding the basic game mechanism.

11. Running and testing

Run the application and check:

  • The initial screen shows a title, Time: 0.000 s, the best time or Best: —, a Start button, and an empty board.
  • Start creates exactly five green circles that do not overlap each other or the target.
  • Dragging moves the circle with the finger.
  • Releasing a circle while it is completely inside the target deletes it.
  • Touching or partially overlapping the target does not erase the circle.
  • Collecting the last circle stops the time and displays an end message.
  • The displayed time stops changing after the end.
  • A better result updates the best time.
  • A slower result does not replace the best time.
  • After closing the application and reopening it the best time is still displayed.
  • Restart creates five new locations and resets the time.

The project is complete: XML screen connected in View Binding, drawing in Canvas, circle model with inheritance, full touch game, monotonic time and a record saved on the device.

The complete flow is now: XML allocates space for the board; Game creates a valid model using distance tests; GameBoardView turns touch events into model changes and draws the model; MainActivity manages round boundaries, timing, and saving. Each layer knows only what its role requires, making each part easier to explain, test, and change without mixing the entire project into one class.

A question to consider next

After the game works, you should stop and ask if Circle should have been View, should Target really be inherited from it, and what does the word Model mean in such a project. Continue to Thinking critically about objects, views, and inheritance .

Continue

Gemini tutor

Open Gem: CollectCircles Studio: Drawing and Playing to get hints, diagnostic questions and explanations appropriate to the stage you are at.