Intro to SwiftUI — Part 2: The basics of views
In the previous post, we learned about the new language features in Swift 5.1, such as opaque result types and function builders, which power the new declarative syntax of SwiftUI.
Now that we understand the basic syntax, it’s time to start exploring the basics of SwiftUI itself.
This post is all about understanding views — what views are, how views are laid out and how these views are declared and modified in SwiftUI. This will help us develop an understanding of the basic building blocks of any piece of user interface in SwiftUI, which is important before we move on to actually building an app and applying more advanced features to it.
So, let’s get started!
View
If you’ve worked with UIKit (or AppKit) before, you must already be familiar with what a view is — a view simply defines a piece of user interface. For example: buttons, labels and containers (e.g. a list) are views.
We build these views by composing other views into some sort of hierarchy where one view is contained in another view and so on.
In frameworks like UIKit and AppKit, a view is a concrete class, such as a UIView
or NSView
. However, in SwiftUI things are a bit different. In SwiftUI, a View
is simply a protocol, with a single requirement — a body
property that is also a View
, which defines the user interface.
Primitive views in SwiftUI, such as a Button
, Text
and VStack
all conform to this protocol and you can build your own custom views by doing so as well.
However, these views are a bit different compared to other views you might have worked with:
- A SwiftUI view is basically a sort of “virtual” view — a description of what to render and letting SwiftUI dictate how to do that (perhaps by using an underlying
UIView
/NSView
or by using CoreGraphics directly). - A SwiftUI view can be defined using a value type, such as a struct. In fact, all primitive views like
List
andSpacer
are just structs. This allows the compiler to allocate storage on the stack, which is a lot cheaper than heap allocation (especially when these types may end up in an existential container).
You can use a class if you want to, but you will have to mark it as final. This means you lose one of the main advantages of using a class (i.e. subclassing) and in that case, you’re simply better off using a struct instead.
Now, since all views have a body
property which is also a View
, it seems like we have an infinite loop:
So, how does SwiftUI know when to stop? Well, all primitive views in Swift (such as Text
or List
) don’t have any child views. This is possible because their body
property has an uninhabited type (i.e. Never
), which means it can’t be constructed.
How this is achieved is unclear— if you declare a view whose body has an uninhabited type, then inside that body you’ll have to return an uninhabited type, which is impossible. It’s likely that there is some runtime dynamism at play, similar to how DynamicViewProperty
works by using runtime metadata to reflect over views.
You can observe the dynamic behaviour by creating a view whose body only contains an
EmptyView
and placing a breakpoint on the body. You’ll notice that the breakpoint is never hit, meaning SwiftUI is not calling the getter, sinceEmptyView
's body has an uninhabited type.
If we were building views using UIKit, we would usually start by first creating a new class that inherits from a UIView
, and maybe customise it by adding some new properties, such as one that stores a list of models.
This means, we would be inheriting the properties of a UIView
as well as defining our own. As a result, we end up with a view which takes a lot of storage.
SwiftUI takes a different approach — instead of inheritance, we use composition and chain smaller, single purpose views together. This also means the storage is shared across the view hierarchy, leading to more efficient memory usage and allowing SwiftUI to optimise our code for faster performance.
SwiftUI also provides some out-of-the-box view types which can be used in different scenarios. For example:
- EquatableView: This is a
View
which compares itself with its previous value and prevents its child views from updating if there were no changes made to the view. You can easily grab anEquatableView
by callingequatable()
on any of your views. - TupleView: This is simply a tuple of
View
s and allows you to easily store and pass around a small collection of views. For example:let views: TupleView<(Text, Text)> = .init((Text(“Hello”), Text(“World”)))
. - AnyView: This is a type-erased view and is helpful when you want to return different views conditionally. This has a performance impact as SwiftUI is going to destroy the entire view hierarchy of this view if any of its child views need an update. An alternative to this would be to use a
Group
orConditonalContent
.
View Layout
Great, now that we’ve got views out of the way, let’s take a look at how the views are laid out on the screen.
SwiftUI lays out the views a bit differently than how auto-layout does. There is a simple three-step process:
- The parent view provides a size for its child
- The child view picks a size for itself
- The child view communicates its size back to its parent and the parent view places the child view in its coordinate space
For example:
- The parent view has the dimensions of the entire safe area of the device, since it is the “root” view (tip: you can get the full size of the device by applying the
.edgesIgnoringSafeArea(.all)
modifier). - The parent view then passes down this size to its child view, which is the button. The button then decides its own size (in this case, by setting its frame to 50pt x 30 pt).
An important thing to remember is that there is no way for the parent to force the child to be of a specific size — it has to respect the size of the child.
- The child view then passes its size up to its parent view, which then lays the child view in its coordinate space (in the centre by default). It also rounds the coordinates to the nearest pixel, which means there is no anti-aliasing.
Let’s take a look at another example:
Here, we have a background view applied to the button, filled with a colour. How does the layout process look now? Well, it’s similar to how it was before.
- The parent view passes down its size to the child view, which is the background view (also known as a modifier view, which we’ll learn more about later in this post).
- Since the background view is layout-neutral, it passes that size down to its child view, which is the button.
- The button decides to keep the same frame as earlier, so it passes the size up to the background view.
- The background view passes this size up to the parent view, but before it does, it also passes that size to its secondary child view, which is the colour view.
- Colours always obey whatever size given to them, so in this case, its the same size as the background (which is the same size as the button). The background then passes the size up to the parent view.
- Finally, the parent view positions the background view in its own coordinate space and centres it as before.
In case you were wondering — yes, there are no layout constraints available to us, like in UIKit. Everything is done using stacks, frames, paddings, spacers, etc. The reason why is because SwiftUI layout isn’t a constraint system.
View Builder
Now that we’ve understood what views are and how its laid out on the screen, let’s take a look at how views are declared.
In SwiftUI, a view heirarchy is declared declaratively, rather than imperatively. In other words, your code describes the user interface and SwiftUI does the heavy lifting to figure out how to draw that on the screen.
Here’s a simple example:
Here, we have a stack of two views — a label and a button, which contains also contains a label, which gives the button its title.
In this example, we’ve declared the hierarchical relationship between the these views, by assembling them into a structure that stores that information and lets SwiftUI render it on the screen.
We created this structure with the help of a container view. Container views allow for easy composition of views, for example, by letting you place one view inside of another (and so on).
How does a container view work? Well, to understand it, let’s take a look at one of many container views available in SwiftUI — a vertical stack.
A vertical stack (or a VStack
) is a container view that allows us to place multiple child views, all stacked vertically.
The way you add views to the stack is by placing them inside a closure. However, this is not an ordinary closure — this is a closure marked with a special attribute, called @ViewBuilder
.
This attribute is a function builder (see previous post for details), that “collects” all the views inside the closure and “reduces” it into a single view (you can think of it as a way of allowing us to represent a view as a function of its inputs).
It also helps us describe the hierarchal relationship between views by using braces & indentation to separate the container view, child views and their modifiers from each other.
@ViewBuilder closures only support a maximum of 10 child views, due to Swift’s lack of variadic generics. You can work around it by either moving the extra child views into their own container (and so on) or extending ViewBuilder with extra buildBlock() methods that can accept more than 10 child views.
Writing this in UIKit would’ve taken us a lot longer, having to explicitly describe each step and calling APIs like addSubview()
. This is very tedious and a small mistake could easily ruin the entire result.
By creating our views declaratively, we can focus on describing what we want to see and letting SwiftUI figure how to do it and you can be sure that you’re going to get a high quality result.
View Modifiers
The last thing to talk about is view modifiers. Just like a View
, a ViewModifer
is also a protocol, with a single requirement. But, instead of a body
property, it requires a body
method that takes in a View
, and returns another View
by “modifying” it in some way.
Here’s a simple example:
In this example, we’re using two built-in modifiers — padding and background. The padding modifier takes the padding amount as an argument and returns a new padded view. By default, it will apply the same padding on all directions, but you can customise that if you want.
The second modifier is background, which takes a view as a parameter and returns a new view with the view passed as argument as the background. In this example, we’re using the Color
view, which simply provides a view whose background is a solid colour of our choice.
We can chain these modifiers together to create the modified view and the order in which we chain enforces a deterministic order in which the views are modified.
For example, here’s what it would look like if we put the padding after the background:
This is a bit strange, isn’t it? Well actually, the padding is still on the screen, it’s just that we can’t see it.
The reason we can’t see the padding is because the background modifier is only wrapping the text, not the padding. This means our padding is being applied outside of the background.
This ordering behaviour is important, because otherwise it would be impossible to know in which order the modifiers are applied, unless we spent a lot of time reading the documentation or manually testing each combination of ordering.
As mentioned earlier, we can create our own modifiers by simply conforming to the ViewModifier
protocol. This is handy when we’re applying the same modification to a bunch of views, as we can encapsulate the behaviour into a single place and apply it to any view we like.
For example, if we had to style the look of text in the container the same, then instead of duplicating the code for each text, we could simply create a modifier that applies that look to each text:
View modifiers are a really cool way to apply simple or advanced modifications to views and chaining them to create even more complex modifications.
Modifiers can also be used to create animatable modifications, by conforming to the AnimatableModifier
protocol (which we will cover in the future).
Conclusion
That’s it folks, hope you enjoyed reading about views in SwiftUI and how it all comes together to help you design a piece of user interface quickly and easily.
In the next post, we will explore data binding and primitive views and how you can combine them together to build stateful user interfaces.