00:06 A while ago, we
asked people on
Twitter for examples of layouts they find easy to describe yet difficult to
implement in SwiftUI. We received many interesting challenges, and we'll look at
one of them today.
00:51 After our SwiftUI Layout
Explained series, in
which we reimplemented parts of SwiftUI's layout system, we should be able to
put our knowledge into practice by building real-world layouts.
01:08 If anyone has other tricky layouts that come to mind after watching
this episode, we'd be interested in hearing about them and seeing if we can
implement them.
Flight View
01:17 The view we're looking at today was proposed by
@Gernot, and it's a
flight view displaying a flight's origin (Berlin) and destination (San
Francisco), with an airplane icon in the middle:

01:37 The airplane icon should be horizontally aligned with the center of
the view, but if there's not enough room for the label on either side of the
icon (without truncating the text), then the layout needs to change by moving
the icon out of the center.
02:07 To illustrate this behavior, we can first implement it with Auto
Layout. We start by constraining the two labels and the icon to be centered
vertically. We align the "Berlin" label to the leading edge and the "San
Francisco" label to the trailing edge, and we make the space between the icon
and each label greater than or equal to 20 points. We also add a horizontal
constraint to center the icon within the view.
03:30 As we run the app and we make the window smaller, there comes a
point where the "San Francisco" label gets truncated, although there's still
space on the other side of the icon.

03:44 One of the requirements for this layout is that the icon should
move out of the center if moving it prevents a label from being truncated. And
if both labels are too wide to fit, then the icon can be centered again. This is
easy with Auto Layout — we just have to give the icon's center constraint a low
priority:

04:23 Now the longer label pushes the icon out of the center if it needs
the space to fit its text. But if there's even less space — less than the
combined widths of the icon and the labels — we also want the labels to truncate
their text. So we select both labels, we lower their content compression
resistance, and we set their line break mode to "Truncate Tail:"

04:59 However, we seem to have mixed up something, because we expect the
icon to move out of the center before the text gets truncated. So we have to
make the content compression resistance of the labels lower than the priority of
the icon's center constraint:

05:42 We could add more details to the layout using additional
constraints, but this is the basic setup of the view.
Using SwiftUI
06:02 Now let's build the same view in SwiftUI. As a first attempt, we
might try to use spacers, but that doesn't quite get us where we want to be:
struct AirplaneView: View {
let from = Text("Berlin")
let to = Text("San Francisco")
let airplaneIcon = Text("✈️")
var body: some View {
HStack(spacing: 0) {
from
Spacer()
airplaneIcon
Spacer()
to
}
.lineLimit(1)
}
}
06:55 The two spacers always become equally wide, meaning the icon isn't
necessarily centered in the view; it just keeps an equal distance from both
labels. What we want instead is for the labels' frames to have equal widths. So
we remove the spacers and we wrap both labels in flexible frames:
struct AirplaneView: View {
let from = Text("Berlin")
let to = Text("San Francisco")
let airplaneIcon = Text("✈️")
var body: some View {
HStack(spacing: 0) {
from
.frame(maxWidth: .infinity, alignment: .leading)
airplaneIcon
to
.frame(maxWidth: .infinity, alignment: .trailing)
}
.lineLimit(1)
}
}
07:36 The HStack
looks at the flexibility of each view it contains and
divides the proposed width over the views, respecting the fixed width of the
icon, and dividing the remaining width over the two labels:

08:10 In other words, the airplane icon gets centered implicitly because
the labels — or rather, the frames around the labels — become the same width.
Measuring Sizes
08:23 The above layout works well as long as there's enough room for
both labels to display their text without truncating. But if one of the labels
doesn't fit, we need to change the layout and draw the icon off-center.
09:05 To pick the appropriate layout, we need to measure the labels, the
airplane icon, and the proposed size, and then we need to figure out if both
labels can fit next to the centered icon.
10:10 Since we'll have to measure four different views and we don't want
to write the same code four times, we'll write a helper that encapsulates the
measuring logic. This helper adds an overlay with a geometry reader to the view
we want to measure, and it uses a preference key to send this width to the
closure we pass to the helper:
struct WidthKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
extension View {
func measureWidth(_ f: @escaping (CGFloat) -> ()) -> some View {
overlay(GeometryReader { proxy in
Color.clear.preference(key: WidthKey.self, value: proxy.size.width)
}
.onPreferenceChange(WidthKey.self, perform: f))
}
}
12:42 We use the helper to measure the width of the HStack
, and we
store this width in a state variable:
struct AirplaneView: View {
let from = Text("Berlin")
let to = Text("San Francisco")
let airplaneIcon = Text("✈️")
@State var proposedWidth: CGFloat = 0
var body: some View {
HStack(spacing: 0) {
from
.frame(maxWidth: .infinity, alignment: .leading)
airplaneIcon
to
.frame(maxWidth: .infinity, alignment: .trailing)
}
.measureWidth { self.proposedWidth = $0 }
.lineLimit(1)
}
}
13:21 We'll do the same for the labels and the icon. To measure the
ideal size of the labels — so not truncated versions — we add hidden copies of
the Text
views in a background layer. By giving these Text
views a fixed
size, they're proposed a nil
size and they become the width they want to be:
struct AirplaneView: View {
let from = Text("Berlin")
let to = Text("San Francisco")
let airplaneIcon = Text("✈️")
@State var proposedWidth: CGFloat = 0
@State var fromWidth: CGFloat = 0
@State var airplaneWidth: CGFloat = 0
@State var toWidth: CGFloat = 0
var body: some View {
HStack(spacing: 0) {
from
.frame(maxWidth: .infinity, alignment: .leading)
airplaneIcon
to
.frame(maxWidth: .infinity, alignment: .trailing)
}
.measureWidth { self.proposedWidth = $0 }
.background(HStack {
from.fixedSize().measureWidth { self.fromWidth = $0 }
airplaneIcon.fixedSize().measureWidth { self.airplaneWidth = $0 }
to.fixedSize().measureWidth { self.toWidth = $0 }
}.hidden())
.lineLimit(1)
}
}
14:47 Using the four measured widths, we can calculate if both labels
fit the available space. We subtract the icon's width from the proposed width,
and we compare half the remainder to the widths of both labels. If either label
is wider than this remaining space, we should draw the airplane icon off-center:
struct AirplaneView: View {
let from = Text("Berlin")
let to = Text("San Francisco")
let airplaneIcon = Text("✈️")
@State var proposedWidth: CGFloat = 0
@State var fromWidth: CGFloat = 0
@State var airplaneWidth: CGFloat = 0
@State var toWidth: CGFloat = 0
var shouldDrawOffCenter: Bool {
let labelWidth = (proposedWidth - airplaneWidth) / 2
return fromWidth > labelWidth || toWidth > labelWidth
}
}
15:48 If we need to draw the icon off-center, we use the variant of the
layout with two spacers. Otherwise, we use the version with flexible frames:
struct AirplaneView: View {
var body: some View {
HStack(spacing: 0) {
if shouldDrawOffCenter {
from
Spacer()
airplaneIcon
Spacer()
to
} else {
from
.frame(maxWidth: .infinity, alignment: .leading)
airplaneIcon
to
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
.measureWidth { self.proposedWidth = $0 }
.background(HStack {
from.fixedSize().measureWidth { self.fromWidth = $0 }
airplaneIcon.fixedSize().measureWidth { self.airplaneWidth = $0 }
to.fixedSize().measureWidth { self.toWidth = $0 }
}.hidden())
.lineLimit(1)
}
}
Checking Previews
16:28 We add a few previews with various sizes to see how the view
behaves. In the cases where the view's width is 175 points or smaller, the icon
moves out of the center to make room for the "San Francisco" label:
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
VStack(spacing: 20) {
AirplaneView().frame(width: 250)
AirplaneView().frame(width: 200)
AirplaneView().frame(width: 175)
AirplaneView().frame(width: 150)
AirplaneView().frame(width: 125)
AirplaneView().frame(width: 100)
}
}
}

17:21 And if the view is too small for either label to fit, the
airplane icon seems to be centered again. This happens because, after
subtracting the icon's width, the flexible frames of the labels are each
proposed half the remaining width, which they both accept because they can
truncate their text.
19:34 There are other interesting layout challenges waiting for us, so
we'll continue with one of them next time.