Igor Skvortsov/Guide - Reusable SwiftUI Button

Created Wed, 30 Apr 2025 00:00:00 +0000 Modified Tue, 08 Jul 2025 22:46:24 +0000
2544 Words 12 min

The short guide on how to render custom view in swipe action.

Intro

In modern SwiftUI projects, it is essential to use reusable components, especially when building a scalable design system. One of the most frequently used components is a button. At first glance, a button might seem like a simple UI element, but in practice, it often requires:

  • Support for different styles (primary, secondary, outline, text),
  • Sizes (small, medium, large),
  • Shapes (rounded, capsule),
  • Proper visual response to states (normal, pressed, disabled),
  • Ability to show a loading indicator.

Result illustration

Why not just use .Button?

Of course, SwiftUI already provides a Button, and it can be customized manually. But as your project grows and you have dozens or hundreds of buttons across screens:

  • Manual configuration leads to code duplication,
  • Inconsistent design appears,
  • It becomes hard to apply global updates to look or behavior.

That’s why we need a unified reusable component based on the ButtonStyle protocol, which allows us to:

  • Standardize button appearance,
  • Handle different states (enabledpressedloading),
  • Be easily configurable through parameters like style, size, shape, etc.

Goal of this guide

In this guide, we will build a universal button in SwiftUI using the ButtonStyle approach. Our goal is to gradually build a component that:

  • Supports multiple visual styles and states,
  • Can be applied via .regularButtonStyle(...),
  • Can be adapted to any design system,
  • Is suitable for open reuse across projects.

How the guide is structured

We won’t jump straight to the final version. Instead, we’ll go step-by-step, adding one feature at a time:

  1. Create a basic ButtonStyle
  2. Add styling for different states
  3. Add size support
  4. Implement button shapes
  5. Handle isPressed and isEnabled
  6. Add isLoading support
  7. Build a convenient .regularButtonStyle(...) modifier
  8. Set up a preview to test and demo all variations

Each step will explain:

  • What we’re adding
  • Why it matters
  • How to implement it in code

2. Basic ButtonStyle

What is ButtonStyle?

In SwiftUI, the ButtonStyle protocol allows customizing the appearance and behavior of buttons. Instead of manually wrapping every Button in backgroundpaddingcornerRadius, etc., you can define a unified style that applies automatically.

It’s similar to CSS styles — define once, reuse everywhere.

Basic usage example:

Button("Click me") {
    print("Clicked")
}
.buttonStyle(MyCustomButtonStyle())

Creating the basic RegularButtonStyle

Let’s start with the simplest version, no configuration yet — just to understand the structure.

import SwiftUI

struct RegularButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .padding()
            .background(Color.blue)
            .foregroundColor(.white)
            .cornerRadius(8)
    }
}

configuration.label — is the content of the button (text, icon, etc.) makeBody(configuration:) is called every time the button state changes (e.g., on press).

Usage:

Button("Basic Button") {
    print("Clicked")
}
.buttonStyle(RegularButtonStyle())

Reacting to presses

The configuration includes an isPressed property — we can use it to change appearance:

struct RegularButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .padding()
            .background(configuration.isPressed ? Color.gray : Color.blue)
            .foregroundColor(.white)
            .cornerRadius(8)
            .animation(.easeInOut, value: configuration.isPressed)
    }
}

Now the button responds visually when pressed.

First step toward reusability

To make the style configurable and reusable, we add parameters:

struct RegularButtonStyle: ButtonStyle {
    let backgroundColor: Color
    let foregroundColor: Color
    let cornerRadius: CGFloat

    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .padding()
            .background(configuration.isPressed ? backgroundColor.opacity(0.7) : backgroundColor)
            .foregroundColor(foregroundColor)
            .cornerRadius(cornerRadius)
    }
}

Usage example:

Button("Primary") {}
    .buttonStyle(
        RegularButtonStyle(
            backgroundColor: .blue,
            foregroundColor: .white,
            cornerRadius: 12
        )
    )

What we’ve accomplished

  • Extracted styling into a separate structure
  • Made button behavior consistent
  • Laid the foundation for future extensions

In the next step, we’ll create a structure to support different button states: normalpressed, and disabled.

3. Styling for Different Button States

Why are states important?

A button in an app is not just a rectangle with text. It can be:

  • Active (normal) — ready to be tapped,
  • Pressed — when the user is holding it down,
  • Disabled — when the action is not available.

Each of these states should be visually distinct, so the user understands what’s going on.

Creating a structure for state-specific styles

We’ll define a RegularButtonStateStyle type that holds styling properties for each state — background, text color, font, size, and whether it’s outlined.

import SwiftUI

public struct RegularButtonStateStyle {
    public typealias StateStyleConfiguration = (normal: Self, disabled: Self, pressed: Self)

    let size: RegularButtonSize
    let font: Font
    let backgroundColor: Color
    let foregroundColor: Color
    let isOutlined: Bool

    init(
        size: RegularButtonSize,
        font: Font,
        backgroundColor: Color,
        foregroundColor: Color,
        isOutlined: Bool = false
    ) {
        self.size = size
        self.font = font
        self.backgroundColor = backgroundColor
        self.foregroundColor = foregroundColor
        self.isOutlined = isOutlined
    }
}

StateStyleConfiguration is a convenient alias to bundle the three states into one logical unit.

Example: primarysecondaryoutlinetext styles

Let’s define some commonly used visual styles:

public extension RegularButtonStateStyle {
    static func primary(size: RegularButtonSize) -> StateStyleConfiguration {
        let font: Font = size == .extraSmall ? .footnote : .subheadline

        return (
            normal: .init(size: size, font: font, backgroundColor: .accentColor, foregroundColor: .white),
            disabled: .init(size: size, font: font, backgroundColor: .accentColor.opacity(0.5), foregroundColor: .gray),
            pressed: .init(size: size, font: font, backgroundColor: .accentColor.opacity(0.8), foregroundColor: .white)
        )
    }

    static func secondary(size: RegularButtonSize) -> StateStyleConfiguration {
        let font: Font = size == .extraSmall ? .footnote : .subheadline

        return (
            normal: .init(size: size, font: font, backgroundColor: Color(.systemGray6), foregroundColor: .primary),
            disabled: .init(size: size, font: font, backgroundColor: Color(.systemGray6), foregroundColor: Color(.systemGray2)),
            pressed: .init(size: size, font: font, backgroundColor: Color(.systemGray4), foregroundColor: .primary)
        )
    }

    static func outline(size: RegularButtonSize) -> StateStyleConfiguration {
        let font = size == .extraSmall ? Font.footnote : Font.subheadline

        return (
            normal: .init(size: size, font: font, backgroundColor: .clear, foregroundColor: .accentColor, isOutlined: true),
            disabled: .init(size: size, font: font, backgroundColor: .clear, foregroundColor: .accentColor.opacity(0.5), isOutlined: true),
            pressed: .init(size: size, font: font, backgroundColor: Color(UIColor.systemBackground), foregroundColor: .accentColor, isOutlined: true)
        )
    }

    static func text(size: RegularButtonSize) -> StateStyleConfiguration {
        let font = size == .extraSmall ? Font.footnote : Font.subheadline

        return (
            normal: .init(size: size, font: font, backgroundColor: .clear, foregroundColor: .blue),
            disabled: .init(size: size, font: font, backgroundColor: .clear, foregroundColor: .blue.opacity(0.5)),
            pressed: .init(size: size, font: font, backgroundColor: .clear, foregroundColor: .indigo)
        )
    }
}

Applying state style configuration

Soon, inside RegularButtonStyle, we’ll be able to inject and use the full StateStyleConfiguration:

Button("Save") {}
    .buttonStyle(
        RegularButtonStyle(
            stateStyleConfiguration: RegularButtonStateStyle.primary(size: .medium)
        )
    )

What we’ve accomplished

  • Defined a consistent styling model for all three button states
  • Gained flexibility in defining color, font, outline per state
  • Set the stage for scalable and reusable button behavior

4. Supporting Different Sizes

Why does size matter?

Buttons come in different contexts:

  • Large CTA buttons on main screens,
  • Compact buttons inside cards or toolbars,
  • Micro buttons next to text.

Having consistent size definitions makes the UI predictable and easier to scale.

Introducing RegularButtonSize

We’ll create an enum that defines fixed height and minimum width:

public enum RegularButtonSize {
    case extraSmall
    case small
    case medium
    case large

    var height: CGFloat {
        switch self {
        case .extraSmall: 38
        case .small: 48
        case .medium: 52
        case .large: 56
        }
    }

    var minWidth: CGFloat {
        switch self {
        case .extraSmall: 80
        case .small, .medium, .large: 96
        }
    }
}

This structure allows for consistent spacing and sizing across all button variations.

How it’s used in styles

Each RegularButtonStateStyle includes a size property, which can be accessed from within RegularButtonStyle:

.frame(height: stateStyle(for: configuration).size.height)
.frame(
    minWidth: stateStyle(for: configuration).size.minWidth,
    maxWidth: fillMaxWidth ? .infinity : nil
)

This guarantees buttons will align consistently across views.

Size also impacts font

We can adjust the font dynamically:

let font: Font = switch size {
case .extraSmall: .footnote
case .small, .medium, .large: .subheadline
}

This logic is already used inside RegularButtonStateStyle.

And in RegularButtonStyle, we simply apply it:

.font(stateStyle(for: configuration).font)

What we’ve accomplished

  • Defined centralized size definitions
  • Matched font and layout to button context
  • Improved visual consistency across all screens

Next, we’ll move on to defining button shapes.

5. Button Shapes

Why offer different shapes?

Different shapes allow buttons to fit into different design contexts:

  • Capsule — soft and friendly, commonly used for prominent CTAs.
  • Rounded rectangle — more neutral, often consistent with cards and list items.

Support for shape customization improves component flexibility and helps match your design system.

Enum RegularButtonShape

Let’s define the shape options:

public enum RegularButtonShape {
    case capsule
    case rounded
}

Applying shape with a modifier

We’ll use a ViewModifier to apply the shape:

private struct ShapeModifier: ViewModifier {
    let shape: RegularButtonShape
    let cornerRadius: CGFloat

    func body(content: Content) -> some View {
        switch shape {
        case .capsule:
            content.clipShape(Capsule())
        case .rounded:
            content.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
        }
    }
}

In makeBody, we apply this modifier:

.modifier(ShapeModifier(shape: shape, cornerRadius: Constants.cornerRadius))

Outline border support

For outline styles, we also want a stroke around the shape:

.background {
    if stateStyle(for: configuration).isOutlined {
        switch shape {
        case .capsule:
            Capsule().stroke(foregroundColor(configuration), lineWidth: 1)
        case .rounded:
            RoundedRectangle(cornerRadius: Constants.cornerRadius)
                .stroke(foregroundColor(configuration), lineWidth: 1)
        }
    }
}

Making it configurable

We add the shape parameter to the style initializer:

public init(
    stateStyleConfiguration: RegularButtonStateStyle.StateStyleConfiguration,
    fillMaxWidth: Bool = false,
    shape: RegularButtonShape = .capsule,
    isLoading: Bool = false
) {
    self.stateStyleConfiguration = stateStyleConfiguration
    self.fillMaxWidth = fillMaxWidth
    self.shape = shape
    self.isLoading = isLoading
}

Usage example

Button("Rounded Button") {}
    .regularButtonStyle(shape: .rounded)

What we’ve accomplished

  • Introduced two shape styles
  • Made shape application reusable via a modifier
  • Ensured outline styles render properly for any shape

Next, we’ll teach the button how to react to isPressed and isEnabled.

6. Handling isPressed and isEnabled

Why is this important?

Users expect feedback from UI elements:

  • The button should visually respond when pressed (isPressed)
  • The button should appear inactive when disabled (isEnabled == false)

Without visual feedback, the interface feels unresponsive and confusing.

SwiftUI provides built-in support for this:

  • configuration.isPressed — indicates if the button is actively pressed
  • @Environment(\.isEnabled) — indicates whether the button is enabled or not

Selecting the correct state style

In RegularButtonStyle, we define a helper method to choose the appropriate style configuration:

private func stateStyle(for configuration: Configuration) -> RegularButtonStateStyle {
    if configuration.isPressed {
        return stateStyleConfiguration.pressed
    } else if isEnabled {
        return stateStyleConfiguration.normal
    } else {
        return stateStyleConfiguration.disabled
    }
}

Order matters: isPressed takes priority over isEnabled.

Applying the selected style

Now we use stateStyle(for:) to apply the correct values dynamically:

.font(stateStyle(for: configuration).font)
.foregroundStyle(foregroundColor(configuration))
.background(backgroundColor(configuration))
.frame(height: stateStyle(for: configuration).size.height)

Each visual attribute adjusts based on the button’s state.

Example usage

A button that visually reflects its disabled state:

Button("Save") {}
    .regularButtonStyle(
        stateStyleConfiguration: RegularButtonStateStyle.primary(size: .medium)
    )
    .disabled(true)

What we’ve accomplished

  • The button reacts visually to being pressed or disabled
  • Style selection logic is encapsulated and reusable
  • We simplified interaction logic for consuming views

Next, we’ll implement support for loading indicators with isLoading.

7. Supporting isLoading

Why support loading state?

Buttons often trigger asynchronous actions — like API calls — where it’s important to:

  • Prevent multiple taps while waiting,
  • Show visual feedback that the operation is in progress.

A loading indicator provides a clear signal to the user that something is happening.

Adding isLoading to the style

Extend the initializer of RegularButtonStyle:

public init(
    stateStyleConfiguration: RegularButtonStateStyle.StateStyleConfiguration,
    fillMaxWidth: Bool = false,
    shape: RegularButtonShape = .capsule,
    isLoading: Bool = false
) {
    self.stateStyleConfiguration = stateStyleConfiguration
    self.fillMaxWidth = fillMaxWidth
    self.shape = shape
    self.isLoading = isLoading
}

Replacing label with ProgressView

In the makeBody(...) method, swap out the button content with a spinner when loading:

ZStack {
    if isLoading {
        ProgressView()
            .progressViewStyle(CircularProgressViewStyle(tint: foregroundColor(configuration)))
    } else {
        configuration.label
    }
}

Disabling the button while loading

.disabled(isLoading)

This ensures the user can’t interact with the button multiple times.

Animating the transition

Add animation for a smoother UI experience:

.animation(.bouncy, value: isLoading)

If you’re targeting iOS 16 or earlier, replace .bouncy with .easeInOut

Example usage

@State private var isLoading = false

Button("Submit") {
    isLoading = true
    Task {
        try? await Task.sleep(nanoseconds: 1_000_000_000)
        isLoading = false
    }
}
.regularButtonStyle(isLoading: isLoading)

What we’ve accomplished

  • Users get clear feedback during long operations
  • The button is safely disabled to prevent duplicate taps
  • The component remains flexible and composable

Next, we’ll simplify usage even further with a custom view modifier.regularButtonStyle(...)

8. Creating a Convenient .regularButtonStyle(...)Modifier

Why use a custom modifier?

After adding multiple configuration options, the default .buttonStyle(...) call can become verbose:

Button("Click me") {
    // ...
}
.buttonStyle(
    RegularButtonStyle(
        stateStyleConfiguration: RegularButtonStateStyle.primary(size: .medium),
        fillMaxWidth: true,
        shape: .capsule,
        isLoading: false
    )
)

To make this cleaner and more intuitive, we can wrap it in a custom view modifier.

Implementing the extension

public extension SwiftUI.View {
    func regularButtonStyle(
        stateStyleConfiguration: RegularButtonStateStyle.StateStyleConfiguration = RegularButtonStateStyle.primary(size: .medium),
        fillMaxWidth: Bool = false,
        shape: RegularButtonShape = .capsule,
        isLoading: Bool = false
    ) -> some View {
        self.buttonStyle(
            RegularButtonStyle(
                stateStyleConfiguration: stateStyleConfiguration,
                fillMaxWidth: fillMaxWidth,
                shape: shape,
                isLoading: isLoading
            )
        )
    }
}

Thanks to default parameters, most buttons can be declared with minimal syntax.

Example usages

Simple use with default parameters

Button("Continue") {}
    .regularButtonStyle()

Secondary style with loading state

Button("Save") {}
    .regularButtonStyle(
        stateStyleConfiguration: RegularButtonStateStyle.secondary(size: .small),
        isLoading: true
    )

Full-width, rounded shape

Button("Submit") {}
    .regularButtonStyle(
        fillMaxWidth: true,
        shape: .rounded
    )

Benefits of this approach

  • Cleaner API for consumers
  • More readable and expressive usage
  • Aligns with SwiftUI’s declarative design principles

What we’ve accomplished

  • Simplified the usage of our button style
  • Reduced boilerplate and improved readability
  • Made the component feel native to SwiftUI

Next, we’ll build a preview container to showcase and test all variations of the button.

9. Preview and Testing

Why use previews?

SwiftUI’s live previews allow us to quickly test how components behave:

  • View different configurations side-by-side
  • Validate layout, responsiveness, and states
  • Save time without needing to run the full app

Creating a dedicated preview for our button helps us verify that all variations look and behave correctly.

Preview container implementation

private struct RegularButtonPreviewContainer: View {
    @State private var isLoading: Bool = false

    var body: some View {
        VStack(spacing: 16) {
            Button("Toggle Loading") {
                isLoading.toggle()
            }
            .buttonStyle(.borderedProminent)

            Divider()

            Group {
                Button("Primary") {}
                    .regularButtonStyle(isLoading: isLoading)

                Button("Primary Rounded") {}
                    .regularButtonStyle(shape: .rounded, isLoading: isLoading)
            }

            Group {
                Button("Secondary") {}
                    .regularButtonStyle(
                        stateStyleConfiguration: RegularButtonStateStyle.secondary(size: .medium),
                        isLoading: isLoading
                    )

                Button("Secondary Rounded") {}
                    .regularButtonStyle(
                        stateStyleConfiguration: RegularButtonStateStyle.secondary(size: .medium),
                        shape: .rounded,
                        isLoading: isLoading
                    )
            }

            Group {
                Button("Outline") {}
                    .regularButtonStyle(
                        stateStyleConfiguration: RegularButtonStateStyle.outline(size: .medium),
                        isLoading: isLoading
                    )

                Button("Outline Rounded") {}
                    .regularButtonStyle(
                        stateStyleConfiguration: RegularButtonStateStyle.outline(size: .medium),
                        shape: .rounded,
                        isLoading: isLoading
                    )
            }

            Group {
                Button("Text") {}
                    .regularButtonStyle(
                        stateStyleConfiguration: RegularButtonStateStyle.text(size: .medium),
                        isLoading: isLoading
                    )
            }
        }
        .padding()
    }
}

Launching the preview

#Preview {
    RegularButtonPreviewContainer()
}

This provides a live environment where you can toggle loading and observe UI behavior instantly.

What we’ve accomplished

  • Created a sandbox to visually inspect all button styles
  • Simplified QA and design review for UI states
  • Built a great foundation for snapshot testing

Next, we’ll wrap up with a summary and ideas for further evolution.

10. Final Thoughts and Scalability

What we’ve built

We’ve created a powerful and flexible SwiftUI button component that:

  • Uses the ButtonStyle protocol for customization
  • Supports:
    • Multiple visual styles (primary, secondary, outline, text)
    • Sizes and shapes
    • Disabled, pressed, and loading states
  • Exposes a clean and reusable .regularButtonStyle(...) modifier
  • Includes a preview system for testing and demonstration

Professional benefits

  • Consistency — centralizes styling logic across your app
  • Reusability — avoids duplicate styling code
  • Scalability — easy to extend for new themes, states, or animations

Ideas for future expansion

  • Add support for icons and icon alignment
  • Use design tokens for spacing, font, and color
  • Integrate haptic feedback
  • Improve accessibility with VoiceOver traits
  • Add UI snapshot testing
  • Bundle as a Swift Package for sharing

When this is overkill

For simple apps with 1–2 buttons, this abstraction may feel heavy. But for larger projects, this investment pays off by improving maintainability and collaboration.

Summary

Building this kind of component is about more than just appearance — it’s about defining how your product feels to interact with. With this reusable approach, you’ll ship features faster, with fewer bugs, and greater consistency.