SwiftUI: Reusable UI with Custom Modifiers

SwiftUI: Reusable UI with Custom Modifiers

·

5 min read

The ability to create custom view modifiers is a powerful feature in SwiftUI, in this article we will cover examples of how this feature can be used to make building UI so much easier. If you are not familiar with ViewModifiers in SwiftUI and how to create custom ones, you can read about them here

The goal of this article is to cover some of the different ways to create custom modifiers and styles in SwiftUI and how they can be used to make building UI more declarative while still achieving a clean and consistent final output. The final UI we want to build is:

Let’s consider all the individual components on the screen:

  • Image: A standard image component with some corner radius

  • Texts: We have a title and a body text

  • Button: A full-width button

Plain SwiftUI code

If you build this screen without any modifiers, the code would look something like this:

struct ContentView: View {
    var body: some View {
        VStack (alignment: .leading) {
            Image("feature")
                .resizable()
                .aspectRatio(contentMode: .fill)
                .frame(minWidth: 0, maxWidth: .infinity)
                .frame(height: 220)
                .cornerRadius(12)
                .padding(.bottom, 12)

            Text("Custom ViewModifiers in SwiftUI are the best!")
                .foregroundColor(Color("titleTextColor"))
                .font(.system(size: 20, weight: .bold))
                .padding(.bottom, 12)

            Text("Custom ViewModifiers in SwiftUI let you create resuable styles that can be applied to all your views")
                .foregroundColor(Color("bodyTextColor"))
                .font(.system(size: 14, weight: .medium))


            Spacer()
            Button(action: {

            }) {
                Text("Label")
                    .font(.system(size: 14, weight: .medium))
            }
            .frame(minWidth: 0, maxWidth: .infinity)
            .padding(.horizontal, 10)
            .padding(.vertical, 12)
            .background(Color.blue)
            .foregroundColor(Color.white)
            .cornerRadius(12)
        }
        .padding(.all, 16)
    }
}

There are a couple of problems with this approach:

  • Styling for some of the elements (the title and details texts for example) would have to be duplicated

  • Changes to some of the common styling (element padding, corner radius etc) would have to be made in multiple places

Now you could solve this problem the UIKit way by creating custom views, but I'm not a fan of this approach because it involved moving away from the built-in Views and makes onboarding new team members more frictional. An easier way would be to define some universal view modifiers that can be applied instead of the styles themselves.

Let's break down the common styling we need:

  • Screen container: The screen itself has universal padding, this is optional but I prefer having all screens have a universal style

  • Corner radius

  • Title and body texts

  • Full width: Items need to be able to fill the width of their parent (the button and the image in the above example)

  • Button styling

  • Image scaling

Custom View Modifiers

Let's start with the corner radius:

struct CommonCornerRadius: ViewModifier {
    func body(content: Content) -> some View {
        content
            .cornerRadius(12)
    }
}

This one is rather simple, it allows us to apply a universal corner radius for elements. This makes it easier to change app styles globally without having to create custom Views or having to make multiple changes across the codebase.

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

This one makes making full-width views easier to implement, no more adding .frame manually!

struct TitleTextModifier: ViewModifier {
    func body(content: Content) -> some View {
        content
            .foregroundColor(Color("titleTextColor"))
            .font(.system(size: 20, weight: .bold))
    }
}

struct BodyTextModifier: ViewModifier {
    func body(content: Content) -> some View {
        content
            .foregroundColor(Color("bodyTextColor"))
            .font(.system(size: 14, weight: .medium))
    }
}

This will allow common text styling, normally you would either create custom Text components or utility functions and add UI components through code.

extension Image {
    func aspectFill() -> some View {
        self
            .resizable()
            .aspectRatio(contentMode: .fill)
    }
}

Alright, you got me…this isn’t a custom view modifier but a simple extension. This is because ViewModifiers apple to the generic Views and some functions such as resizable only apply to images, using a combination of extensions and custom modifiers helps get around this.

struct FullWidthButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .fullWidth()
            .foregroundColor(Color.white)
            .font(.system(size: 14, weight: .medium))
            .padding(.horizontal, 10)
            .padding(.vertical, 12)
            .background(configuration.isPressed ? Color.blue.opacity(0.2) : Color.blue)

    }
}

struct FullWidthButton: ViewModifier {
    func body(content: Content) -> some View {
        content
            .buttonStyle(FullWidthButtonStyle())
    }
}

Finally, this is for the button, note that while we could have simply created a ViewModifier to accomplish the same effect the button’s appearance would not have changed when tapped. This is because setting .background on a button forces it to use that background in both tapped and untapped states. ButtonStyle lets us change the opacity of the button based on whether or not it is pressed.

Now for convenience, I like making extensions that use these modifiers:

extension View {
    func commonCornerRadius() -> some View {
        modifier(CommonCornerRadius())
    }

    func fullWidth() -> some View {
        modifier(FullWidthModifier())
    }

    func title() -> some View {
        modifier(TitleTextModifier())
    }

    func body() -> some View {
        modifier(BodyTextModifier())
    }

    func fullWidthButton() -> some View {
        modifier(FullWidthButton())
    }
}

extension Image {
    func aspectFill() -> some View {
        self
            .resizable()
            .aspectRatio(contentMode: .fill)
    }
}

Now let's convert the code to use these instead of styling directly:

struct ContentView: View {
    var body: some View {
        VStack (alignment: .leading) {
            Image("feature")
                .aspectFill()
                .fullWidth()
                .frame(height: 220)
                .commonCornerRadius()
                .padding(.bottom, 12)

            Text("Custom ViewModifiers in SwiftUI are the best!")
                .title()
                .padding(.bottom, 12)

            Text("Custom ViewModifiers in SwiftUI let you create resuable styles that can be applied to all your views")
                .body()


            Spacer()
            Button(action: {

            }) {
                Text("Awesome")
            }
            .fullWidthButton()
            .commonCornerRadius()
        }
        .padding(.all, 16)
    }
}

Much cleaner! Now at first glance, this feels like more code and effort than simply manually setting the styles but in the long run, this will save a lot of effort. Personally, this approach also encourages your app’s style to be more consistent by relying more on common modifiers than on a view-by-view basis of styling.

And that's about it! Hopefully, this helps you build your apps quicker and easier, another benefit is that these modifiers can be dropped into any of your apps and tweaked to match its style guidelines. I've also been working on a library to take this even further, you can check it out here (PS: At the time of writing this the library is in a super early stage the repo is empty :p but stay tuned)