This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We implement a real-world layout that looks simple but is not so simple in SwiftUI.

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 this 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 them next time.

Resources

  • Sample Code

    Written in Swift 5.3

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

48 Episodes · 17h47min

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes