A Delayable Progress Indicator in SwiftUI


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: a Bool flag to trigger the display of the indicator
  • delay: a TimeInterval controlling the threshold (default is 0.5)
  • transition: an optional AnyTransition 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