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 show how the layout library we've built over the past two months can be used to adapt to any font and screen size.

00:06 We're finally ready to use our layout library! Instead of building more features, we're going to see how it works in practice.

00:14 In the first episode of this series, we showed a flight info app. Now we're going to build something similar to that example. We've prepared some types, an example flight, a date formatter, and a helper that provides metadata:

struct Airport {
    var city: String
    var code: String
    var time: Date
}

struct Flight {
    var origin: Airport
    var destination: Airport
    var name: String
    var terminal: String
    var gate: String
    var boarding: Date
}

let start: TimeInterval = 3600*7
let flight = Flight(origin: Airport(city: "Berlin", code: "TXL", time:
    Date(timeIntervalSince1970: start)), destination: Airport(city: "Paris", code: "CDG", time: Date(timeIntervalSince1970: start + 2*3600)), name: "AF123", terminal: "1", gate: "14", boarding: Date(timeIntervalSince1970: start - 1800))

let formatter: DateFormatter = {
    let f = DateFormatter()
    f.dateStyle = .none
    f.timeStyle = .short
    return f
}()

extension Flight {
    var metaData: [(String, String)] {
        return [("FLIGHT", name), ("TERMINAL", terminal), ("GATE", gate), ("BOARDING", formatter.string(from: boarding))]
    }
}

00:58 As a first step, we add a layout property on Flight and call it for the layout that is to be displayed by the view controller:

extension Flight {
    var layout: Layout {
        return .empty
    }
}

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        let layout = flight.layout
        
        let container = LayoutContainer(layout)
        container.translatesAutoresizingMaskIntoConstraints = false

        view.addSubview(container)
        view.addConstraints([
            container.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor),
            container.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
            container.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
        ])
    }
}

Showing Origin and Destination

01:21 Next we can create some labels grouped in a vertical layout and containing the information we want to display. By using a date formatter, we're already introducing some variation into our layout, since the string returned by the formatter depends on the user's locale:

extension Flight {
    var layout: Layout {
        let l = UILabel(text: "From", size: .body).layout
        let c = UILabel(text: origin.code, size: .largeTitle).layout
        let t = UILabel(text: formatter.string(from: origin.time), size: .body).layout
        return [l, c, t].vertical()
    }
}

03:24 We can now see information about the origin airport:

03:29 We'll need the same information about the destination airport. We move the above code into a method on Airport so that we can reuse it for both airports:

extension Airport {
    func layout(title: String) -> Layout {
        let l = UILabel(text: title, size: .body).layout
        let c = UILabel(text: code, size: .largeTitle).layout
        let t = UILabel(text: formatter.string(from: time), size: .body).layout
        return [l, c, t].vertical()
    }
}

extension Flight {
    var layout: Layout {
        let orig = origin.layout(title: "From")
        let dest = destination.layout(title: "To")
        return [orig, dest].horizontal(space: .flexible(min: 20))
    }
}

04:50 What happens when we put the two layouts next to each other can be counterintuitive to someone who doesn't know how the library works:

04:59 The layout library is line based, and it concatenates the first line of the destination airport layout with the last line of the origin airport layout. That explains why the "To" label is placed next to the "8:00 AM" label.

To fix the layout, we want to wrap each group of labels in a box and have the two boxes laid out next to each other on a line:

extension Airport {
    func layout(title: String) -> Layout {
        // ...
        return [l, c, t].vertical().box()
    }
}

05:40 The two boxes are now placed next to each other, but we immediately see that they overlap when the font size is too large. We should provide an alternative layout that stacks the two airports vertically:

extension Flight {
    var layout: Layout {
        let orig = origin.layout(title: "From")
        let dest = destination.layout(title: "To")
        return [orig, dest].horizontal(space: .flexible(min: 20)).or([orig, dest].vertical(space: 10))
    }
}

Now, if there's not enough room to put the two airports next to each other on the same line, the layout switches to the vertical version:

06:58 We wrap the whole thing in a box with a wrapper view with a gray background. We use a custom convenience initializer to create this wrapper view:

extension Flight {
    var layout: Layout {
        let orig = origin.layout(title: "From")
        let dest = destination.layout(title: "To")
        let flightBg = UIView(backgroundColor: .gray, cornerRadius: 10)
        return [orig, dest].horizontal(space: .flexible(min: 20)).or([orig, dest].vertical(space: 10)).box(wrapper: flightBg, width: .flexible(min: 0))
    }
}

We give the box a flexible width so that it stretches out to the entire width of the screen — we'd ideally like to express that the minimum width is based on the box's contents, but we can't do that currently, so we define the minimum width as zero.

08:07 We also change the text color of the labels to white, in order to make them look better on the gray background:

extension Airport {
    func layout(title: String) -> Layout {
        let l = UILabel(text: title, size: .body, textColor: .white).layout
        let c = UILabel(text: code, size: .largeTitle, textColor: .white).layout
        let t = UILabel(text: formatter.string(from: time), size: .body, textColor: .white).layout
        return [l, c, t].vertical().box()
    }
}

Adding Metadata Labels

08:47 Next, we add the metadata to the layout by mapping over the array of key-value pairs, wrapping each label pair in a box and combining the boxes horizontally. We wrap the entire metadata combination in a box with a red background color:

extension Flight {
    var layout: Layout {
        // ...
        let flight = // ...
        let metaItems = metaData.map { (key, value) in
            [
                UILabel(text: key, size: .caption1, textColor: .white).layout,
                UILabel(text: value, size: .body, textColor: .white).layout
            ].vertical(space: 0).box()
        }
        let meta = metaItems.horizontal(space: .flexible(min: 20)).box(wrapper: UIView(backgroundColor: .red, cornerRadius: 10), width: .flexible(min: 0))
        return [flight, meta].vertical(space: 20)
    }
}

Adding Variations

11:50 This looks good, but when we increase the font size using the Accessibility Inspector, the labels quickly start to overlap, so let's provide some alternative layouts. If the four boxes don't fit next to each other on one line, we want to switch to a grid of two by two:

extension Flight {
    var layout: Layout {
        // ...
        let meta = metaItems.horizontal(space: .flexible(min: 20)).or(
            [
                metaItems[0...1].horizontal(space: .flexible(min: 20)),
                metaItems[2...3].horizontal(space: .flexible(min: 20))
            ].vertical(space: 10)
        )
        let metaLayout = meta.box(wrapper: UIView(backgroundColor: .red, cornerRadius: 10), width: .flexible(min: 0))
        return [flight, metaLayout].vertical(space: 20)
    }
}

13:43 This almost works, but we've defined the horizontal and vertical helpers on Array, and by using a range subscript on metaItems, we end up with an ArraySlice instead. Looking at the helpers, we see that we use methods like dropLast and reversed, so we need to work with a type that can be reversed efficiently. We change the type to BidirectionalCollection:

extension BidirectionalCollection where Element == Layout {
    func horizontal(space: Width? = nil) -> Layout {
        guard var result = last else { return .empty }
        for l in dropLast().reversed() {
            if let width = space {
                result = .space(width, result)
            }
            result = l + result
        }
        return result
    }
    
    func vertical(space: CGFloat = 0) -> Layout {
        guard var result = last else { return .empty }
        for l in dropLast().reversed() {
            result = l + .newline(space: space, result)
        }
        return result
    }
}

14:19 Everything else still works because BidirectionalCollection has the same API as Array. We run the app and check that it works:

14:37 Increasing the font size even more, we want to switch to a third variation where each of the four metadata boxes is on its own line:

extension Flight {
    var layout: Layout {
        // ...
        let meta = metaItems.horizontal(space: .flexible(min: 20)).or(
            [
                metaItems[0...1].horizontal(space: .flexible(min: 20)),
                metaItems[2...3].horizontal(space: .flexible(min: 20))
            ].vertical(space: 10)
        ).or(metaItems.vertical(space: 10))
        // ...
    }
}

Centering Horizontally

15:36 It would be nice to center the airport information labels. We would use text alignment for this if we could stretch out the labels to fill the width of their containers. Unfortunately, we can't do that, because we can't define a custom width for views.

16:07 However, we can achieve the same effect by adding a flexible space before and after the labels, because each label is on a separate line inside the box. We add a helper, centered, to horizontally join together a flexible space, a layout element, and another flexible space:

extension Layout {
    var centered: Layout {
        return [.space(.flexible(min: 0), .empty), self, .space(.flexible(min: 0), .empty)].horizontal()
    }
}

Then we call this helper to center the three airport labels:

extension Airport {
    func layout(title: String) -> Layout {
        let l = UILabel(text: title, size: .body, textColor: .white).layout.centered
        let c = UILabel(text: code, size: .largeTitle, textColor: .white).layout.centered
        let t = UILabel(text: formatter.string(from: time), size: .body, textColor: .white).layout.centered
        return [l, c, t].vertical().box()
    }
}

18:08 The labels are now centered inside their box. However, when the font size is large enough that the airports get laid out vertically, then the two boxes should also be centered inside their surrounding box. To achieve this, we use the centered helper on both airport elements in the vertical choice:

extension Flight {
    var layout: Layout {
        let orig = origin.layout(title: "From")
        let dest = destination.layout(title: "To")
        let flightBg = UIView(backgroundColor: .gray, cornerRadius: 10)
        let flight = [orig, dest].horizontal(space: .flexible(min: 20)).or([orig.centered, dest.centered].vertical(space: 10)).box(wrapper: flightBg, width: .flexible(min: 0))
        // ...
        return [flight, metaLayout].vertical(space: 20)
    }
}

Conclusion

19:36 In this short amount of time and using a fairly small amount of code, we've made our app work for a whole range of font sizes, device orientations, and locales. It would take a lot of work to build the same responsive layout using just Auto Layout constraints. If anyone wants to take up this challenge, we'd be very interested in seeing that implementation!

19:49 There are still many more features we could add to the library. For example, we could automatically use a scroll view if the layout doesn't fit on the screen vertically. It would also be nice to have the option to vertically align elements on a single line, so that we could put an airplane icon in the middle between the origin and destination.

20:09 The possibilities are endless, but the library is already very useful right now. We really like the idea that we only have to define a few layout variations and that the library figures out which one to apply for us.

Resources

  • Sample Project

    Written in Swift 4.2

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    New subscriber-only episodes every two weeks

  • Invite Your Team

    Sign up additional team members at 30% discount

  • Support Us

    Ensure the continuous production of new episodes