[SC]()

iOS. Apple. Indies. Plus Things.

Refactoring to TipKit from AppStorage and Custom Views in SwiftUI

// Written by Jordan Morgan // May 1st, 2024 // Read it in about 3 minutes // RE: TipKit

This post is brought to you by Emerge Tools, the best way to build on mobile.

In my latest update to Elite Hoops, I replaced several homegrown solutions with Apple’s nascent iOS 17 equivalents. The most prominent example of this is TipKit.

To prompt coaches about a functional user experience feature in Elite Hoops, I had created my own view that worked like this:

Custom tip presenting a tip in Elite Hoops.

It works, but I was not particularly fond of how it looked, or the code I had to maintain to make it work. Any interface code, relying on user path logic combined with caching flags, has a unique propensity to naturally become voluminous — making them less than fun to maintain years later. Let alone the first time — but adding more and more logic to it? Nah, I’d rather not mess with it.

All that to say, when I have that feeling about some code — I always replace it with a baked-in Apple API if one is available.

Thus, TipKit. Here is what it looked like after replacing my own stuff with it:

TipKit presenting a tip in Elite Hoops.

let concatenatedThoughts = """

Plus, plugging in `.blurReplace()` makes it so fun.

"""

In addition to being a familiar user experience to most people (Apple has been using TipKit-esque tips for years), it also makes the code easier to reason about.

Skip the Extra Caching Flag

When using my old method, I had to manually cache the flag to hide or show it:

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

And then later, in the view:

CourtView()
.onFirstAppear {
    0.33.delayThen {
        if appSettings.hasSeenWelcomeToTeamsExplainer && !appSettings.hasSeenPassingTipsChanges {
            appSettings.hasSeenPassingTipsChanges = true
            showTipsPopover.toggle()
        }
    }
}

With TipKit, it all becomes a little more intuitive. I can skip the manual caching layer, because TipKit inherently uses its own. That also helps me worry about one less thing and it keeps the actual tip logic housed within the tip code itself:

import TipKit

struct PassingTip: Tip {
    @Parameter
    static var showPassingTip: Bool = false
    
    var rules: [Rule] {
        [
            #Rule(Self.$showPassingTip) {
                $0 == true
            }
        ]
    }
    
    var title: Text {
        Text("Tip: Easily pass to players")
    }
    
    var message: Text? {
        Text("· Tap twice on a player to pass.\n· Tap and hold on a player for more.")
            
    }
    
    var image: Image? {
        Image(systemName: "lightbulb.circle.fill")
    }
    
}

Which means the original calling site doesn’t even really have to change much:

CourtView()
.onFirstAppear {
    0.33.delayThen {
        if appSettings.hasSeenWelcomeToTeamsExplainer && !PassingTip.showPassingTip {
        	PassingTip.showPassingTip.toggle()
        }
    }
}

Further, they are easier to test without changing the call site (before, I needed to change the call site or nuke UserDefaults, which had a trickle down effect on a bunch of other things I wasn’t testing):

// In EliteHoopsApp

init() {
	try? Tips.resetDatastore()
	try? Tips.configure()
}

Aggregation

While moving over a few other tips, I was pleasantly suprised to see how trivial it was to aggregate tip logic. That is, TipA may have a flag that TipB needs to know about in its conditions to present.

In the same view, I show another tip about an easier way to undo things. But, it shouldn’t present if the previous tip hasn’t shown yet. Sticking all of this into the rules array of the other tip makes it all easy peezy:

struct UndoTip: Tip {
    @Parameter
    static var showUndoRedoTip: Bool = false
    
    var rules: [Rule] {
        [
            #Rule(Self.$showUndoRedoTip) {
                $0 == true
            },
            // References the previous tip above
            #Rule(PassingTip.$showPassingTip) {
                $0 == true
            }
        ]
    }
    
    var title: Text {
        Text("Tip: Easily undo")
    }
    
    var message: Text? {
        Text("· Tap twice with two fingers to undo.")
            
    }
    
    var image: Image? {
        Image(systemName: "arrow.uturn.backward.circle.fill")
    }
    
}

In the end, I made things look a little nicer, removed code I didn’t need to maintain and rested easier knowing I can let Apple handle Tip-related UI code going forward. Typically, I find frameworks from an API design standpoint to either be easy to use but rigid, or they are hard to get started with but extremely flexible.

TipKit is easy to use and it’s flexible. That…that’s the good stuff.

Until next time ✌️.

···

Spot an issue, anything to add?

Reach Out.