Customizing the Appearance and Interaction Behavior of Buttons

Published on

Get weekly handpicked updates on Swift and SwiftUI!

Changing the appearance or behavior of components through Style is a very powerful feature provided by SwiftUI. This article will introduce how to customize the appearance and interaction behavior of a Button by creating implementations that conform to the ButtonStyle or PrimitiveButtonStyle protocol.

The example code for this article can be found here

Customizing the Appearance of a Button

Buttons are a common component in UI design. Compared to UIKit, SwiftUI uses the Button view, allowing developers to create buttons with minimal code.

Swift
Button(action: signIn) {
    Text("Sign In")
}

In most cases, developers customize the appearance of a button by providing different views to the label parameter of the Button.

Swift
struct RoundedAndShadowButton<V>:View where V:View {
    let label:V
    let action: () -> Void
    init(label: V, action: @escaping () -> Void) {
        self.label = label
        self.action = action
    }
    var body: some View {
        Button {
            action()
        } label: {
            label
                .foregroundColor(.white)
                .padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
                .background(
                    RoundedRectangle(cornerRadius: 10)
                        .foregroundColor(.blue)
                    )
                .compositingGroup()
                .shadow(radius: 5,x:0,y:3)
                .contentShape(Rectangle())
        }
        .buttonStyle(.plain)
    }
}

let label = Label("Press Me", systemImage: "digitalcrown.horizontal.press.fill")

RoundedAndShadowButton(label: label, action: { pressAction("button view") })

https://cdn.fatbobman.com/buttonView_2023-02-15_17.36.59.2023-02-15%2017_38_28.gif

Customize Interaction Animation with ButtonStyle

Unfortunately, the above code cannot modify the button’s press effect after clicking. Luckily, SwiftUI provides the ButtonStyle protocol to help us customize interaction animations.

Swift
public protocol ButtonStyle {
    @ViewBuilder func makeBody(configuration: Self.Configuration) -> Self.Body
    typealias Configuration = ButtonStyleConfiguration
}

public struct ButtonStyleConfiguration {
    public let role: ButtonRole?
    public let label: ButtonStyleConfiguration.Label
    public let isPressed: Bool
}

The usage of the ButtonStyle protocol is very similar to ViewModifier. By using the information provided by ButtonStyleConfiguration, developers only need to implement the makeBody method to customize the interactive animation.

  • label: The current view of the target button, usually corresponding to the label parameter content in the Button view.
  • role: A parameter added in iOS 15 to identify the role of the button (cancel or destructive).
  • isPressed: The current pressed state of the button, which is the driving force for most people to use ButtonStyle.
Swift
struct RoundedAndShadowButtonStyle:ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .foregroundColor(.white)
            .padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
            .background(
                RoundedRectangle(cornerRadius: 10)
                    .foregroundColor(.blue)
                )
            .compositingGroup()
            // Adjust interactive animations based on isPressing
            .shadow(radius:configuration.isPressed ? 0 : 5,x:0,y: configuration.isPressed ? 0 :3)
            .scaleEffect(configuration.isPressed ? 0.95 : 1)
            .animation(.spring(), value: configuration.isPressed)
    }
}

extension ButtonStyle where Self == RoundedAndShadowButtonStyle {
    static var roundedAndShadow:RoundedAndShadowButtonStyle {
        RoundedAndShadowButtonStyle()
    }
}

Applying the buttonStyle decorator to the Button view

Swift
Button(action: { pressAction("rounded and shadow") }, label: { label })
       .buttonStyle(.roundedAndShadow)

https://cdn.fatbobman.com/buttonStyle1_2023-02-15_18.27.17.2023-02-15%2018_28_25.gif

Creating a versatile ButtonStyle implementation requires considering many conditions, such as role, controlSize, dynamic font size, color mode, and so on. Similar to ViewModifier, you can access more information through environment values:

Swift
struct RoundedAndShadowProButtonStyle:ButtonStyle {
    @Environment(\.controlSize) var controlSize
    func makeBody(configuration: Configuration) -> some View {
            configuration.label
                .foregroundColor(.white)
                .padding(getPadding())
                .font(getFontSize())
                .background(
                    RoundedRectangle(cornerRadius: 10)
                        .foregroundColor( configuration.role == .destructive ? .red : .blue)
                )
                .compositingGroup()
                .overlay(
                    VStack {
                        if configuration.isPressed {
                            RoundedRectangle(cornerRadius: 10)
                                .fill(Color.white.opacity(0.5))
                                .blendMode(.hue)
                        }
                    }
                    )
                .shadow(radius:configuration.isPressed ? 0 : 5,x:0,y: configuration.isPressed ? 0 :3)
                .scaleEffect(configuration.isPressed ? 0.95 : 1)
                .animation(.spring(), value: configuration.isPressed)
    }

    func getPadding() -> EdgeInsets {
        let unit:CGFloat = 4
        switch controlSize {
            case .regular:
                return EdgeInsets(top: unit * 2, leading: unit * 4, bottom: unit * 2, trailing: unit * 4)
            case .large:
                return EdgeInsets(top: unit * 3, leading: unit * 5, bottom: unit * 3, trailing: unit * 5)
            case .mini:
                return EdgeInsets(top: unit / 2, leading: unit * 2, bottom: unit/2, trailing: unit * 2)
            case .small:
                return EdgeInsets(top: unit, leading: unit * 3, bottom: unit, trailing: unit * 3)
            @unknown default:
                fatalError()
        }
    }

    func getFontSize() -> Font {
        switch controlSize {
            case .regular:
                return .body
            case .large:
                return .title3
            case .small:
                return .callout
            case .mini:
                return .caption2
            @unknown default:
                fatalError()
        }
    }
}

extension ButtonStyle where Self == RoundedAndShadowProButtonStyle {
    static var roundedAndShadowPro:RoundedAndShadowProButtonStyle {
        RoundedAndShadowProButtonStyle()
    }
}

// 使用
HStack {
    Button(role: .destructive, action: { pressAction("rounded and shadow pro") }, label: { label })
        .buttonStyle(.roundedAndShadowPro)
        .controlSize(.large)
    Button(action: { pressAction("rounded and shadow pro") }, label: { label })
        .buttonStyle(.roundedAndShadowPro)
        .controlSize(.small)
}

https://cdn.fatbobman.com/image-20230215183940567.png

Customizing Interaction Behavior with PrimitiveButtonStyle

In SwiftUI, the default interaction behavior of a Button is to perform the specified action when the button is released. Additionally, when you tap the button and keep your finger (or mouse) pressed without releasing it, the action will still be executed even if you move outside the Button view.

Although the default gesture of a Button is similar to a TapGesture, the Button’s gesture is an irreversible action. However, if you use a TapGesture and move outside the clickable area without releasing your finger, SwiftUI will not invoke the action specified in the onEnded closure.

According to feedback from Yoo_Das, the statement “the Button’s gesture is an irreversible action” in the previous paragraph is not accurate enough. The Button’s gesture can be considered a conditional and reversible action. After pressing the button, if you move your finger beyond a certain preset distance (the specific value is unclear) before releasing it, the closure of the button will not be called.

If we want to achieve a similar effect as the TapGesture (a button that can be canceled), we can use another protocol provided by SwiftUI called PrimitiveButtonStyle.

Swift
public protocol PrimitiveButtonStyle {
    @ViewBuilder func makeBody(configuration: Self.Configuration) -> Self.Body
    typealias Configuration = PrimitiveButtonStyleConfiguration
}

public struct PrimitiveButtonStyleConfiguration {
    public let role: ButtonRole?
    public let label: PrimitiveButtonStyleConfiguration.Label
    public func trigger()
}

The main difference between PrimitiveButtonStyle and ButtonStyle is that PrimitiveButtonStyle requires developers to manually handle the interaction logic and call the trigger method at the appropriate time (which can be understood as the closure corresponding to the action parameter of Button).

Swift
struct CancellableButtonStyle:PrimitiveButtonStyle {
    @GestureState var isPressing = false

    func makeBody(configuration: Configuration) -> some View {
        let drag = DragGesture(minimumDistance: 0)
            .updating($isPressing, body: {_,pressing,_ in
                if !pressing { pressing = true}
            })

        configuration.label
            .foregroundColor(.white)
            .padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
            .background(
                RoundedRectangle(cornerRadius: 10)
                    .foregroundColor( configuration.role == .destructive ? .red : .blue)
            )
            .compositingGroup()
            .shadow(radius:isPressing ? 0 : 5,x:0,y: isPressing ? 0 :3)
            .scaleEffect(isPressing ? 0.95 : 1)
            .animation(.spring(), value: isPressing)
            // Get click status
            .gesture(drag)
            .simultaneousGesture(TapGesture().onEnded{
                configuration.trigger() // Perform the action specified by Button
            })
    }
}

extension PrimitiveButtonStyle where Self == CancellableButtonStyle {
    static var cancellable:CancellableButtonStyle {
        CancellableButtonStyle()
    }
}

https://cdn.fatbobman.com/cancallableStyle_2023-02-15_19.06.47.2023-02-15%2019_08_00.gif

Perhaps someone would say that since the code above can simulate the click state through DragGesture, it is possible to achieve the same effect without using PrimitiveButtonStyle. In this case, what are the advantages of using Style?

  • ButtonStyle and PrimitiveButtonStyle are API specifically designed for button styles. They can be applied not only to Button views, but also to many pre-built system button functionalities in SwiftUI, such as EditButton, Share, Link, NavigationLink (not in List), etc.
  • The keyboardShortcut modifier can only be applied to Button, and the view + TapGesture cannot set shortcuts.

Whether it is double-clicking, long-pressing, or even triggered by motion sensing, developers can customize their button interaction logic through the PrimitiveButtonStyle protocol.

Pre-built System Styles

Starting from iOS 15, SwiftUI provides richer pre-built Styles based on the original PlainButtonStyle and DefaultButtonStyle.

  • PlainButtonStyle: No modifications are made to the Button view.
  • BorderlessButtonStyle: The default style in most cases. If the text color is not specified, the text is changed to the accent color.
  • BorderedButtonStyle: Adds a rounded rectangular background to the button, using the tint color as the background color.
  • BorderedProminentButtonStyle: Adds a rounded rectangular background to the button, with the system’s accent color as the background color.

Among them, PlainButtonStyle not only applies to Button, but also affects the behavior of cells in List and Form. By default, even if the view of the cell contains multiple buttons, SwiftUI will treat the cell of the List as a single button (calling the actions of all buttons when clicked). By setting the PlainButtonStyle style for the List, this behavior can be adjusted so that multiple buttons in a cell can be triggered separately.

Swift
List {
    HStack {
        Button("11"){print("1")}
        Button("22"){print("2")}
    }
}
.buttonStyle(.plain)

Notes

  • Unlike ViewModifier, ButtonStyle does not support chaining. Button will only adopt the nearest Style.
Swift
VStack {
    Button("11"){print("1")} // plain
    Button("22"){print("2")} // borderless
        .buttonStyle(.borderless)
    Button("33"){print("3")} // borderedProminent
        .buttonStyle(.borderedProminent)
        .buttonStyle(.borderless)
}
.buttonStyle(.plain)
  • Some button styles behave and appear differently in different contexts, and may not even work. For example, it is not possible to style a NavigationLink within a List.
  • Adding gesture operations (such as TapGesture) to the label view or ButtonStyle implementation of a Button will cause the Button to no longer invoke its specified closure action. Additional gestures should be added outside of the Button, for example using the simultaneousGesture implementation mentioned below.

Add a Trigger to a Button

In SwiftUI, to determine whether a button has been pressed (especially system buttons), we usually add a trigger by setting parallel gestures:

Swift
EditButton()
    .buttonStyle(.roundedAndShadowPro)
    .simultaneousGesture(TapGesture().onEnded{ print("pressed")}) 
    .withTitle("edit button with simultaneous trigger")

However, the above method does not work on macOS. Through Style, we can add a trigger to the button style when setting its style:

Swift
struct TriggerActionStyle:ButtonStyle {
    let trigger:() -> Void
    init(trigger: @escaping () -> Void) {
        self.trigger = trigger
    }
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .foregroundColor(.white)
            .padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
            .background(
                RoundedRectangle(cornerRadius: 10)
                    .foregroundColor(.blue)
                )
            .compositingGroup()
            .shadow(radius:configuration.isPressed ? 0 : 5,x:0,y: configuration.isPressed ? 0 :3)
            .scaleEffect(configuration.isPressed ? 0.95 : 1)
            .animation(.spring(), value: configuration.isPressed)
            .onChange(of: configuration.isPressed){ isPressed in
                if !isPressed {
                    trigger()
                }
            }
    }
}

extension ButtonStyle where Self == TriggerActionStyle {
    static func triggerAction(trigger perform:@escaping () -> Void) -> TriggerActionStyle {
        .init(trigger: perform)
    }
}

https://cdn.fatbobman.com/trigger1_2023-02-15_20.08.05.2023-02-15%2020_09_17.gif

Of course, you can achieve the same result using PrimitiveButtonStyle:

Swift
struct TriggerButton2: PrimitiveButtonStyle {
    var trigger: () -> Void

    func makeBody(configuration: PrimitiveButtonStyle.Configuration) -> some View {
        MyButton(trigger: trigger, configuration: configuration)
    }

    struct MyButton: View {
        @State private var pressed = false
        var trigger: () -> Void

        let configuration: PrimitiveButtonStyle.Configuration

        var body: some View {
            return configuration.label
                .foregroundColor(.white)
                .padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
                .background(
                    RoundedRectangle(cornerRadius: 10)
                        .foregroundColor(.blue)
                )
                .compositingGroup()
                .shadow(radius: pressed ? 0 : 5, x: 0, y: pressed ? 0 : 3)
                .scaleEffect(pressed ? 0.95 : 1)
                .animation(.spring(), value: pressed)
                .onLongPressGesture(minimumDuration: 2.5, maximumDistance: .infinity, pressing: { pressing in
                    withAnimation(.easeInOut(duration: 0.3)) {
                        self.pressed = pressing
                    }
                    if pressing {
                        configuration.trigger()
                        trigger()
                    } else {
                        print("release")
                    }
                }, perform: {})
        }
    }
}

https://cdn.fatbobman.com/trigger2_2023-02-15_20.15.56.2023-02-15%2020_16_30.gif

Summary

Although the effect of custom styles is significant, unfortunately, SwiftUI currently only provides a few component style protocols for developers to customize, and the properties provided are also limited. Hopefully, in future versions, SwiftUI can provide developers with more powerful custom component capabilities.

Weekly Swift & SwiftUI insights, delivered every Monday night. Join developers worldwide.
Easy unsubscribe, zero spam guaranteed