SwiftUI: Reusable UI with Custom Modifiers
Nemi Shah
Posted on December 8, 2022
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 it here
The goal with 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 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 personally Im 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.
Lets break down the common styling we need:
- Screen container: The screen itself has a 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
Lets 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 adding 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 simple 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 state. 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 lets 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 thats 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. Ive 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, in fact the repo is empty :p but stay tuned).
PS: In my free time I also freelance as a mobile dev, if you are looking to hire feel free to reach out!
Posted on December 8, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.