One of the most interesting aspects of SwiftUI, at least from an architectural perspective, is how it essentially treats views as data. After all, a SwiftUI view isn’t a direct representation of the pixels that are being rendered on the screen, but rather a description of how a given piece of UI should work, look, and behave.
That very data-driven approach gives us a ton of flexibility when it comes to how we structure our view code — to the point where one might even start to question what the difference actually is between defining a piece of UI as a view type, versus implementing that same code as a modifier instead.
Take the following FeaturedLabel
view as an example — it adds a leading star image to a given text, and also applies a specific foreground color and font to make that text stand out as being “featured”:
struct FeaturedLabel: View { var text: String var body: some View { HStack { Image(systemName: "star") Text(text) } .foregroundColor(.orange) .font(.headline) }}
While the above may look like a typical custom view, the exact same rendered UI could just as easily be achieved using a “modifier-like” View
protocol extension instead — like this:
extension View { func featured() -> some View { HStack { Image(systemName: "star") self } .foregroundColor(.orange) .font(.headline) }}
Here’s what those two different solutions look like side-by-side when placed within an example ContentView
:
struct ContentView: View { var body: some View { VStack { FeaturedLabel(text: "Hello, world!") Text("Hello, world!").featured() } }}
One key difference between our two solutions, though, is that the latter can be applied to any view, while the former only enables us to create String
-based featured labels. That’s something that we could address, though, by turning our FeaturedLabel
into a custom container view that accepts any kind of View
-conforming content, rather than just a plain string:
struct FeaturedLabel<Content: View>: View { @ViewBuilder var content: () -> Content var body: some View { HStack { Image(systemName: "star") content() } .foregroundColor(.orange) .font(.headline) }}
Above we’re adding the ViewBuilder
attribute to our content
closure in order to enable the full power of SwiftUI’s view building API to be used at each call site (which, for example, lets us use if
and switch
statements when building the content for each FeaturedLabel
).
We might still want to make it easy to initialize a FeaturedLabel
instance with a string, though, rather than always having to pass a closure containing a Text
view. Thankfully, that’s something that we can easily make possible using a type-constrained extension:
extension FeaturedLabel where Content == Text { init(_ text: String) { self.init { Text(text) } }}
Above we’re using an underscore to remove the external parameter label for text
, to mimic the way SwiftUI’s own, built-in convenience APIs work for types like Button
and NavigationLink
.
With those changes in place, both of our two solutions now have the exact same amount of flexibility, and can easily be used to create both text-based labels, as well as labels that render any kind of SwiftUI view that we want:
struct ContentView: View { @State private var isToggleOn = false var body: some View { VStack { Group { FeaturedLabel("Hello, world!") Text("Hello, world!").featured() } Group { FeaturedLabel { Toggle("Toggle", isOn: $isToggleOn) } Toggle("Toggle", isOn: $isToggleOn).featured() } } }}
So at this point, we might really start to ask ourselves — what exactly is the difference between defining a piece of UI as a view versus a modifier? Is there really any practical differences at all, besides code style and structure?
Well, what about state? Let’s say that we wanted to make our new featured labels automatically fade in when they first appear? That would require us to define something like a @State
-marked opacity
property that we’d then animate over using an onAppear
closure — for example like this:
struct FeaturedLabel<Content: View>: View { @ViewBuilder var content: () -> Content @State private var opacity = 0.0 var body: some View { HStack { Image(systemName: "star") content() } .foregroundColor(.orange) .font(.headline) .opacity(opacity).onAppear { withAnimation { opacity = 1 }} }}
At first, participating in the SwiftUI state management system might seem like something that only proper view types can do, but it turns out that modifiers have the exact same capability — as long as we define such a modifier as a proper ViewModifier
-conforming type, rather than just using a View
protocol extension:
struct FeaturedModifier: ViewModifier { @State private var opacity = 0.0 func body(content: Content) -> some View { HStack { Image(systemName: "star") content } .foregroundColor(.orange) .font(.headline) .opacity(opacity).onAppear { withAnimation { opacity = 1 }} }}
With the above in place, we can now replace our previous featured
method implementation with a call to add our new FeaturedModifier
to the current view — and both of our two featured label approaches will once again have the exact same end result:
extension View { func featured() -> some View { modifier(FeaturedModifier()) }}
Also worth noting is that when wrapping our code within a ViewModifier
type, that code is lazily evaluated when needed, rather than being executed up-front when the modifier is first added, which could make a difference performance-wise in certain situations.
So, regardless of whether we want to change the styles or structure of a view, or introduce a new piece of state, it’s starting to become clear that SwiftUI views and modifiers have the exact same capabilities. But that brings us to the next question — if there are no practical differences between the two approaches, how do we choose between them?
At least to me, it all comes down to the structure of the resulting view hierarchy. Although we were, technically, changing the view hierarchy when wrapping one of our featured labels within an HStack
in order to add our star image, conceptually, that was more about styling than it was about structure. When applying the featured
modifier to a view, its layout or placement within the view hierarchy didn’t really change in any meaningful way — it still just remained a single view with the exact same kind of overall layout, at least from a high-level perspective.
That’s not always the case, though. So let’s take a look at another example which should illustrate the potential structural differences between views and modifiers a bit more clearly.
Here we’ve written a SplitView
container, which takes two views — one leading, and one trailing — and then renders them side-by-side with a divider between them, while also maximizing their frames so that they’ll end up splitting the available space equally:
struct SplitView<Leading: View, Trailing: View>: View { @ViewBuilder var leading: () -> Leading @ViewBuilder var trailing: () -> Trailing var body: some View { HStack { prepareSubview(leading()) Divider() prepareSubview(trailing()) } } private func prepareSubview(_ view: some View) -> some View { view.frame(maxWidth: .infinity, maxHeight: .infinity) }}
Just like before, we could definitely achieve the exact same result using a modifier-based approach instead — which could look like this:
extension View { func split(with trailingView: some View) -> some View { HStack { maximize() Divider() trailingView.maximize() } } func maximize() -> some View { frame(maxWidth: .infinity, maxHeight: .infinity) }}
However, if we once again put our two solutions next to each other within the same example ContentView
, then we can start to see that this time the two approaches do look quite different in terms of structure and clarity:
struct ContentView: View { var body: some View { VStack { SplitView(leading: { Text("Leading") }, trailing: { Text("Trailing") }) Text("Leading").split(with: Text("Trailing")) } }}
Looking at the view-based call site above, it’s very clear that our two texts are being wrapped within a container, and it’s also easy to tell which of those two texts will end up being the leading versus trailing view.
That same thing can’t really be said for the modifier-based version this time, though, which really requires us to know that the view that we’re applying the modifier to will end up in the leading slot. Plus, we can’t really tell that those two texts will end up being wrapped in some kind of container at all. It looks more like we’re styling the leading label using the trailing label somehow, which really isn’t the case.
While we could attempt to solve that clarity problem with more verbose API naming, the core issue would still remain — that the modifier-based version doesn’t properly show what the resulting view hierarchy will be in this case. So, in situations like the one above, when we’re wrapping multiple siblings within a parent container, opting for a view-based solution will often give us a much clearer end result.
On the flip side, if all that we’re doing is applying a set of styles to a single view, then implementing that as either a “modifier-like” extension, or using a proper ViewModifier
type, will most often be the way to go. And for everything in between — such as our earlier “featured label” example — it all really comes down to code style and personal preference as to which solution will be the best fit for each given project.
Just look at how SwiftUI’s built-in API was designed — containers (such as HStack
and VStack
) are views, while styling APIs (such as padding
and foregroundColor
) are implemented as modifiers. So, if we follow that same approach as much as possible within our own projects, then we’ll likely end up with UI code that feels consistent and inline with SwiftUI itself.
I hope that you found this article interesting and useful. Feel free to find me on Mastodon, or contact me via email, if you have any questions, comments, or feedback.
Thanks for reading!
إرسال تعليق