loading...
Cover image for Build your own button component library in SwiftUI from scratch

Build your own button component library in SwiftUI from scratch

calin_crist profile image Calin-Cristian Ciubotariu Updated on ・12 min read


Introduction

Buttons are the UI components that people use to interact with your app. It's important to make them look appropriate to the action they trigger, to be consistent and accessible across the app and to give visual feedback to users.

In the previous blog post I talked about what are view modifiers and how can we use them to create stylish UI in our SwiftUI apps.

Here I will present how to apply them to create reusable styles for buttons. It's going to be a long and enjoyable ride.

buckle up image



SwiftUI buttons

Creating a button in SwiftUI is pretty simple. It requires an action and the actual content displayed and tappable.

Button(action: {
  //    action on tap - e.g. update a @State variable
}) {
  // content - e.g. Text or Image
}

Of course, to style this Button view you have to add view modifiers. You have 2 options:

  1. apply the modifiers to (each of) the views inside the content
  2. apply the modifiers to the button view - that will apply the modifiers to all the views inside the content
//  1.
Button(action: { }) {
  Text("Tap me!")
      .padding()
      .font(.title)
      .background(Color.green)
      .foregroundColor(.white)
}

/*=======================================================================*/

//  2.
Button(action: { }) {
  Text("Tap me!")
}
.padding()
.font(.title)
.background(Color.green)
.foregroundColor(.white)

Both will have the same result:

button with "Tap me" caption


However, the difference however can be seen whenever you have multiple views inside the content:

//  1.
Button(action: { }) {
  Image(systemName: "square.and.arrow.down")
  Text("Tap me!")
  .padding()
  .font(.title)
  .background(Color.green)
  .foregroundColor(.white)
}

/*=======================================================================*/

//  2.
Button(action: { }) {
  Image(systemName: "square.and.arrow.down")
  Text("Tap me!")
}
.padding()
.font(.title)
.background(Color.green)
.foregroundColor(.white)

As you can see, in the first button the Image view is left out — but it's still tappable.

comparison between buttons with different styles



Something to keep in mind:

  • like I noted in my previous blog post about view modifiers, usually the order matters
  • padding should be (again, usually) put before anything regarding the background or border of the button. Let the button breathe, give it some space. We wouldn't want something like:

comparison between buttons with different styles

  • the modifiers regarding fonts and colors are applied here to the Image view because it's using the SF Symbol icon (the Apple's equivalent of FontAwesome)



ButtonStyle modifier

There are 2 types of view modifiers:

  1. Modifiers bundled with the View protocol, available to any view:

E.g. padding or background , that you can be apply to any View.

  1. Modifiers specific to a type, available only to instances of that type:

These are used to take advantage of specific traits of that View. And buttons are a perfect example for this. For example we want to change the look&feel whenever the user taps on the button.

We have buttonStyle view modifier that accepts a struct that implements the ButtonStyle protocol.

By default, we have 3 pre-defined styles.

/// The default button style.
Button(action: { }) {
  Image(systemName: "square.and.arrow.down")
  Text("Tap me!")
}
.buttonStyle(DefaultButtonStyle())

/// The style may apply a visual effect to indicate the pressed, focused,
/// or enabled state of the button.
Button(action: { }) {
  Image(systemName: "square.and.arrow.down")
  Text("Tap me!")
}
.buttonStyle(PlainButtonStyle())


/// A standard `Button` style that does not apply a border to its content.
Button(action: { }) {
  Image(systemName: "square.and.arrow.down")
  Text("Tap me!")
}
.buttonStyle(BorderlessButtonStyle())

3 buttons with 3 pre-defined styles

But what if we would want to customise further our buttons? Like change the opacity when the button is pressed?

That's where the ButtonStyle protocol comes in.



Build your own ButtonStyle modifier

As I was stating before, the view modifier specific to buttons gives us access to specific traits. This is done through the Configuration(which is in fact a typealias Configuration = ButtonStyleConfiguration).

What we can access are:

  • label - the button content as a whole view
  • isPressed - a Bool var that becomes true whenever the button is pressed

Let's do a more interesting example: when the button is pressed, scale it down and decrease the opacity to give it a highlight effect. And as a bonus, animate these changes.

//  CustomButtonStyle
import SwiftUI

struct CustomButtonStyle: ButtonStyle {
    func makeBody(configuration: Self.Configuration) -> some View {
        return configuration.label
            .padding()
            .background(Color.green)
            .foregroundColor(Color.white)
            .opacity(configuration.isPressed ? 0.7 : 1)
            .scaleEffect(configuration.isPressed ? 0.8 : 1)
            .animation(.easeInOut(duration: 0.2))
    }
}


//  USAGE:
Button(action: { }) {
  Text("This is a custom button")
}
.buttonStyle(CustomButtonStyle())

And the result:

button with custom style and animation



React Native UI libraries

Short backstory

In 2019 I started a "sabbatical" year and a half from native iOS development. I started to develop mobile apps using React Native as my main job.

I learned a lot, but what amazed me was the power to quickly prototype apps with existing UI libraries. Where I came from (the native development world) that wasn't even a thought.

What I used back then was Callstack's React Native Paper (material design) and then NativeBase.

Import, use and still customise was mind blowing to me. I started to understand the power and noise around React Native.

mind blown gif


NativeBase as an example

NativeBase library is made from pre-build components that help every developer to build stuff faster and consistent across all the screens. And buttons are not an exception to this.

They offer a long list of props — inputs for the Button component that tell it how to look or to behave - like outlined, transparent, bordered, rounded, large or small.

No more @IBOutlets, no more subclassing, no more "CustomButton" that ate another UIButton :)

I'm aware that in React Native you end up doing the same thing when implementing your custom styles, but it's nowhere near as easy using UIKit.

Fast-forward to today, I'm happy that I rediscovered my love to develop beautiful things using SwiftUI.

Let's see how can we develop a themed UI library for our buttons similar to the ones from NativeBase.

nativebase buttons



Create a UI library

Below I summarise how are the buttons described based on the NativeBase examples.

  • Types: light, primary, success, info, warning, danger, dark
  • Styles:
    • default (color fill),
    • transparent,
    • outline
    • rounded (color fill)
    • full width
  • States: enabled(by default), disabled
  • Sizes: small, default, large (font sizes)


What I wanted to achieve is to be as close as possible as declaring:

<Button rounded success>
  <Text>Success</Text>
</Button>

So it would be something like this with all the variables in place:

Button(action: { }) {
  Text(primaryButtonText)
}
.buttonStyle(
  NativeBaseLikeButtonStyle(
    .rounded(type: .primary),
    size: .small,
    disabled: false,
    isFullWidth: true,
  )
)


Now, looking at these requirements and the native base examples, we can see there are common traits that describe this UI:

  • foreground color - the text color
  • background color
  • border color - for outlined buttons
  • border radius - for both default and rounded buttons
  • border width



Button style configuration

So let's create a protocol called ButtonStyleConfig.

protocol ButtonStyleConfig {
    var foregroundColor: Color? { get }
    var backgroundColor: Color? { get }
    var borderColor: Color { get }
    var borderWidth: CGFloat { get }
    var cornerRadius: CGFloat { get }
}

extension ButtonStyleConfig {
    var borderColor: Color {
        Color.clear
    }

    var borderWidth: CGFloat {
        0
    }

    var cornerRadius: CGFloat {
        6
    }
}


Looking on the designs, we can see that most of them buttons have no border and the same corner radius. As an arbitrary value, I chose 6 for corner radius.

Another thing that we can notice is that no matter what type of buttons there are, all of them have 2 colors: a primary and a secondary one. For example, for the default success button we have the primary color green and the secondary color white. In fact most of them have the secondary color white, so let's make that the default value.

protocol AccentColoured {
    static var primaryColor: Color?  { get }
    static var secondaryColor: Color? { get }
}

extension AccentColoured {
    static var secondaryColor: Color? {
        Color.white
    }
}


Now, let's create specific structs that describe each color for each style:

struct PrimaryStyleConfig: AccentColoured {
    static var primaryColor: Color? {
        Color.blue
    }
}

struct SuccessStyleConfig: AccentColoured {
    static var primaryColor: Color? {
        Color.green
    }
}

struct InfoStyleConfig: AccentColoured {
    static var primaryColor: Color? {
        return Color.blue.opacity(0.6)
    }
}

struct LightStyleConfig: AccentColoured {
    static var primaryColor: Color? {
        return Color.gray.opacity(0.2)
    }
    static var secondaryColor: Color? {
        Color.blue
    }
}

struct WarningStyleConfig: AccentColoured {
    static var primaryColor: Color? {
        return Color.orange
    }
}

struct DangerStyleConfig: AccentColoured {
    static var primaryColor: Color? {
        return Color.red
    }
}

struct DarkStyleConfig: AccentColoured {
    static var primaryColor: Color? {
        return Color.black
    }
}

Of course, there are a lot of ways of doing this instead of using structs, like plist or JSON files.

Let's make the styles easier to use and create an enum for them:

enum ButtonStyles {
    case primary, light, success, info, warning, danger, dark

    var secondaryColor: Color? {
        switch self {
        case .light:
            return LightStyleConfig.secondaryColor

        case .primary:
            return PrimaryStyleConfig.secondaryColor

        default:
            return Color.white
        }
    }

    var primaryColor: Color? {
        switch self {
        case .primary:
            return PrimaryStyleConfig.primaryColor

        case .light:
            return LightStyleConfig.primaryColor

        case .success:
            return SuccessStyleConfig.primaryColor

        case .info:
            return InfoStyleConfig.primaryColor

        case .warning:
            return WarningStyleConfig.primaryColor

        case .danger:
            return DangerStyleConfig.primaryColor

        case .dark:
            return DarkStyleConfig.primaryColor
        }
    }
}



Display styles

Now let's use an enum to describe the display styles: default, transparent, outline and rounded and to specify the button style configurations by implementing the ButtonStyleConfig protocol.

As you can see, having the type returning the primary and secondary colors helps us a lot when switching these for the transparent/outline styles.

enum DisplayStyle: ButtonStyleConfig {

    case `default`(type: ButtonStyles = .primary)
    case transparent(type: ButtonStyles = .primary)
    case outline(type: ButtonStyles = .primary)
    case rounded(type: ButtonStyles = .primary)

    var foregroundColor: Color? {
        switch self {
        case .default(let type):
            return type.secondaryColor

        case .transparent(let type):
            return type.primaryColor

        case .outline(let type):
            return type.primaryColor

        case .rounded(let type):
            return type.secondaryColor
        }
    }

    var backgroundColor: Color? {
        switch self {
        case .default(let type):
            return type.primaryColor

        case .transparent(let type):
            return type.secondaryColor

        case .outline(let type):
            return type.secondaryColor

        case .rounded(let type):
            return type.primaryColor
        }
    }

    var borderColor: Color {
        if case .outline(let type) = self {
            return type.primaryColor ?? Color.clear
        }

        return Color.clear
    }

    var cornerRadius: CGFloat {
        if case .rounded(_) = self {
            return 40
        }

        return 6
    }
}



Is there something we missed? Oh, yes, the size of the button, that is in fact the font size. So we can create a simple enum:

enum Size {
    case `default`, small, large

    func getFont() -> Font {
        switch self {
        case .small:
            return Font.caption
        case .large:
            return Font.title
        default:
            return Font.body
        }
    }
}



With all these components, we can start creating our custom button styles.

gif



Theme the buttons

Let's use the CustomButtonStyle we created earlier and add a custom init. Like I described in the previous blog post we can pass parameters to our custom view modifier.

struct CustomButtonStyle: ButtonStyle {

  private var display: DisplayStyle
  private var font: Font

  init(_ display: DisplayStyle = .default(type: .primary),
         size: Size = .default) {

        self.display = display
        font = size.getFont()
    }

    func makeBody(configuration: Self.Configuration) -> some View {
        return configuration.label
            .padding()
            .font(font)
            .background(display.backgroundColor)            //  <---
            .foregroundColor(display.foregroundColor)   //  <---
            .cornerRadius(display.cornerRadius)             //  <---
            .opacity(configuration.isPressed ? 0.7 : 1)
            .shadow(color: display.backgroundColor!.opacity(0.2),
                radius: display.cornerRadius,
                x: 0,
                y: 5)
    }
}

As a subtle touch, I added a shadow component to give the buttons a little depth.



Let's see it in action:

  • this is how it looks by default: .buttonStyle(CustomButtonStyle())

default primary style

  • CustomButtonStyle(.rounded(type: .success))

rounded success button style

  • CustomButtonStyle(.transparent(type: .success)

transparent success button style

  • CustomButtonStyle(.outline(type: .success))

failed outlined success button style

Wait, what? Shouldn't it be outlined? But where's the border? Let's fix it!


Borders

Let's fix it! We can add the border by adding a RoundedRectangle as an overlay view:

func makeBody(configuration: Self.Configuration) -> some View {
        return configuration.label
            .padding()
            .font(font)
            .background(display.backgroundColor)            
            .foregroundColor(display.foregroundColor)   
            .cornerRadius(display.cornerRadius)             
            .opacity(configuration.isPressed ? 0.7 : 1)
            .shadow(color: display.backgroundColor!.opacity(0.2),
                radius: display.cornerRadius,
                x: 0,
                y: 5)
            .overlay(
              RoundedRectangle(cornerRadius: display.cornerRadius)  //  <---
              .stroke(display.borderColor, lineWidth: 1)
            )
}

The outlined style it's fixed!

fixed outlined success button style

What we achieved until now

Let's see what we can do until now:

buttons with basic animations

Full width

Right now, our buttons take the minimum width needed. But what if we want to use a submit button for our form and want it to take the full width available?

How:

To make a view take the full width available, we can use the .frame view modifier like this:

.frame(maxWidth: .infinity).

Where:

In the previous blog post I emphasised that the order of modifiers matters.

What we want to achieve is not only a full width. We want make the corners, backgrounds, shadows and overlays impact the entire view and keep the proportions.

To do that we need to specify the frame before the background modifier.

struct CustomButtonStyle: ButtonStyle {
  //    ...
  func makeBody(configuration: Self.Configuration) -> some View {
        return configuration.label
            .frame(maxWidth: .infinity) //  <---
            .padding()
            .font(font)
            .background(display.backgroundColor)
            .foregroundColor(display.foregroundColor)
            .cornerRadius(display.cornerRadius)
            .opacity(configuration.isPressed ? 0.7 : 1)
            .shadow(color: display.backgroundColor!.opacity(0.2),
                    radius: display.cornerRadius,
                    x: 0,
                    y: 5)
            .overlay(
              RoundedRectangle(cornerRadius: display.cornerRadius)
              .stroke(display.borderColor, lineWidth: 1)
            )
  }
}

And the result is this:

full width buttons

Now, this is not something I always want. I would like to specify that through a boolean, something like: CustomButtonStyle(.default(type: .dark), isFullWidth: true) .

So, specify the flag in the init method and apply the frame modifier only if it's true in the makeBody method.

struct CustomButtonStyle: ButtonStyle {
  //    ...
  private var isFullWidth: Bool //  <---

  init(_ display: DisplayStyle = .default(type: .primary),
         size: Size = .default,
         isFullWidth: Bool = false) {

        self.isFullWidth = isFullWidth  //  <---
        // ...
  }
}

If we're here, let's create a small custom modifier that's making a view to have full width.

struct FullWidthModifier: ViewModifier {
    func body(content: Content) -> some View {
        content
            .frame(maxWidth: .infinity)
    }
}

Unfortunately at this moment, there isn't a method to conditionally apply a modifier. But we can create it!

Googling about this issue, we find this excellent article on view modifiers. There we can find a very useful extension that applies a specified modifier only if the condition is true.

extension View {
    // If condition is met, apply modifier, otherwise, leave the view untouched
    public func applyModifier<T>(if condition: Bool, _ modifier: T) -> some View where T: ViewModifier {
        Group {
            if condition {
                self.modifier(modifier)
            } else {
                self
            }
        }
    }
}

Now we have all the building blocks we need:

func makeBody(configuration: Self.Configuration) -> some View {
        return configuration.label
            .applyModifier(if: isFullWidth, FullWidthModifier()) // <---
            .padding()
            .font(font)
            .background(display.backgroundColor)
            .foregroundColor(display.foregroundColor)
                    // ...
}

As an example of usage, let's see it applied to the same button:

//  Dark themed button
Button(action: {}) {
  Text("Dark")
}
.buttonStyle(CustomButtonStyle(.default(type: .dark), isFullWidth: false))

//  Dark themed button full width
Button(action: {}) {
  Text("Dark")
}
.buttonStyle(CustomButtonStyle(.default(type: .dark), isFullWidth: true))

default and block dark buttons

Crazy animations

As you can see, the only way we indicate the buttons are pressed is through a highlight effect: .opacity(configuration.isPressed ? 0.7 : 1).

Let's add back the scaleEffect modifier, but this time change the animation effect to make it bounce. And to make it even crazier let's change the radius when it's pressed:

func makeBody(configuration: Self.Configuration) -> some View {
        return configuration.label
            .padding()
            .font(font)
            .background(display.backgroundColor)
            .foregroundColor(display.foregroundColor)
            .cornerRadius(display.cornerRadius)
            .opacity(configuration.isPressed ? 0.7 : 1)
            .shadow(color: display.backgroundColor!.opacity(0.2),
                    radius: display.cornerRadius,
                    x: 0,
                    y: 5)
            .overlay(
                RoundedRectangle(cornerRadius:display.cornerRadius)
                    .stroke(display.borderColor, lineWidth: 1)
            )
            .scaleEffect(configuration.isPressed ? 0.8 : 1)
            .animation(
              Animation.spring(response: 0.8, 
                               dampingFraction: 0.1, 
                               blendDuration: 10)
            )
    }

Voilà!

buttons with animations

Do we really need that in a real-life scenario? Most probably not but we need to animate our lives sometimes :)

Conclusion

Congratulations! You made it this far and the reward is your own button component library that's easy to extend and customise. For example, what you can do is to enhance the button component to support loading states, displaying a spinner, making the button untappable.

Useful links



Follow me

If you enjoy what I write, please follow my activity wherever you prefer: Dev.to, Medium or on my very own gatsby powered blog calincrist.com.

Follow me on Twitter if you want to have a chat or simply see what I'm up to. I try to post something there every day.

Posted on by:

calin_crist profile

Calin-Cristian Ciubotariu

@calin_crist

I am a software developer passionate about mobile technologies. iOS/React Native. When I'm not coding, I like cycling, craft beers, whiskey and going to rock concerts with my wife.

Discussion

markdown guide