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 refactor a simple flow layout to have a functional interface, disentangling the layout code from UIKit code.

00:06 Today we're going to look at how to write some functional code. We'll start out with imperative code, which is pretty standard layout code that doesn't rely on Auto Layout. Then we'll refactor it to a mostly functional version with a small imperative shell around it.

00:26 We start by implementing a flow layout, which is similar to UICollectionViewFlowLayout but much simpler. We already have a custom class, ButtonsView, which the view controller populates with a bunch of pill-shaped buttons, making it look like a tags interface. We wrote a custom initializer on UIButton to create this type of button:

final class ButtonsView: UIView {
    override func layoutSubviews() {
        super.layoutSubviews()
    }
}

extension UIButton {
    convenience init(pill title: String) {/*...*/}
}

class ViewController: UIViewController {
    @IBOutlet weak var buttonView: ButtonsView!
    @IBOutlet weak var constraint: NSLayoutConstraint!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        let buttons = (0..<20).map { UIButton(pill: "Button   \($0)") }
        for b in buttons { buttonView.addSubview(b) }
    }
    // ...
}

Imperative Layout Code

01:05 In layoutSubviews, we can first put all the buttons' frames at the top left so that we can at least see them. We're using the intrinsic content size of the subviews, so this will only work with views that supply an explicit content size:

final class ButtonsView: UIView {
    override func layoutSubviews() {
        super.layoutSubviews()

        for s in subviews {
            s.frame = CGRect(origin: .zero, size: s.intrinsicContentSize)
        }
    }
}

01:49 The next step is to keep track of a running x coordinate, in order to position the buttons next to each other on a single line. While we're laying them out, we add each subview's width to the current x:

final class ButtonsView: UIView {
    override func layoutSubviews() {
        super.layoutSubviews()
        
        var currentX = 0 as CGFloat
        for s in subviews {
            let size = s.intrinsicContentSize
            s.frame = CGRect(origin: CGPoint(x: currentX, y: 0), size: size)
            currentX += size.width
        }
    }
}

02:46 All buttons are lined up next to each other now and flowing off the screen. Before we assign a position to each button, we should check whether the button is wider than the space we have left on the current line and, if so, move down to the next line. We'll use a fixed offset of 50 points for each new line for now:

final class ButtonsView: UIView {
    override func layoutSubviews() {
        super.layoutSubviews()
        
        var currentX = 0 as CGFloat
        var currentY = 0 as CGFloat
        for s in subviews {
            let size = s.intrinsicContentSize
            if currentX + size.width > bounds.width {
                currentX = 0
                currentY = 50
            }
            s.frame = CGRect(origin: CGPoint(x: currentX, y: currentY), size: size)
            currentX += size.width
        }
    }
}

04:36 Instead of using a hardcoded line height, we should make this value dynamic by setting it to the highest view on each line. When we break to the next line, we reset the line height to allow for it to be recalculated for the new line:

final class ButtonsView: UIView {
    override func layoutSubviews() {
        super.layoutSubviews()
        
        var currentX = 0 as CGFloat
        var currentY = 0 as CGFloat
        var lineHeight = 0 as CGFloat
        for s in subviews {
            let size = s.intrinsicContentSize
            if currentX + size.width > bounds.width {
                currentX = 0
                currentY += lineHeight
                lineHeight = 0
            }
            s.frame = CGRect(origin: CGPoint(x: currentX, y: currentY), size: size)
            lineHeight = max(lineHeight, size.height)
            currentX += size.width
        }
    }
}

05:37 The buttons are now flowing from left to right and top to bottom, and they are directly next to each other without any spacing. If we rotate the device, we see the buttons laid out in landscape, and we get the animated transition between the two orientations for free.

05:42 Next, we'll add some spacing between the buttons using a UIOffset constant. We add the vertical spacing after each new line, and after adding a view to the current row, we add the horizontal spacing as well:

final class ButtonsView: UIView {
    override func layoutSubviews() {
        super.layoutSubviews()
        
        let spacing = UIOffset(horizontal: 10, vertical: 10)
        var currentX = 0 as CGFloat
        var currentY = 0 as CGFloat
        var lineHeight = 0 as CGFloat
        for s in subviews {
            let size = s.intrinsicContentSize
            if currentX + size.width > bounds.width {
                currentX = 0
                currentY += lineHeight + spacing.vertical
                lineHeight = 0
            }
            s.frame = CGRect(origin: CGPoint(x: currentX, y: currentY), size: size)
            lineHeight = max(lineHeight, size.height)
            currentX += size.width + spacing.horizontal
        }
    }
}

Separating the Layout into a Struct

06:31 This completes our layout, but even though it's quite simple, the result is a long blob of imperative code. It'd be nice if we could separate the layout code from the rest of the view code. The frame calculation doesn't have to be entangled with the specific functionality of the view — which also makes it harder to test.

07:26 The layout code only needs three inputs: the spacing constant, the container size, and the frames of the subviews. Let's see how we can pull it out into a separate struct. We move the spacing and container size constants to the struct as properties and let them be set with an initializer:

struct FlowLayout {
    let spacing: UIOffset
    let containerSize: CGSize
    init(containerSize: CGSize, spacing: UIOffset = UIOffset(horizontal: 10, vertical: 10)) {
        self.spacing = spacing
        self.containerSize = containerSize
    }
}

08:51 Then, we move the variables from outside the for-loop into FlowLayout as private properties, and we move the body of the for-loop into a mutating method. Instead of directly setting the frame of a subview, this method has to return the frame:

struct FlowLayout {
    let spacing: UIOffset
    let containerSize: CGSize
    
    init(containerSize: CGSize, spacing: UIOffset = UIOffset(horizontal: 10, vertical: 10)) {
        self.spacing = spacing
        self.containerSize = containerSize
    }
    
    var currentX = 0 as CGFloat
    var currentY = 0 as CGFloat
    var lineHeight = 0 as CGFloat
    
    mutating func add(element size: CGSize) -> CGRect {
        if currentX + size.width > containerSize.width {
            currentX = 0
            currentY += lineHeight + spacing.vertical
            lineHeight = 0
        }
        defer {
            lineHeight = max(lineHeight, size.height)
            currentX += size.width + spacing.horizontal
        }
        return CGRect(origin: CGPoint(x: currentX, y: currentY), size: size)
    }
}

10:31 In the ButtonsView, we create a FlowLayout and call it with each subview's size to get its frame back:

final class ButtonsView: UIView {
    override func layoutSubviews() {
        super.layoutSubviews()
        
        var flowLayout = FlowLayout(containerSize: bounds.size)
        for s in subviews {
            s.frame = flowLayout.add(element: s.intrinsicContentSize)
        }
    }
}

11:16 In separating the layout code into a struct, we made it easier to test. We just have to create an instance of FlowLayout and feed it some sizes — we don't even need actual subviews for this.

12:01 We could write other layouts — like a justified flow layout — the same way, and come up with a common interface in order to dynamically swap out layouts like you can do with UICollectionView.

12:27 We can refactor and clean up the code a little bit by combining the running x and y coordinates into one CGPoint:

struct FlowLayout {
    // ...
    
    var current = CGPoint.zero
    var lineHeight = 0 as CGFloat
    
    mutating func add(element size: CGSize) -> CGRect {
        if current.x + size.width > containerSize.width {
            current.x = 0
            current.y += lineHeight + spacing.vertical
            lineHeight = 0
        }
        defer {
            lineHeight = max(lineHeight, size.height)
            current.x += size.width + spacing.horizontal
        }
        return CGRect(origin: current, size: size)
    }
}

Converting the Struct to a Function

13:23 If we don't add elements one by one, but rather pass in all sizes to calculate all frames at once — like we'd need to do for a justified layout — then we can write the layout code as a single function.

13:38 We change struct into func and pass in the initializer's parameters as the function parameters. We also need to pass in an array of sizes, and the function has to return an array of CGRect:

func flowLayout(containerSize: CGSize, spacing: UIOffset = UIOffset(horizontal: 10, vertical: 10), sizes: [CGSize]) -> [CGRect] {
    // ...
}

14:23 We replace the mutating method with a for-loop that collects all frames in an array:

func flowLayout(containerSize: CGSize, spacing: UIOffset = UIOffset(horizontal: 10, vertical: 10), sizes: [CGSize]) -> [CGRect] {
    var current = CGPoint.zero
    var lineHeight = 0 as CGFloat

    var result: [CGRect] = []
    for size in sizes {
        if current.x + size.width > containerSize.width {
            current.x = 0
            current.y += lineHeight + spacing.vertical
            lineHeight = 0
        }
        defer {
            lineHeight = max(lineHeight, size.height)
            current.x += size.width + spacing.horizontal
        }
        result.append(CGRect(origin: current, size: size))
    }
    return result
}

15:11 The view can now pass an array of its subviews' sizes to this layout function and then assign the returned frames back to the subviews:

final class ButtonsView: UIView {
    override func layoutSubviews() {
        super.layoutSubviews()
        
        let sizes = subviews.map { $0.intrinsicContentSize }
        let frames = flowLayout(containerSize: bounds.size, sizes: sizes)
        for (idx, frame) in frames.enumerated() {
            subviews[idx].frame = frame
        }
    }
}

16:35 We run it and everything still works!

Conclusion

16:46 It's almost always possible to replace a mutable struct with a function — as long as we can pass in all the data up front. This would make sense when creating a justified layout, for which we need to know all sizes in order to calculate the spacing on each line.

17:18 Even though we're using an imperative for-loop, it's contained within a function that doesn't have any side effects. So we have a purely functional interface with an imperative implementation on the inside. We could've written the function body with other functions like map and reduce, but that wouldn't make the code any clearer.

17:55 We're really happy with this mix-and-match combination: a pure interface without side effects is so easy to work with, and the imperative implementation is very to the point and efficient.

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