Multiple PopoverTip Modifiers in SwiftUI: Persistent Display Glitch
Asked Answered
B

4

4

I've encountered an issue when attempting to add multiple popoverTip modifiers in my SwiftUI code. Regardless of whether there's a specified rule or parameter, the tips begin to constantly appear and disappear. Is this a recognized issue? How can we sequentially display multiple tip popovers on complex views? Even when one tip is invalidated, the glitch persists. Should this be used only for views without any state updates?

Here's a sample code that demonstrates the problem:

import SwiftUI
import TipKit

@main
struct testbedApp: App {
    var body: some Scene {
        WindowGroup {
          ContentView()
        }
    }
  
  init() {
    try? Tips.configure()
  }
}

struct PopoverTip1: Tip {
    var title: Text {
        Text("Test title 1").foregroundStyle(.indigo)
    }

    var message: Text? {
        Text("Test message 1")
    }
}

struct PopoverTip2: Tip {
    var title: Text {
        Text("Test title 2").foregroundStyle(.indigo)
    }

    var message: Text? {
        Text("Test message 2")
    }
}

struct ContentView: View {
    private let timer = Timer.publish(every: 0.001, on: .main, in: .common).autoconnect()
  
    @State private var counter = 1
    
    var body: some View {
        VStack(spacing: 20) {
            Spacer()
            Text("Counter value: \(counter)").popoverTip(PopoverTip1())
            Spacer()
            Text("Counter value multiplied by 2: \(counter * 2)")
                .foregroundStyle(.tertiary)
                .popoverTip(PopoverTip2())
            Spacer()
        }
        .padding()
        .onReceive(timer) { _ in
          counter += 1
        }
    }
}

#Preview {
    ContentView()
}
Binns answered 21/9, 2023 at 15:32 Comment(2)
Maybe it is due to the way you update your ContentView with timer every 0.001 second. When you change your @State var counter your ContentView rebuilds everytime remaking Text and its modifiers including .popoverTipStaceestacey
@malsagov Thank you for pointing that out. However, even when I adjust the timer to update every second, the issue persists. It seems counterintuitive, as it implies I can't update the view's state while presenting the tip. Could there be another potential solution or insight?Binns
L
1

Should this be used only for views without any state updates?

It would seem so. You can certainly get it to work by avoiding updates in the view that is showing the tips.

It works with the following changes:

  • Move the counter to an ObservableObject that is created (but not observed) in the parent view.
  • Factor-out the two Text views to separate views and pass them the wrapped counter to observe.

With just these changes to your original code, it works to the extent that tip 1 is shown every time you restart the app, but tip 2 is never shown. In order that tip 1 is recorded as seen, you need to add a tap gesture that invalidates the tip, as explained in the documentation. Then, the next time you start the app, tip 2 is shown.

Here is your example with all the changes applied:


class PublishedCounter: ObservableObject {
    @Published private var val = 1

    var value: Int {
        val
    }

    func increment() {
        val += 1
    }
}

struct Text1: View {
    @ObservedObject var counter: PublishedCounter

    var body: some View {
        Text("Counter value: \(counter.value)")
    }
}

struct Text2: View {
    @ObservedObject var counter: PublishedCounter

    var body: some View {
        Text("Counter value multiplied by 2: \(counter.value * 2)")
            .foregroundStyle(.tertiary)
    }
}

struct ContentView: View {
    private let counter = PublishedCounter()
    private let timer = Timer.publish(every: 0.001, on: .main, in: .common).autoconnect()
    private var tip1 = PopoverTip1()
    private var tip2 = PopoverTip2()

    var body: some View {
        VStack(spacing: 20) {
            Spacer()
            Text1(counter: counter)
                .popoverTip(tip1)
                .onTapGesture {

                    // Invalidate the tip when someone uses the feature
                    tip1.invalidate(reason: .actionPerformed)
                }
            Spacer()
            Text2(counter: counter)
                .popoverTip(tip2)
                .onTapGesture {

                    // Invalidate the tip when someone uses the feature
                    tip2.invalidate(reason: .actionPerformed)
                }
            Spacer()
        }
        .padding()
        .onReceive(timer) { _ in
            counter.increment()
        }
    }
}

To reset all tips (for testing purposes), add a line to purge the tips to the init function of the app, before Tips.configure():

init() {

    // Purge all TipKit-related data and reset the state of all tips
    try? Tips.resetDatastore()

    try? Tips.configure()
}
Liverwort answered 29/9, 2023 at 16:56 Comment(0)
H
1

It is a bit hacky and fragile but you can use a shared hardcoded Event Rule to present sequential tips.

Each Tip would have a hardcoded count.

struct PopoverTip1: Tip {

    var title: Text {
        Text("Test title 1").foregroundStyle(.indigo)
    }
    var rules: [Rule] {
        #Rule(TipSampleView.tipDidOpen) {
            $0.donations.count == 1
        }
    }
    var message: Text? {
        Text("Test message 1")
    }
}

Then onReceive send a donation.

import SwiftUI
import TipKit
struct TipSampleView: View {
    static let tipDidOpen = Tips.Event(id: "tipDidOpen")
    @State private var timer = Timer.publish(every: 2, on: .main, in: .common).autoconnect()
    
    var body: some View {
        VStack {
            Text("Tip 1")
                .popoverTip(PopoverTip1())
            
            Text("Tip 2")
                .popoverTip(PopoverTip2())
        }
        .task {
            do {
                try Tips.configure([.displayFrequency(.immediate)])
            } catch {
                print(error)
            }
        }
        .onReceive(timer) { timer in
            TipSampleView.tipDidOpen.sendDonation()
            
            if TipSampleView.tipDidOpen.donations.count >= 4{
                try? Tips.resetDatastore()
            }
        }
    }
}

It is a bit tacky because from my testing the hardcoded count has to be "odd"

struct PopoverTip2: Tip {
    static let id: Int = 2
    var title: Text {
        Text("Test title 2").foregroundStyle(.indigo)
    }
    var rules: [Rule] {
        #Rule(TipSampleView.tipDidOpen) {
            $0.donations.count == 3 //Odd
        }
    }

    var message: Text? {
        Text("Test message 2")
    }
}

If they are sequential the "even" get skipped.

I used the documentation for the Tips.Event screen on the Apple website.

https://developer.apple.com/documentation/tipkit/tips/event

The logic behind this solution is that only one tip is eligible to be shown at a time.

It seems like tip kit evaluates what tips to show at every reload of the body and presents/dismisses anything that is eligible. I think this is intentional but a bug report wouldn’t hurt.

Haggle answered 30/9, 2023 at 22:43 Comment(0)
L
0

I've also experienced this and ended up sending donations with a timeout. It's not ideal and can be glitchy, but works for me until a proper solution.

DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
   tipDidOpen.sendDonation()
}
Lizliza answered 25/11, 2023 at 11:41 Comment(0)
G
0

Found a solution! Using .onDisappear and @State, we can add show tips sequentially. This means you can't use .popoverTip, but that's easily solved by using .popover with TipView.

import SwiftUI
import TipKit

struct FirstTip: Tip {
    var title: Text {
        Text("This is the first tip.")
    }
    
    var message: Text? {
        Text("This is the first tip's body.")
    }
}

struct SecondTip: Tip {
    var title: Text {
        Text("This is the second tip.")
    }
    
    var message: Text? {
        Text("This is the second tip's body.")
    }
}

struct ThirdTip: Tip {
    var title: Text {
        Text("This is the third tip.")
    }
    
    var message: Text? {
        Text("This is the third tip's body.")
    }
}

struct ContentView: View {
    // The first tip shouldn't have an @State variable, because it'll show by default.
    @State var showSecondTip = false
    @State var showThirdTip = false
    
    let firstTip = FirstTip()
    let secondTip = SecondTip()
    let thirdTip = ThirdTip()
    
    var body: some View {
        VStack {
            TipView(firstTip)
                .onDisappear {
                    showSecondTip = true
                }
            
            Text("What do you call a factory that only makes mediocre products? A satisfactory.")
                .padding(.bottom)
            
            HStack {
                Button("That was not funny.") {
                    
                }.popover(isPresented: $showSecondTip) {
                    TipView(secondTip)
                        .onDisappear {
                            showThirdTip = true
                        }
                }
                
                Spacer()
                
                Button ("That was funny.") {
                    
                }
                .popover(isPresented: $showThirdTip) {
                    TipView(thirdTip)
                        .onDisappear {
                            // show further tips...
                        }
                }
            }
        }.padding()
    }
}

#Preview {
    ContentView()
        .task {
            try? Tips.resetDatastore()
            
            try? Tips.configure([
                .displayFrequency(.immediate),
                .datastoreLocation(.applicationDefault)
            ])
        }
}

Admittedly, .popover on iOS is just a sheet, but using a custom popover view should get you the result you want. This could also be cleaned up further with custom views and view modifiers, but this is a working solution for me.

Greathouse answered 4/7 at 21:22 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.