Greetings, traveler!
One of the common UX issues in iOS applications is the appearance of loading indicators for operations that complete almost instantly. When the indicator briefly appears and disappears, it creates a “flicker” effect which users perceive as visual noise. A better approach is to display the indicator only if the operation takes longer than a defined threshold.
In this article, we build a reusable ProgressIndicator
component in SwiftUI that introduces a delay before displaying the loading view. If the associated operation completes quickly, the indicator never appears. If it actually takes time, the indicator becomes visible with an animation.
Rationale Behind the Delay
A typical scenario looks like this:
- The user triggers an action
- The application sets a flag (
isLoading = true
) - The next frame displays a
ProgressView
- The operation finishes quickly and the flag changes to
false
- The indicator disappears
Although this is technically correct, the quick appearance/disappearance feels distracting and unnecessary. Adding a delay means that the indicator is shown only when it makes sense to show it from the user’s perspective.
Implementation Details
The indicator component takes three parameters:
isIndicating
: aBool
flag to trigger the display of the indicatordelay
: aTimeInterval
controlling the threshold (default is0.5
)transition
: an optionalAnyTransition
used when presenting or dismissing the view
A key part of the implementation is the storage of the currently active Task
that runs the delay. When the flag toggles, the task is canceled and replaced as appropriate.
public struct ProgressIndicator<Content: View>: View {
private let isIndicating: Bool
private let delay: TimeInterval
private let transition: AnyTransition
private let content: Content
@State private var isPresented = false
@State private var pendingTask: Task<Void, Never>?
public init(
_ isIndicating: Bool,
delay: TimeInterval = 0.5,
transition: AnyTransition = .scale.combined(with: .opacity),
@ViewBuilder content: () -> Content
) {
self.isIndicating = isIndicating
self.delay = delay
self.transition = transition
self.content = content()
}
public var body: some View {
Group {
if isPresented {
content.transition(transition)
}
}
.onChange(of: isIndicating) { _, newValue in
// Cancel any running delay task
pendingTask?.cancel()
if newValue {
// Start a new delayed task
pendingTask = Task {
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
guard !Task.isCancelled else { return }
withAnimation {
isPresented = true
}
}
} else {
// Hide immediately
pendingTask = nil
withAnimation {
isPresented = false
}
}
}
}
}
Example Usage
Here is a simple demo that toggles the progress indicator on button tap:
struct Demo: View {
@State private var isIndicating: Bool = false
var body: some View {
VStack(spacing: 40) {
ProgressIndicator(isIndicating) {
ProgressView()
.controlSize(.extraLarge)
}
Button("Tap me") {
isIndicating.toggle()
}
.buttonStyle(.borderedProminent)
}
}
}
Conclusion
A delayable indicator is a small but effective enhancement to user experience in cases where loading operations may complete quickly. By deferring the display of the indicator, we eliminate unnecessary visual noise and improve the overall perceived responsiveness of the interface.
GitHub: https://github.com/Livsy90/DelayableIndicator
If you enjoyed this article, please feel free to follow me on my social media: