[SC]()

iOS. Apple. Indies. Plus Things.

A Less Evil NotificationCenter?

// Written by Jordan Morgan // Jan 24th, 2023 // Read it in about 3 minutes // RE: Tech Notes

Look, I get it. I shouldn’t be spamming NotificationCenter to do things like show a banner in SwiftUI:

But I also shouldn’t eat these little nuggets from heaven at 11:00 p.m. at night, and yet…

And so it is, I found myself in a little bit of a pickle. Here’s the scenario. In my upcoming basketball app shown above, I want to let folks know about a critical gesture to erase their strokes. After they draw their first stroke, I show the aforementioned banner.

Therein lies my issue, because SwiftUI begets so much sweet, beautiful view abstraction - I’m essentially several layers deep in my view hierarchy. That means the place which shows the banner has no clue about the place where the PencilKit strokes are drawn:

An exploded view of the basketball app's view hierarchy showing how the view that needs to trigger the banner is far in the view tree from the view which will show it.

Or, expressed as code - it’s a bit like this:

struct ViewOne: View {
	@State private var showTip = false

	var body: some View {
		VStack {
			StuffAndThings()
			ViewTwo()
		}
		.customBanner($showTip) // The Banner!
	}
}

struct ViewTwo: View {

	var body: some View {
		VStack {
			MoreStuffAndThings()
			ViewThree()
		}
	}
}

struct ViewThree: View {

	var body: some View {
		VStack {
			EvenMoreStuffAndThings()
			ViewFour()
		}
	}
}

struct ViewFour: View {
	var body: some View {
		ZStack {
			Stuff()
			WrappedPencilKitView() // Logic to fire banner here
		}
	}
}


To take care of it to hit my internal ship date, I took a shortcut. Enter the air-horn of NotificationCenter.

// Abbreviated for clarity...

final class AppSettings: ObservableObject {
    @AppStorage("hasSeenPencilKitTipsView") var viewedPKTips: Bool = false
}

// The wrapped PencilKit stuff
extension DrawingViewController: PKCanvasViewDelegate {
    func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) {
    	let settings = AppSettings()

    	guard !settings.viewedPKTips else {
    		return
    	}

        NotificationCenter.default.post(name: .ehFirstPencilStroke, 							    object: nil)

        settings.viewedPKTips = true
    }
}

// The SwiftUI View
struct WhiteboardView: View {
	@State private var showPencilTips = false

	var body: some View {
		/* Bunch of SwiftUI */
		.showCustomBanner($showPencilTips)
		.onReceive(NotificationCenter.default.publisher(for: .ehFirstPencilStroke), perform: { _ in
    		showPencilTips = true
		})
	}
} 

And that does #MostlyWork™️. But the fangs of blasting notifications can show themselves quite quickly. For one, there are two places that use the same drawing view, but I only want one of them to show this particular banner. Secondly, there are other custom notifications I use too, which means my View can end up looking like onReceive purgatory:

// The SwiftUI View
struct WhiteboardView: View {
	@State private var showPencilTips = false
	@State private var showSubsTips = false

	var body: some View {
		/* Bunch of SwiftUI */
		.showCustomBanner($showPencilTips)
		.onReceive(NotificationCenter.default.publisher(for: .ehFirstPencilStroke), perform: { _ in
    		showPencilTips = true
		})
		.onReceive(NotificationCenter.default.publisher(for: .ehShowSubstitutionExplainer), perform: { _ in
    		showSubsTips = true
		})
		/* And on...*/
	}
} 

So, for the time being, I just tried to make things a little nicer. One notification to rule them all. This helps me package up identifiers to avoid showing things at the wrong places while also ensuring I only require on .onReceive modifier for any custom domain logic using NotificationCenter:

extension NSNotification.Name {
    static let ehNotification: NSNotification.Name = .init(rawValue: "eh_notification")
}

extension NotificationCenter {
    func ehPublisher() -> NotificationCenter.Publisher {
        return self.publisher(for: .ehNotification)
    }
}

struct EHPayload {
    enum Scenario: Hashable {
        case didDrawPencilStroke,
             requestTeamFetch(courtID: Int64),
             showBench(courtID: Int64)
    }
    
    let scenario: Scenario
}

Basically, what we’ve got now is one abstract notification that fires, an Enum that identifies what kind of data or scenario the notification represents and one tiny little extension to get at a publisher easier.

So, the above now looks like this:

// Abbreviated for clarity...

extension DrawingViewController: PKCanvasViewDelegate {
    func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) {
    	let settings = AppSettings()

    	guard !settings.viewedPKTips else {
    		return
    	}

        let noteScenario: EHPayload = .init(scenario: .didDrawPencilStroke)
		NotificationCenter.default.post(name: .ehNotification,
			                            object: noteScenario)

        settings.viewedPKTips = true
    }
}

// The SwiftUI View
struct WhiteboardView: View {
	@State private var showPencilTips = false
	private let nc = NotificationCenter.default

	var body: some View {
		/* Bunch of SwiftUI */
		.showCustomBanner($showPencilTips)
		.onReceive(nc.ehPublisher()), perform: { note in
			guard let payload = note.object as? EHPayload else {
				return 
			}
            guard payload.scenario == .didDrawPencilStroke else {
            	return 
            }

    		showPencilTips = true
		})
	}
} 

And voilà. There is type safety, flexibility and only one onReceive modifier.

Final Thoughts

Look, sometimes in this business, you just have to sing for your supper. Is this whole thing in service of me thinking in UIKit terms in a SwiftUI context? Probably. This is one of those fun situations where you’ve got to dress up some ugly code and move on. If the time comes where this isn’t stable or I need to refactor things, I’ve been told passing an environment key would probably be the better option.

But for now, my banners work!

Until next time ✌️

···

Spot an issue, anything to add?

Reach Out.