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.

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 (
enabled,pressed,loading), - 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:
- Create a basic
ButtonStyle - Add styling for different states
- Add size support
- Implement button shapes
- Handle
isPressedandisEnabled - Add
isLoadingsupport - Build a convenient
.regularButtonStyle(...)modifier - 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 background, padding, cornerRadius, 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: normal, pressed, 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
}
}
StateStyleConfigurationis a convenient alias to bundle the three states into one logical unit.
Example: primary, secondary, outline, text 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:
isPressedtakes priority overisEnabled.
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
.bouncywith.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
ButtonStyleprotocol 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.