TipKit: How can I integrate a Tip with SwiftUI or UIKit?

2k views Asked by At

Now that TipKit has been released by Apple and should be working on Xcode 15 beta 5, I don't know how to integrate a Tip with a view?

I have the following code:

import SwiftUI

struct TipKitTestView: View {
    var body: some View {
        VStack {
            Text("Some filler text")
            UselessTip()
        }
    }
}

struct UselessTip: Tip {
    var title: Text {
        Text("Useless title")
    }
    
    var message: Text {
        Text("Some useless message that is a bit longer than the title.")
    }
}

The compiler does not like me having UselessTip() inside TipKitTestView, giving the error: Static method 'buildExpression' requires that 'UselessTip' conform to 'View'. How can I get the code to compile? I don't know how to make the Tip a View if that makes any sense.

On a side note, what code would make the Tip work within UIKit? I am trying to add Tips to my project with a combination of SwiftUI and UIKit code, so I don't know how to integrate Tips in a project with predominantly UIKit code. Does anyone know how to do that?

2

There are 2 answers

3
Ben Dodson On

Whilst TipKit is predominantly written in SwiftUI, Apple have provided UIKit and AppKit implementations.

To implement a tip in UIKit, you could do something like this:

struct SearchTip: Tip {
    var title: Text {
        Text("Add a new game")
    }
    
    var message: Text? {
        Text("Search for new games to play via IGDB.")
    }
    
    var asset: Image? {
        Image(systemName: "magnifyingglass")
    }
}

class ExampleViewController: UIViewController {
    var searchButton: UIButton
    var searchTip = SearchTip()

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        Task { @MainActor in
            for await shouldDisplay in searchTip.shouldDisplayUpdates {
                if shouldDisplay {
                    let controller = TipUIPopoverViewController(searchTip, sourceItem: searchButton)
                    present(controller)
                } else if presentedViewController is TipUIPopoverViewController {
                    dismiss(animated: true)
                }
            }
        }
    }
}

There is further documentation available from Apple for UIKit implementation via TipUIView, TipUIPopoverViewController, and TipUICollectionViewCell. I've also written an article which goes into how to integrate TipKit with SwiftUI or UIKit.

2
Stuart Breckenridge On

There are a few things you need to do:

  1. In Other Swift Settings in your Build Settings add -external-plugin-path $(SYSTEM_DEVELOPER_DIR)/Platforms/iPhoneOS.platform/Developer/usr/lib/swift/host/plugins#$(SYSTEM_DEVELOPER_DIR)/Platforms/iPhoneOS.platform/Developer/usr/bin/swift-plugin-server

  2. Import TipKit, then in your App's body add a task to configure Tips:

var body: some Scene {
        WindowGroup {
            ContentView()
                .task {
                    try? await Tips.configure()
                }
        }
    }
  1. Create a Tip:
public struct PickNumbersTip: Tip {
    
    @Parameter
    static var hasGeneratedNumbers: Bool = false
    
    public var id: String {
        return "tip.identifier.pick-numbers"
    }
    
    public var title: Text {
        return Text("tip.title.pick-numbers", comment: "Pick Numbers Tip Title")
    }
    
    public var message: Text? {
        return Text("tip.message.pick.numbers", comment: "Pick Numbers Tip Message")
    }
    
    public var asset: Image? {
        return Image(systemName: "hand.tap")
    }
    
    public var actions: [Action] {
        [
            Action(
                id: "action.title.dismiss",
                title: String(localized: "action.title.dismiss", comment: "Dismiss")
            ),
            Action(
                id: "action.title.try-now",
                title: String(localized: "action.title.try-now", comment: "Try Now")
            )
        ]
    }
    
    public var rules: [Rule] {
        #Rule(Self.$hasGeneratedNumbers) { $0 == false } // User has never generated numbers, which makes this tip eligible for display.
    }
    
    public var options: [TipOption] {
        [Tips.MaxDisplayCount(1)]
    }
    
}
  1. Add it to a View:
struct ContentView: View {
    
    @State private var viewModel = ContentViewModel()
    
    private var pickNumbersTip = PickNumbersTip()
    private var generatedNumbersTip = GeneratedNumbersTip()
    
    var body: some View {
        VStack {
            
            HStack {
                ForEach(0..<viewModel.latestNumbers.count, id: \.self) { i in
                    BallView(number: viewModel.latestNumbers[i])
                }
            }
            .popoverTip(generatedNumbersTip, arrowEdge: .top) { action in
                if action.id == "action.title.dismiss" {
                    generatedNumbersTip.invalidate(reason: .userClosedTip)
                }
                if action.id == "action.title.find-out-more" {
                    generatedNumbersTip.invalidate(reason: .userPerformedAction)
                    UIApplication.shared.open(URL(string: "https://developer.apple.com/documentation/gameplaykit/gkrandomdistribution")!)
                }
            }
            
            Spacer()
            Button(action: {
                viewModel.latestNumbers = LottoGenerator.new()
                PickNumbersTip.hasGeneratedNumbers = true
                GeneratedNumbersTip.hasGeneratedNumbers = true
                GeneratedNumbersTip.countOfGeneratedNumbers.donate()
            }, label: {
                Text("button.title.pick-numbers", comment: "Pick Numbers")
            })
            .buttonStyle(.borderedProminent)
            .popoverTip(pickNumbersTip, arrowEdge: .bottom) { action in
                if action.id == "action.title.dismiss" {
                    pickNumbersTip.invalidate(reason: .userClosedTip)
                }
                if action.id == "action.title.try-now" {
                    pickNumbersTip.invalidate(reason: .userPerformedAction)
                    PickNumbersTip.hasGeneratedNumbers = true
                    viewModel.latestNumbers = LottoGenerator.new()
                    GeneratedNumbersTip.hasGeneratedNumbers = true
                    GeneratedNumbersTip.countOfGeneratedNumbers.donate()
                }
            }
        }
        .padding()
        .task {
            for await status in pickNumbersTip.shouldDisplayUpdates {
                print("Pick Numbers Tip Display Eligibility: \(status)")
            }
        }
        .task {
            for await status in generatedNumbersTip.shouldDisplayUpdates {
                print("Generated Numbers Tip Display Eligibility: \(status)")
            }
        }
    }
    
    private struct BallView: View {
        
        var number: Int
        
        var body: some View {
            ZStack {
                Circle()
                    .foregroundStyle(.red)
                
                Text(verbatim: "\(number)")
                    .bold()
                    .fontWidth(.condensed)
                    .foregroundStyle(.white)
            }
        }
    }
    
}

Working sample app available here: https://github.com/stuartbreckenridge/TipKitSample