Exploring SwiftUI's Button styles

SwiftUI
26 January 2021

Button is no doubt one of the most popular SwiftUI elements, it's also very special, as it is the only component with two different style protocols: ButtonStyle and PrimitiveButtonStyle.

In this article, let's explore everything there's to know about button styling, and more.

As usual the focus is on iOS, the same concepts can be applied to all platforms with little change.

Starting up

SwiftUI comes with three built-in styles: DefaultButtonStyle, BorderlessButtonStyle, and PlainButtonStyle.

When declaring a simple button, DefaultButtonStyle is applied:

Button("Simple button") { 
  // button tapped
  ...
}

DefaultButtonStyle is not a style per se: it's our way to let SwiftUI pick the style for us (based on the context, platform, parent views, and more).

The actual default style is BorderlessButtonStyle, which applies a blue tint on top of our button, or the app accent color if we're on iOS 14, along with some visual effects when tapped, focused, etc.

Unless we are in an exception (I haven't found one yet, please let me know if you do), the following three declarations are equivalent:

Button("Simple button") { 
  ...
}

Button("Simple button") { 
  ...
}
.buttonStyle(DefaultButtonStyle())

Button("Simple button") { 
  ...
}
.buttonStyle(BorderlessButtonStyle())

In iOS 13 the (blue) tint is applied to images declared within our button label, to avoid so we need to either add a rendering modifier to our images (e.g. Image("image").renderingMode(.original)) or declare the proper rendering in the image asset catalog.From iOS 14 only template images will be tinted by default.

Lastly, SwiftUI offers PlainButtonStyle, which displays the button label without a tint, but still applies visual effects in different states:

Button("Plain style button") { 
  // button tapped
  ...
}
.buttonStyle(PlainButtonStyle())

These are all the styles SwiftUI provides us in iOS: thankfully we can create new ones with ButtonStyle and PrimitiveButtonStyle, let's start with ButtonStyle.

ButtonStyle

The documentation suggests us to use ButtonStyle when we're declaring the button appearance ourselves, but the button interaction behaves as any other standard button (a.k.a. its action is triggered when tapped).

public protocol ButtonStyle {
  associatedtype Body: View

  func makeBody(configuration: Self.Configuration) -> Self.Body

  typealias Configuration = ButtonStyleConfiguration
}

The only requirement of ButtonStyle is to return a view from makeBody(configuration:), the function takes in a ButtonStyleConfiguration instance:

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

This configuration comes with two properties:

  • label is the button label, for example if our button is Button(action: {}, label: { Text("Hello world") }), then Text("Hello world") will be our label
  • isPressed is the current state of the button, which can be used in ButtonStyle's makeBody(configuration:) for visual effects

Let's define a few examples:

struct RoundedRectangleButtonStyle: ButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    HStack {
      Spacer()
      configuration.label.foregroundColor(.black)
      Spacer()
    }
    .padding()
    .background(Color.yellow.cornerRadius(8))
    .scaleEffect(configuration.isPressed ? 0.95 : 1)
  }
}
struct ShadowButtonStyle: ButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    configuration.label
      .shadow(
        color: configuration.isPressed ? Color.blue : Color.black,
        radius: 4, x: 0, y: 5
      )
  }
}

Note how these new buttons do not come with the default effects when tapped, focused, etc: it's now up to us to add such effects in out buttons.

This is all there is to know about ButtonStyle. It lets us customize any button appearance, the main advantages are:

  • can apply the same style to multiple buttons without code duplication
  • access to the isPressed event
  • keeps the standard interaction/behavior

Applying and composing multiple styles

Button doesn't have an initializer accepting a ButtonStyleConfiguration instance (FB8979053), making things complicated when composing multiple styles together.

As per our current declarations, applying multiple ButtonStyles has no effect, and only the closest style will be used (the makeBody(configuration:) of other styles won't even be called):

// Only RoundedRectangleButtonStyle is applied
Button("Rounded rectangle button style") {
  // button tapped
  ...
}
.buttonStyle(RoundedRectangleButtonStyle())
.buttonStyle(ShadowButtonStyle())
.buttonStyle(BorderlessButtonStyle())
.buttonStyle(DefaultButtonStyle())

A "workaround" for this limitation is to return a new Button in our ButtonStyle makeBody(configuration:) function, for example we could update RoundedRectangleButtonStyle as following:

struct RoundedRectangleButtonStyle: ButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    Button(action: {}, label: {
      HStack {
        Spacer()
        configuration.label.foregroundColor(.black)
        Spacer()
      }
    })
    // 👇🏻 makes all taps go to the the original button
    .allowsHitTesting(false)
    .padding()
    .background(Color.yellow.cornerRadius(8))
    .scaleEffect(configuration.isPressed ? 0.95 : 1)
  }
}

With this new definition the previous example almost works:

Button("Rounded rectangle + shadow button style") {
  // button tapped
  ...
}
.buttonStyle(RoundedRectangleButtonStyle())
.buttonStyle(ShadowButtonStyle())

The main drawback is that further styles are applied to fictitious and not tappable buttons, thus not receiving any isPressed events.

There are more advanced solutions for these drawbacks, but at this point we're fighting the framework:
instead, it's better to consider each .buttonStyle(..) as a complete override of previous ones.

An easy fix for such limitations, at least for the moment, is to create and use a new style which combines the desired effects, for example:

struct RoundedRectangleWithShadowedLabelButtonStyle: ButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    HStack {
      Spacer()
      configuration.label.foregroundColor(.black)
        .shadow(
          color: configuration.isPressed ? Color.red : Color.black,
          radius: 4, x: 0, y: 5
        )
      Spacer()
    }
    .padding()
    .background(Color.yellow.cornerRadius(8))
    .scaleEffect(configuration.isPressed ? 0.95 : 1)
  }
}

Which we can use as following:

Button("Rounded rectangle + shadow button style") {
  // button tapped
  ...  
}
.buttonStyle(RoundedRectangleWithShadowedLabelButtonStyle())

A kind reminder that this is a technical article, not a design article.

PrimitiveButtonStyle

While ButtonStyle was all about customizing the appearance and keeping the standard interaction behavior, PrimitiveButtonStyle lets us customize both, meaning that it's up to us to define the button appearance and to decide when and how to trigger the button action.

The PrimitiveButtonStyle definition is nearly identical to ButtonStyle:

public protocol PrimitiveButtonStyle {
    associatedtype Body : View

    func makeBody(configuration: Self.Configuration) -> Self.Body

    typealias Configuration = PrimitiveButtonStyleConfiguration
}

The only difference stands in the makeBody(configuration:) parameter, which is now a PrimitiveButtonStyleConfiguration type:

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

This configuration once again comes with the button label as a property, however isPressed is now replaced by a trigger() function:
invoking trigger() is how we call the button action, and it's now up to us to define the right time to do so.

For example, if we would like a button to trigger only when double tapped, we could define the following style:

struct DoubleTapOnlyStyle: PrimitiveButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    configuration.label
      .onTapGesture(count: 2, perform: configuration.trigger)
  }
}

Which we can then use like any other style:

Button("Double tap me") {
  // button double tapped
  ...  
}
.buttonStyle(DoubleTapOnlyStyle())

Applying and composing multiple (primitive) styles

Unlike for ButtonStyleConfiguration, Button does have an initializer accepting a PrimitiveButtonStyleConfiguration instance, allowing us to compose/apply multiple (primitive) styles to the same button.

For example, consider the following styles:

// The button action triggers on double taps.
struct DoubleTapStyle: PrimitiveButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    Button(configuration) // <- Button instead of configuration.label
      .onTapGesture(count: 2, perform: configuration.trigger)
  }
}

// The button action triggers on swipes.
// (even when terminated outside the button)
struct SwipeButtonStyle: PrimitiveButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    Button(configuration)
      .gesture(
        DragGesture()
          .onEnded { _ in
            configuration.trigger()
          }
      )
  }
}

As each style returns a button, they can be combined and work together no problem:

Button(
  "Double tap or swipe", 
  action: { 
    // handle action here
    ...
  }
)
.buttonStyle(DoubleTapStyle())
.buttonStyle(SwipeButtonStyle())

There's a small side-effect of this approach:
Button(configuration) comes with the default button interaction and style, thankfully we can remove both of these by defining yet another "plain" style.

struct PlainNoTapStyle: PrimitiveButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    Button(configuration)
      .buttonStyle(PlainButtonStyle()) // removes any default appearance
      .allowsHitTesting(false)         // no more triggers on taps
      .contentShape(Rectangle())       // let other interactions work
  }
}

If we now add this style to our button definition, we will truly make it work with just double taps and swipes:

Button(
  "Double tap or swipe", 
  action: {
    // handle action here
    ...
  }
)
.buttonStyle(DoubleTapStyle())
.buttonStyle(SwipeButtonStyle())
.buttonStyle(PlainNoTapStyle())

However we'd probably want to keep the single tap default interaction enabled to most buttons.

Using PrimitiveButtonStyle and ButtonStyle

We've covered how each ButtonStyle can be considered a complete override of previous styles, while PrimitiveButtonStyle allows us to compose multiple styles (when defined correctly), what about combining the two?

We can apply and have active both a ButtonStyle and (multiple) PrimitiveButtonStyle at the same time, for example:

Button(
  "Primitive + button style", 
  action: { 
    // handle action here
    ...
  }
)
// 👇🏻 triggers the button even when dragging our finger out of the button
.buttonStyle(SwipeButtonStyle()) 
.buttonStyle(RoundedRectangleButtonStyle())

In these situations it's important to have the ButtonStyle (RoundedRectangleButtonStyle above) declared last, as otherwise it would erase the PrimitiveButtonStyle as well.

Note that our ButtonStyle will only receive the isPressed event on the standard tap gesture, it won't know when the button action is triggered thanks to any other PrimitiveButtonStyle: it's our duty to define any visual clue on those styles when/if needed.

Conclusions

Buttons are the SwiftUI component with the simplest interaction: tap to trigger them.

In this article we've seen how we can turn any button into much more advanced elements with completely different appearances and gestures:
we won't need to go beyond a custom ButtonStyle most of the time, but it's always good to know that more powerful tools are there when needed.

Have you ever extended a button interaction? What other uses/needs do you see for such styles? Please let me know!

Thank you for reading and stay tuned for more articles.

⭑⭑⭑⭑⭑

Related articles

More SwiftUI articles

Browse all