Articles about Swift development, by Konstantin Semianov

Building a SwiftUI LoadingButtonStyle

LoadingButtonStyle in action

Intro

When creating an application, quite often clicking a button means triggering some asynchronous action. If this action is non-blocking for your UX, you might consider making an optimistic update. For example, when you tap a like button, you just let the app behave as if the request has already been completed successfully and increment the like counter, but roll-back if it actually fails later. However, this is not always possible. Consider user authentication. A user enters credentials and the app has to wait until the server responds. The app should always provide feedback on the loading process, otherwise, a user might think that something broke, try to tap the button multiple times or even abandon the app altogether. The simplest way to communicate the loading process is to show a progress indicator instead/alongside the button label. Let's explore how to do it with the ButtonStyle API in SwiftUI.

Demo

Let's start with the simplest SwiftUI button:

import SwiftUI

#Preview {
    Button("Click me") { print("Clicked") }
}
Simple button

Create a new ButtonStyle and implement its only requirement makeBody function:

struct LoadingButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
    }
}

#Preview {
    Button("Click me") { print("Clicked") }
        .buttonStyle(LoadingButtonStyle())
}
Button with custom style

At the moment, our custom button style is not doing much, and even removes the default behavior with foreground style, pressed state and disabled animation. Let's bring it closer to the original:

struct LoadingButtonStyle: ButtonStyle {
    @Environment(\.isEnabled) private var isEnabled

    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .foregroundStyle(Color.accentColor)
            .opacity(configuration.isPressed ? 0.2 : 1)
            .animation(.default, value: isEnabled)
    }
}

Now, we need to add a boolean flag to our style to indicate whether it is currently in the loading state:

struct LoadingButtonStyle: ButtonStyle {
    @Environment(\.isEnabled) private var isEnabled
    private var isLoading: Bool

    init(isLoading: Bool) {
        self.isLoading = isLoading
    }

    func makeBody(configuration: Configuration) -> some View {
        HStack(spacing: 8) {
            if isLoading {
                ProgressView()
            }

            configuration.label
        }
        .foregroundStyle(Color.accentColor)
        .opacity(configuration.isPressed ? 0.2 : 1)
        .animation(.default, value: isEnabled)
        .animation(.default, value: isLoading)
    }
}

It is also possible to substitute the original label with the ProgressView if that's what you wish. Just replace the HStack with this:

ZStack {
    ProgressView().opacity(isLoading ? 1 : 0)

    configuration.label.opacity(isLoading ? 0 : 1)
}

Now, how do we preview this? Let's create a function that imitates some asynchronous work that lasts for 2 seconds:

private func performWork() async {
    try? await Task.sleep(nanoseconds: 2_000_000_000)
}

Now let's define a container for preview purposes and see what we get:

private struct PreviewContainer: View {
    @State private var loadingTask: Task<Void, Never>?
    private var isLoading: Bool { loadingTask != nil }

    var body: some View {
        Button("Click me") {
            guard loadingTask == nil else {
                return
            }

            loadingTask = Task {
                await performWork()

                await MainActor.run {
                    loadingTask = nil
                }
            }
        }
        .buttonStyle(LoadingButtonStyle(isLoading: isLoading))
        .disabled(isLoading)
        .onDisappear {
            loadingTask?.cancel()
            loadingTask = nil
        }
    }
}

#Preview {
    PreviewContainer()
}
Fully-working custom style

Note that we save the asynchronous action to our state and cancel the task if the button happens to disappear before the asynchronous operation completes. This will help avoid an undefined behavior when a user already navigated away from the screen and then the operation completes. Furthermore, when the async operation is in progress, it’s important to ensure that additional taps on the button do not trigger multiple concurrent tasks, leading to a race condition. Here guard loadingTask == nil and .disabled(isLoading) help with that.

It is also possible to manage more states for the button. For example, normal, loading, success and failure. For this you'd just create an enum for all your states and change isLoading: Bool variable to something like buttonState: ButtonState and adjust makeBody accordingly. But we'll leave this as an exercise to the reader.

Final thoughts

We've built our LoadingButtonStyle as a reusable component for handling button asynchronous state. It's important to resist the allure of just creating a separate view for every little thing instead of making a reusable component for your design system. This is crucial for ensuring design consistency across your app, and it will help you iterate on your codebase more quickly.