Replicating SwiftUI styles for custom components
The techniques in this article is verified to work for iOS 13 and above
Many default components in SwiftUI come with a styling option in form of a modifier. In this article we will explore how we can replicate this for our own components. If you are wondering what view styles are, or which default components support them, I highly recommend reading Apples documentation on the matter.
The benefit of using styles becomes evident when we want to conditionally change the appearance of our components.
Without style modifier
@State var shouldShowProminentButtons = false
VStack {
if shouldShowProminentButtons {
ProminentButton("Prominent button") {}
ProminentButton("Prominent button") {}
} else {
Button("Plain button") {}
Button("Plain button") {}
}
}
We have to duplicate our code to switch style
With style modifier
@State var shouldShowProminentButtons = false
VStack {
Button("Plain or prominent button") {}
Button("Plain or prominent button") {}
}
.buttonStyle(shouldShowProminentButtons ? .borderedProminent : .plain)
Cleaner code, and we don’t have to duplicate our code for each style supported
The comparison above uses internal components, with our own implementation we want to extend our options by having our styles switchable, and have our context exposed to other components.
How styles work
Styles are defined as modifiers on View, and not the component itself. This is because it actually does not alter the component directly, but rather changes the environment value for the associated style, and this is where context comes into play. Environment values are cascading, and therefor not changed until explicitly defined on a child node.
VStack {
Button("Plain button") {} // Plain is default style
Button("Prominent button") {}
.buttonStyle(.borderedProminent) // Overrides style
VStack {
Button("Plain Button") {} // Not overriden
VStack {
// Parent VStack overrides environment in this context
Button("Prominent Button") {}
}
.buttonStyle(.borderedProminent)
}
}
Replicating style modifiers
Let’s tackle this by creating a Card component that acts as a wrapper for any content. The component itself won’t have any modifications to it, but that’s not the point. The point is to have a uniform way of creating different kinds of cards.
struct Card<Content: View>: View {
var content: () -> Content
init(content: @escaping () -> Content) {
self.content = content
}
var body: some View {
content()
}
}
Next we need a configuration that helps us define the requirements and possibilities for alteration a style can apply to the basic card style. Note that nothing in the configuration is necessarily required, but omitting the use of the content parameter for instance would make the style useless as it doesn’t implement a key feature.
We also need a protocol for how a custom style should return the new view, and expose the configuration.
struct CardStyleConfiguration {
struct Content: View {
init<Content: View>(content: Content) {
body = AnyView(content)
}
var body: AnyView
}
let content: CardStyleConfiguration.Content
}
protocol CardStyle {
associatedtype Body : View
typealias Configuration = CardStyleConfiguration
func makeBody(configuration: Self.Configuration) -> Self.Body
}
Now we can create some styles:
struct BasicCardStyle: CardStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.content
.padding()
.background(RoundedRectangle(cornerRadius: 8.0).fill(.white))
.background(RoundedRectangle(cornerRadius: 8.0).stroke(.black))
}
}
struct ElevatedCardStyle: CardStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.content
.padding()
.background(
RoundedRectangle(cornerRadius: 8.0).fill(.white)
.shadow(radius: 5, x: 3, y: 3)
)
}
}
Now we could actually use these styles, but our Card component could only use one of them, as they are not interchangeable. For this we need a stateful parameter that holds what kind of style we want to use. Obviously this could be done with a @Binding, but then we would have no context. Now we should set up the environment, so that we can set a context.
To simplify access to our styles we first create an enum defining each style.
enum CardStyles {
case basic, elevated
func get() -> any CardStyle {
switch self {
case .basic: return BasicCardStyle()
case .elevated: return ElevatedCardStyle()
}
}
}
/// Defines the KeyPath \.cardStyle on environment
extension EnvironmentValues {
var cardStyle: CardStyles {
get { self[CardStyleKey.self] }
set { self[CardStyleKey.self] = newValue }
}
}
/// Sets the default value for cardStyle in the environment
struct CardStyleKey: EnvironmentKey {
static var defaultValue = CardStyles.basic
}
Now let’s revisit our Card component so that it actually uses the style defined by the environment, and let us also create a modifier that allows us to change the environment value. Note that we return an AnyView, this is because each style does not hold the same view, but can hold any view.
struct Card<Content: View>: View {
@Environment(\.cardStyle) var style
var content: () -> Content
init(content: @escaping () -> Content) {
self.content = content
}
var body: some View {
AnyView (
style.get().makeBody(
configuration: CardStyleConfiguration(
content: CardStyleConfiguration.Content(content: content())
)
)
)
}
}
extension View {
func cardStyle(_ style: CardStyles) -> some View {
environment(\.cardStyle, style)
}
}
Now we can use it like this:
Card {
// Content here
}
.cardStyle(.basic)
To further utilize the context we now have enforced, we can create another component that changes its appearance based on the card style.
Do note that native styles are internal and therefore not exposed to the environment, so this can only be done with custom styles.
struct CardTitle: View {
@Environment(\.cardStyle) private var cardStyle
let title: String
init(_ title: String) {
self.title = title
}
var body: some View {
switch cardStyle {
case .basic:
Text(title)
.font(.title)
.bold()
case .elevated:
Text(title)
.font(.title2)
.fontWeight(.heavy)
.foregroundStyle(.cyan)
}
}
}
And the result when CardTitle is used inside our Card with different styles:
VStack(spacing: 30) {
Card {
VStack(alignment: .leading) {
CardTitle("Basic card")
Text("Lorem...")
}
}
Card {
VStack(alignment: .leading) {
CardTitle("Elevated card")
Text("Lorem...")
}
}
.cardStyle(.elevated)
}
Conclusion
With this technique we have a uniform way of altering our content with a single modifier that can be explicitly set for one component, or at a higher level. This replicates a pattern that is already well established within SwiftUI and therefore makes it easy for other maintainers of our code to use.
Styles can be conditionally set, not only for theming purposes, but can also be used to set specific styles based on user preferences in the operating system, such as accessibility settings.