Displaying a progress dialog for long operations
A long-running task can make an Android app appear frozen, even when the code is still working correctly. Reading a large SQLite database, importing records, downloading images, or preparing a report may take several seconds, so the interface needs to communicate that work is underway.
A progress dialog gives users immediate feedback and prevents them from repeatedly tapping the same button. This matters during a Melbourne train commute, when a user may be working with an inconsistent mobile connection, just as much as it does in a busy Brisbane café using crowded Wi-Fi.
The classic ProgressDialog class is familiar in older Android projects, although it has been deprecated since API level 26. It is still useful for understanding the pattern, while newer applications should generally use an in-layout ProgressBar, a dialog fragment, or a lifecycle-aware progress component.
Why long operations need visible feedback
Android expects the main, or UI, thread to remain responsive. If database work or network processing runs directly inside a click handler, the screen cannot redraw until the operation finishes. The result is a frozen interface and, after enough time, an Application Not Responding warning.
A modal progress indicator also establishes a clear interaction state. While records are being loaded, the user should not be able to start the same import twice or navigate into a screen that depends on incomplete data. A message such as “Loading bookings…” is more helpful than a vague spinner because it explains what the application is doing.
The duration of an operation should influence the design. A task that completes in under a second may need no dialog at all, while a task lasting several seconds needs a visible indicator and, where practical, a cancellation option.
Creating a simple progress dialog
In a legacy Java activity, a ProgressDialog can be created before starting work. Set the style to a spinner for an operation with an unknown duration, then display it only after the user initiates the task.
private ProgressDialog progressDialog;
private void beginLoading() {
progressDialog = new ProgressDialog(this);
progressDialog.setTitle("Please wait");
progressDialog.setMessage("Loading customer records...");
progressDialog.setProgressStyle(ProgressDialog.STYLE_SPINNER);
progressDialog.setCancelable(false);
progressDialog.show();
loadRecords();
}
The setCancelable(false) call prevents the Back button from dismissing the dialog while the operation continues. That setting is appropriate only when leaving the operation halfway through could cause confusion. If the work can safely stop, allow cancellation and handle it explicitly instead of leaving the user trapped behind a modal window.
A determinate dialog is more informative when the total amount of work is known. For example, an import of 200 rows can call setMax(200) and update the current value after each row. Avoid displaying a percentage that is only an estimate, because inaccurate progress can make an app feel less reliable.
Running work away from the UI thread
Showing a dialog does not make a slow operation asynchronous. The database or network code must run outside the main thread. A small Java example can use an ExecutorService for background work and a Handler to return the result to the UI thread.
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private void loadRecords() {
executor.execute(() -> {
List<Record> records = repository.readRecords();
mainHandler.post(() -> {
if (progressDialog != null && progressDialog.isShowing()) {
progressDialog.dismiss();
}
displayRecords(records);
});
});
}
The repository.readRecords() method should contain the expensive work, such as a SQLite query or a remote request. Updating views, dismissing the dialog, and showing a Toast must happen on the main thread. This separation keeps scrolling and button feedback responsive while the task is running.
For modern applications, Kotlin coroutines, WorkManager, or another lifecycle-aware solution may be a better fit. WorkManager is especially suitable for work that must continue after the activity leaves the screen, such as a scheduled upload or a large data synchronisation.
Showing progress inside the screen
A full-screen or in-layout ProgressBar is usually safer than a deprecated modal dialog. Add the indicator to the activity or fragment layout, hide the content while loading, and make it visible only during the operation.
<ProgressBar
android:id="@+id/loadingIndicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
The screen can remain navigable when the operation does not need to block every action. For example, a list of existing appointments might stay visible while new data is synchronised. A small loading indicator near the list communicates progress without covering the entire screen.
This approach also works well with dialog-based controls. An app that lets users choose a date can keep the loading state consistent with its other components, including date picker dialogs. Keeping the indicator in the same layout hierarchy reduces window-management problems during rotation.
Updating messages and determinate progress
A spinner communicates activity but not duration. When processing a group of files or importing rows, update the message and progress value as each item completes. The update should be posted to the UI thread rather than made directly from the worker.
for (int index = 0; index < records.size(); index++) {
importRecord(records.get(index));
int completed = index + 1;
mainHandler.post(() -> {
if (progressDialog != null && progressDialog.isShowing()) {
progressDialog.setProgress(completed);
progressDialog.setMessage(
"Imported " + completed + " of " + records.size()
);
}
});
}
Messages should be short and specific. “Importing invoices…” is clearer than “Processing…”, while “Checking 48 files…” gives the user a useful sense of scale. Australian users will understand plain English such as “Loading your details”; avoid overly technical wording and unexplained abbreviations.
Do not send an update for every tiny operation if thousands of updates will create unnecessary overhead. Updating at sensible intervals, or after each meaningful unit of work, keeps the display smooth and reduces pressure on the main thread.
Managing cancellation and lifecycle changes
A dialog belongs to an activity or fragment, so it must be dismissed carefully. If the user rotates the device or leaves the screen, the original window may no longer exist. Calling dismiss() on a dialog attached to a destroyed activity can produce a window leak or other lifecycle errors.
A robust implementation keeps the task independent from the screen and observes its state from the current lifecycle owner. With a ViewModel, the activity can reconnect to the existing operation after rotation and display the correct loading state without starting duplicate work.
Cancellation also needs to reach the worker. An ExecutorService task can be interrupted, while a coroutine can be cancelled through its scope. Repository code should check for cancellation between sizeable steps and close database cursors, streams, and response bodies in all cases.
Handling success, errors, and empty results
The progress indicator must disappear on every possible outcome. That includes a successful query, an empty result, a timeout, an authentication failure, and an exception thrown by SQLite. A try/finally block or a single observable state model helps prevent a dialog from remaining on screen indefinitely.
try {
List<Record> records = repository.readRecords();
mainHandler.post(() -> showRecords(records));
} catch (Exception exception) {
mainHandler.post(() ->
Toast.makeText(this, "Could not load records", Toast.LENGTH_LONG).show()
);
} finally {
mainHandler.post(() -> {
if (progressDialog != null && progressDialog.isShowing()) {
progressDialog.dismiss();
}
});
}
Error messages should explain the next meaningful state without exposing a stack trace. If the problem is a network timeout, offer a retry action. If no records exist, show an empty-state message rather than treating the result as an error. Apps used across Australia may also encounter unreliable regional coverage, so offline-friendly behaviour is valuable.
Testing the experience on real devices
Test with realistic delays instead of relying only on an instant emulator database. Add a temporary delay, use a larger dataset, and test on an older Android phone. A fast development laptop may hide problems that appear on a budget handset or during a weak connection outside Sydney or Perth.
Check rotation, backgrounding, Back-button behaviour, repeated taps, and accessibility services. The progress state should have a meaningful content description, and important status changes should not be conveyed through colour alone. Verify that the screen remains usable with large font settings and that the dialog does not hide the only error message.
A reliable loading experience has a clear beginning, visible progress, and a definite end. When Android developers separate background work from UI updates and handle lifecycle changes deliberately, a progress indicator becomes more than decoration: it keeps the application understandable and responsive while demanding operations finish.