Property observers in Swift

Being able to observe changes in various values is essential for many different kinds of programming styles and techniques. Whether we’re using delegates, functions, or reactive programming — much of our logic is often driven by changes in states and values.

While there are a number of abstractions that we can create to be able to observe and communicate such value changes in different ways — Swift comes built-in with a simple, yet powerful way to attach observations to any kind of non-lazy, stored property — appropriately named property observers.

This week, let’s take a look at a few different ways to use property observers, and how they can let us drive parts of our logic in a very reactive way — without any additional frameworks or abstractions.

Automatic updates

When dealing with any kind of state, we’d ideally like to have a single source of truth, that we can both inspect and observe — in order to update other parts of our code base that depend on that state’s current value.

For example, let’s say that we’re building a drawing application, and that one of our core UI classes is a view controller that renders a toolbox panel — which lets our users change the drawing tool (like pens, brushes, or erasers) that’s currently being used. Our ToolboxViewController is then owned by a parent view controller that coordinates both the toolbox and the drawing canvas.

To enable ToolboxViewController to let its owner know whenever the currently selected tool was changed, and to do so in a decoupled manner, we’re using the delegate pattern to define a communication protocol — like this:

protocol ToolboxViewControllerDelegate: AnyObject {
    func toolboxViewController(
        _ viewController: ToolboxViewController,
        willChangeToolTo newTool: Tool
    )

    func toolboxViewController(
        _ viewController: ToolboxViewController,
        didChangeToolFrom oldTool: Tool
    )
}

class ToolboxViewController: UIViewController {
    weak var delegate: ToolboxViewControllerDelegate?
    var tool = Tool.boxedSelection
}

The above API uses the very common ”will/did” naming convention, that has long been used on Apple’s platforms, which enables the delegate to take different actions both before and after a change in state occurs.

However, since the view controller’s tool property can be mutated at any time, it’ll be hard to guarantee that the API contract that our delegation protocol establishes will actually be followed. We’ll essentially be required to remember to always call both the willChangeTool and the didChangeTool methods anytime we make any change to the tool property — either within or outside of ToolboxViewController.

The above problem is exactly what property observers aim to solve. They come in two different flavors — willSet and didSet — and let us easily run a block of code either before or after a property was assigned. In any willSet block, a newValue variable will automatically be available, which contains the value that our property is about to be updated with — and likewise didSet blocks contain an oldValue variable that works the same way, but for the previous value.

Using both willSet and didSet, we can now easily make sure that our delegate is indeed notified in a correct way anytime our tool property is changed:

class ToolboxViewController: UIViewController {
    weak var delegate: ToolboxViewControllerDelegate?

    var tool = Tool.boxedSelection {
        willSet {
            // Property observers are called even if the new
            // value is the same as the old one, so we need to
            // perform a simple equality check here.
            guard tool != newValue else {
                return
            }

            delegate?.toolboxViewController(self,
                willChangeToolTo: newValue
            )
        }

        didSet {
            guard tool != oldValue else {
                return
            }

            delegate?.toolboxViewController(self,
                didChangeToolFrom: oldValue
            )
        }
    }
}

Now, regardless of where and how we change the currently selected tool, the delegate will always be notified — which enables us to keep our view controller’s API simple, while still ensuring correctness under the hood.

View configuration

Another area in which property observers really shine is when we want to configure a view based on a given value. For example, here we’re using a didSet property observer to propagate changes to a colors tuple down to an underlying CAGradientLayer, that’s used to render a gradient view:

class GradientView: UIView {
    // By defining a custom 'layerClass' for a view, we can
    // leverage all of UIView's default layer handling, while
    // still using a more specialized CALayer subclass.
    override class var layerClass: AnyClass {
        return CAGradientLayer.self
    }

    var colors: (start: UIColor, end: UIColor)? {
        didSet {
            let gradient = layer as! CAGradientLayer

            gradient.colors = colors.map {
                [$0.start.cgColor, $0.end.cgColor]
            }
        }
    }
}

We skip doing an equality check in the above didSet observation, since we’re simply passing the assigned value on to our CAGradientLayer — there’s no logic that requires that the change is to a completely new value.

The above kind of design both gives us the same benefits as in the ToolboxViewController example from before, but it also enables us to hide implementation details (such as that the gradient will be rendered using CAGradientLayer), behind a much simpler API. All we have to do to use the above class is to assign a start and end color, and everything else will automatically be taken care of.

Rendering only when needed

While there’s no harm in re-rendering simple views whenever a value change occurred, for more complex views or datasets, doing updates too eagerly can sometimes lead to performance problems and a lot of unnecessary computation.

For example, let’s say that we’re building a custom graph rendering system that lets us plot a (potentially large) number of points on a graph — and that rendering such a graph can be a quite expensive operation. That would make the following kind of code problematic, if we were to always re-render the graph any time one of the following three properties were changed:

private extension GraphViewController {
    func configure(_ view: GraphView, with graph: Graph) {
        view.points = graph.points.map { $0.cgPoint }
        view.lineColor = theme.graphLineColor
        view.drawMarkers = shouldDrawMarkers
        // At this point we'll have rendered the graph 3 times
    }
}

To fix the above issue, we might need to implement a more lazy and defensive rendering scheme. In this case, we’ll both start equality-checking each change (similar to what we did in the initial ToolboxViewController example), and also use UIKit’s setNeedsDisplay and drawRect methods — which enables us to defer the actual rendering of our graph until the next drawing cycle, avoiding duplicate updates:

class GraphView: UIView {
    var points = [CGPoint]() {
        didSet { property(\.points, didChangeFrom: oldValue) }
    }

    var lineColor = UIColor.black {
        didSet { property(\.lineColor, didChangeFrom: oldValue) }
    }

    var drawMarkers = false {
        didSet { property(\.drawMarkers, didChangeFrom: oldValue) }
    }

    override func draw(_ rect: CGRect) {
        // Draw the graph
        ...
    }

    private func property<T: Equatable>(
        _ keyPath: KeyPath<GraphView, T>,
        didChangeFrom oldValue: T
    ) {
        guard self[keyPath: keyPath] != oldValue else {
            return
        }

        setNeedsDisplay()
    }
}

Above we use key paths to avoid duplicating too much code between our didSet observers, since we can now perform all equality checks using a single guard statement in our property(didChangeFrom:) method — and only schedule a redraw once a value was actually changed.

Input validation

Finally, let’s take a look at how we can utilize property observers to validate (and adjust) input values.

Let’s say that we’re using some slight “gamification” by giving our users achievements whenever they accomplish goals within our app. We model our achievements using an Achivement struct that, among other data, contains the amount of progress that the user has made towards a given achievement — defined as a rate between 0 and 1, looking like this:

struct Achievement {
    var id: ID
    var name: String
    var progressRate: Double = 0
}

Whenever a piece of code requires that a value stays within a certain range, it’s always nice if we can do whatever we can to ensure that — and that out-of-bounds values trigger some form of error.

In this case, we could make that happen by adding a didSet observation to our progressRate property — and within that observation block trigger an assertionFailure in case the assigned value didn’t match our expectations, and also adjust it to always be within the allowed bounds — like this:

struct Achievement {
    var id: ID
    var name: String
    var progressRate: Double = 0 {
        didSet {
            let allowedRange: ClosedRange<Double> = 0...1

            if allowedRange.contains(progressRate) {
                return
            }

            assertionFailure("Progress rate must be within 0-1")
            progressRate = max(allowedRange.lowerBound, progressRate)
            progressRate = min(allowedRange.upperBound, progressRate)
        }
    }
}

The reason we’re using assertionFailure above, is that we don’t want to cause crashes in production, while still being able to detect invalid code paths during development. For more on that, check out “Picking the right way of failing in Swift”.

Note that we’re able to re-assign the observed property within the above didSet block, without causing any infinite recursions — since changes made within a didSet block don’t trigger any additional observations on that same property.

Another situation in which property observations aren’t triggered is when initializing an object or value — which in our case can become a bit problematic, since even though we’re no longer able to assign an invalid progressRate to an achievement, initializations are left unchecked — making this code pass without triggering our assertion:

let achievement = Achievement(
    id: 7,
    name: "Completed 10 tasks",
    progressRate: 999
)

To fix that, let’s move our validation logic to a separate method — that we can both call from within our didSet observation block, and also when initializing an Achievement instance:

private extension Achievement {
    func validate(_ progressRate: Double) -> Double {
        let allowedRange: ClosedRange<Double> = 0...1

        if allowedRange.contains(progressRate) {
            return progressRate
        }

        assertionFailure("Progress rate must be within 0-1")

        return max(
            allowedRange.lowerBound,
            min(allowedRange.upperBound, progressRate)
        )
    }
}

The reason the above method returns a new value, rather than simply assigning it directly to our progressRate property, is because we need to do the actual assignment within our didSet block — otherwise we would start causing infinite recursions, since the compiler only protects us from such recursions when the mutation happens directly within a property observer itself.

We can now use the above method to ensure that both assignments, and initializations, use progressRate values that are within the allowed range:

struct Achievement {
    var id: ID
    var name: String
    var progressRate: Double = 0 {
        didSet {
            progressRate = validate(progressRate)
        }
    }

    init(id: ID, name: String, progressRate: Double) {
        self.id = id
        self.name = name
        self.progressRate = validate(progressRate)
    }
}

Another approach to solving the above problem would be to do the validation at the call sites instead, for example by introducing a dedicated Progress type. We’ll take a closer look at different tactics for that kind of validation, and other ways to ensure that our values stay correct, in future articles.

Conclusion

Property observations using willSet and didSet provide an incredibly powerful way to easily observe changes in values, without the need for any kind of additional abstraction. They can be used to ensure that we’re driving our logic based on a single source of truth, and allow us to reactively update other values and state when a given property changes.

While Swift’s built-in property observers only enable us to observe synchronous property changes, they can also be used to build asynchronous abstractions as well — such as Futures & Promises.

What do you think? Do you currently use property observers to propagate value changes, or is it something you’ll try out? Let me know — along with your questions, comments and feedback — on Twitter @johnsundell.

Thanks for reading! 🚀

Structuring model data in Swift

Designing Swift APIs