Pass data throughout your app with observers and Notifications - Xcode 11 & Swift 5

Pass data throughout your app with observers and Notifications - Xcode 11 & Swift 5

Overview

Observers are a tricky thing in Swift. We will start by taking a look at some of Apple's documentation and then move on to writing our own code. Observers are incredibly important when coding an app, but can be daunting to a new developer. The idea behind an observer is to respond instantly to a change in data. The faster an app can respond to a change in data, the faster your app will feel to the user.

Imagine you have a function or method that periodically checks if data has changed and then responds accordingly. An observer could revolutionize this function. With an observer, not only will your app instantly respond to changes in data, it will also reduce CPU usage and energy impact of your app. Now instead of a timer constantly leaching off of our CPU, we have a simple listener that fires only when data has changed.

To begin testing the code, we will need to create a new Playground to work on. Once created, open the identity tab and confirm that Playground Settings are set to macOS. This solves a common error where the playground never compiles or runs. Strange bug, but easy fix. You can open the tab by clicking the top right button on the tool bar. If your tool bar is missing, just hit Option-Command-T, or go to View -> Show Toolbar.

No alt text provided for this image

Now let's take a look at what Apple has written for us in their documentation and start coding. You can click the following link to jump right to their documentation on properties. About a third of the way down there is a section called Property Observers which is the resource we will be looking at.

Property observers observe and respond to changes in a property’s value. Property observers are called every time a property’s value is set, even if the new value is the same as the property’s current value.
class StepCounter {

    var totalSteps: Int = 0 {
        willSet(newTotalSteps) {
            print("About to set totalSteps to \(newTotalSteps)")
        }

        didSet {
            if totalSteps > oldValue  {
                print("Added \(totalSteps - oldValue) steps")
            }
        }
    }

}

let stepCounter = StepCounter()
stepCounter.totalSteps = 200

// About to set totalSteps to 200
// Added 200 steps

This is incredibly useful. If we take a look at the code they provide we can already see that the code responds and prints dynamic data every time we update stepCounter.totalSteps even if we don't change the value. But how does it work? According to Apple:

"If you implement a willSet observer, it’s passed the new property value as a constant parameter. You can specify a name for this parameter as part of your willSet implementation. If you don’t write the parameter name and parentheses within your implementation, the parameter is made available with a default parameter name of newValue."

"Similarly, if you implement a didSet observer, it’s passed a constant parameter containing the old property value. You can name the parameter or use the default parameter name of oldValue. If you assign a value to a property within its own didSet observer, the new value that you assign replaces the one that was just set."

I was confused when first examining the documents, but the documentation is actually very clear. Fortunately there are only two kinds of property observers to remember: willSet and didSet.

In willSet, Swift provides your code with a value called newValue with can be substituted for a parameter. The newValue contains data for what the new property value is going to be. Note that willSet runs prior to the value updating. This means that you can easily write a conditional before the value changes.

class StepCounter {

    var totalSteps: Int = 0 {
        willSet (newTotalSteps){
            if newTotalSteps < 200 {
                print("About to set totalSteps to \(newTotalSteps)")
            }
        }
        didSet {

        }
    }

}
let stepCounter = StepCounter()
stepCounter.totalSteps = 200

stepCounter.totalSteps = 20

When you run this code it will only output About to set totalSteps to 20 and not About to set totalSteps to 200.

In didSet you are provided with a default oldValue unless you specify a name as a parameter to represent the previous value. You can add conditionals here as well, but at this point the value has already set.

Apple lists a parameter in their example to replace newValue called 'newTotalSteps', but this is unnecessary as they mention. You are also not required to use both of these observers. You can remove willSet or didSet and the code will still run perfectly. In the code below you can see that we removed the parameter from willSet and removed didSet entirely, yet the code runs and compiles perfectly.

class StepCounter {

    var totalSteps: Int = 0 {
        willSet {
            print("About to set totalSteps to \(newValue)")
        }
}


let stepCounter = StepCounter()

stepCounter.totalSteps = 200

At this point the documentation digresses to Property Wrappers so we will conclude our examination of the docs and move on to our own code.

Coding

A playground will no longer be enough for our tests, so create a new project with the Single View App template. Choose Storyboard as the User Interface because we will be working with UIKit. I named my project "notifs", but feel free to use any name you wish.

No alt text provided for this image

Create a new Swift file and import UIKit. UIKit contains NotificationCenter which will be essential for the rest of this guide. At this point your project should look similar to, or exactly like this:

No alt text provided for this image

At this point we are ready to work on creating NotificationCenter listeners. First declare a class StateManager. Add an enumeration with two cases (up and down) within StateManager. Our code uses two cases, but you could include additional ?cases. Next, add a method to StateManager called stateDidChange( ). Leave it empty for now, but we will soon return to it. Finally, add a structure StateStateS where we will eventually store our enumeration.

struct StateStateS {

}

class StateManager {

    enum StateState {
        case up
        case down
    }

   func stateDidChange() {

    }

}

Using a structure will prevent us from having multiple instances of enumerations. We only want one instance because our goal is to observe a single value throughout our app, and respond consistently. Keeping the value consistent globally is useful for things like a color mode where changing the value should affect every view in your app at the same time. For example if you have three color modes, light, dark, and rainbow, you could use this code to notify every view to change colors to fit the color mode. If you want to touch up on Structures, Classes, or Enumerations, check out the Apple documentation from the following links:

Next we need to create an instance of our StateState enumeration within the StateStateS structure with a property observer didSet. Call the method stateDidChange() from the StateManager( ) Class in didSet.

struct StateStateS {

    static var state = StateManager.StateState.down {
           //property observer on 'state'
           // run a function on each value change.
        didSet {
           StateManager().stateDidChange() 
         }
       }

}

We will now use the Apple NotificationCenter API to broadcast notifications whenever the state changes. NotificationCenter is singleton-based but we will inject a NotificationCenter instance in the initializer of StateManager regardless.

class StateManager {

    enum StateState {
        case up
        case down
    }

    private let notificationCenter: NotificationCenter

    init(notificationCenter: NotificationCenter = .default) {
         self.notificationCenter = notificationCenter

    }

    func stateDidChange() {

    }

}

NotificationCenter operates with named notifications that identify when an event is either observed or triggered. To create these named notifications, add an extension to NotificationCenter.Name. The extension is useful to have a single storage for our notification names and avoid inline strings. Inline strings are fine, but for data containing strings that repeat, they become inefficient. Add two notifications, one for up and one for down.

extension Notification.Name {

    static var stateUp: Notification.Name {
        return .init(rawValue: "StateManager.StateUp")
    }

    static var stateDown: Notification.Name {
        return .init(rawValue: "StateManager.StateDown")
    }

}

Now that we have our notifications, we need to link them in some way to our data changing in value so they can post when the value changes. Earlier we linked the property observer didSet in StateStateS.state to StateManager( ).stateDidChange( ), so now all we need to do is add our post functions there. We will use a switch to check the value of StateStateS.state and separate the two posts.

    func stateDidChange() {

        switch StateStateS.state {
        case .up:
            notificationCenter.post(name: .stateDown, object: nil)
        case .down:
            notificationCenter.post(name: .stateUp, object: nil)
        }

    }

We are actually almost finished at this point! Currently our only problem is that we have no way of changing the value of state, so let's add a few methods to change state and we will be able to use this system to pass data around our app! Add these methods to StateManager:

    func up() {
        StateStateS.state = .up
    }

    

    func down() {

        switch StateStateS.state {
        case .down:
            break
        case .up:
            StateStateS.state = .down
        }

    }

Both of these methods accomplish a similar effect, however I've included a switch in down( ) to demonstrate additional ways that you can manage data flow. You can also simply set the state as shown in up( ).

At this point we are done in File.swift and will move on to adding the observers elsewhere in the app. Before we move on, here is all the code we have written thus far:

import UIKit


struct StateStateS {

    static var state = StateManager.StateState.down {

        didSet {
            StateManager().stateDidChange()
        }
    }
}




class StateManager {
   
    enum StateState {

        case up
        case down
    }

    

    private let notificationCenter: NotificationCenter

    init(notificationCenter: NotificationCenter = .default) {
        self.notificationCenter = notificationCenter
    }

    

    func up() {

        StateStateS.state = .up
    }

    

    func down() {

        switch StateStateS.state {
        case .down:
            break
        case .up:
            StateStateS.state = .down
        }
    }

    

    func stateDidChange() {

        switch StateStateS.state {
        case .up:
            notificationCenter.post(name: .stateDown, object: nil)
        case .down:
            notificationCenter.post(name: .stateUp, object: nil)
        }
    }
}




extension Notification.Name {

    static var stateUp: Notification.Name {
        return .init(rawValue: "StateManager.StateUp")
    }

    static var stateDown: Notification.Name {
        return .init(rawValue: "StateManager.StateDown")
    }

}

To change the state to down simply call StateManager( ).down( ). To change the state to up, call StateManager( ).up( ) from anywhere in your app.

StateManager().down() //state becomes down
StateManager().up() //state becomes up

To execute code within your app when the state changes, add an observer to the NotificationCenter and link it to a selector that you wish to fire when the value changes to the set value. This might be coded in viewDidLoad( ), but it can be written within a method or action as well. However, until the observer is added your app will not execute the selector when the value changes.

override func viewDidLoad() {
        super.viewDidLoad()
        NotificationCenter.default.addObserver(self, selector: #selector(stateDidDown), name: .stateDown, object: nil)
        NotificationCenter.default.addObserver(self, selector: #selector(stateDidUp), name: .stateUp, object: nil)
    }

    

    @objc private func stateDidDown(_ notification: Notification) {
        //whatever code put here will execute whenever the state is set to down
    }
@objc private func stateDidUp(_ notification: Notification) {
        //whatever code put here will execute whenever the state is set to up
    }

    

Feel free to message or email me at [email protected] if you have any questions.

Note: This article explains a portion of a previous article that I wrote which detailed creating a SwiftUI popup on a UIViewController. That article uses this code very closely, so if you want to see an example of this code in action, follow the link. You can find that article here:


要查看或添加评论,请登录

Ean Krenzin-Blank的更多文章