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.