2023-12-12

Detect Tip Popover ViewController being dismissed when clicked outside the tip popover - using iOS 17's TipKit with UIKit

I'm implementing the new iOS 17 TipKit to show some tip popovers the first time a user plays the game I'm developing right now, knowing that those tips will only show for users who have upgraded to iOS 17.
Since my app uses UIKit, I've learned most of TipKit's UIKit implementation from Ben Dodson's article and I've updated my code based on Apple's documentation on TipUIPopoverViewController as otherwise I would have been left with a memory leak that Apple's code resolved.

In my main ViewController I want to show the first tip popover and then when that one gets dismissed show the second tip popover on another sourceItem.
If the user dismisses the popover by clicking the X in the corner then everything works correctly and the second popover shows up. But if the user clicks outside of the popover, it will also dismiss, but that isn't caught by my code so the 2nd tip won't show up.
So the question is, how to detect that the tip was dismissed when it was clicked outside of the popover?
With TipKit, a lot of the work is done for you so there's little code for creating that TipUIPopoverViewController itself.
So when researching this issue, since few people have already adopted the new TipKit, I found similar questions being asked for dismissing a regular popover ViewController and I tried to adapt some of the answers I found there, but without success - see below.

Here's the code I have and how I tried to solve this issue.
First I created 2 structs, one for each Tip, the 1st one is called PauseTip and the 2nd one is FreezeTip. In FreezeTip I set a rule with a boolean called pauseTipDisplayed so that that 2nd tip will only show if that boolean is true.
This rule is set up inside the 2nd struct as follows:

struct FreezeTip: Tip {
     
       @available(iOS 17.0, *)
       @Parameter
       static var pauseTipDisplayed: Bool = false
     
       @available(iOS 17.0, *)
       var rules: [Rule] {
           #Rule(Self.$pauseTipDisplayed) { $0 == true }
       }
       [...]
}

So now all I need to do is set pauseTipDisplayed to true when the first tip was dismissed and this 2nd tip will show up.

In my ViewController I added the following properties:

private var pauseTip = PauseTip()
private var pauseTipObservationTask: Task<Void, Never>?
private weak var pauseTipPopoverController: UIViewController?
     
private var freezeTip = FreezeTip()
private var freezeTipObservationTask: Task<Void, Never>?
private weak var freezeTipPopoverController: UIViewController?

Then I wrote 2 functions to handle each tip popover and I'm calling them where needed (I didn't put them in viewDidAppear as shown in the 2 examples I learned from because that would have been too early for my needs).
Here's the 1st function:

@available(iOS 17.0, *)
func handlePauseTip() {
    pauseTipObservationTask = pauseTipObservationTask ?? Task { @MainActor in
        for await shouldDisplay in pauseTip.shouldDisplayUpdates {
            if shouldDisplay {
                let pauseTipContoller = TipUIPopoverViewController(pauseTip, sourceItem: pauseButton)
                pauseTipContoller.popoverPresentationController?.backgroundColor = .purple
                     
                DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
                    self.present(pauseTipContoller, animated: true)
                }
                     
                pauseTipPopoverController = pauseTipContoller
                     
            } else {
                if presentedViewController is TipUIPopoverViewController {
                    dismiss(animated: true)
                    pauseTipPopoverController = nil
                    FreezeMazeTip.pauseTipDisplayed = true  // This will affect the rule to show the 2nd tip popover
                         
                    }
            }
        }
    }
}

You can see that the last row in the else statement is changing the boolean flag to allow showing the 2nd tip popover.
The function for the 2nd tip is identical just using different names, except for setting the boolean flag so I won't copy it again.

Finally, when I deinit the ViewController I run the following or else the whole VC will remain in memory and cause a memory leak:

pauseTipObservationTask?.cancel()
pauseTipObservationTask = nil
        
freezeTipObservationTask?.cancel()
freezeTipObservationTask = nil

As I wrote above, all of this works fine if the user dismisses the first tip by clicking on the X on top of the popover.
But if the user clicks outside the popover and that popover closes, the 2nd tip never shows up.

Here are several things I tried to resolve this issue:

  1. I tried checking isBeingDismissed. I added this check in the else clause at the end of the function so that part would then look like this:
            } else {
                if pauseTipPopoverController != nil {
                    if pauseTipPopoverController!.isBeingDismissed {
                        FreezeTip.pauseTipDisplayed = true  // This will affect the rule to show the 2nd tip popover
                    }
                }
                     
                if presentedViewController is TipUIPopoverViewController {
                    dismiss(animated: true)
                    pauseTipPopoverController = nil
                    FreezeTip.pauseTipDisplayed = true  // This will affect the rule to show the 2nd tip popover
                         
                    }
            }

But that didn't help. Checking with the debugger I found that when the user dismisses by clicking outside the popover, this else statement is never even reached.
Should I put this check elsewhere in my code?

  1. I tried using the following delegate method: presentationControllerDidDismiss. For that to happen I made the following changes. I made my ViewController conform to the following:
class ViewController: UIViewController, UIPopoverPresentationControllerDelegate {
...

Then in viewDidLoad I set the following:

pauseTipPopoverController?.presentationController?.delegate = self

TipUIPopoverViewController has a presentationDelegate, but I wasn't successful to access it because I defined pauseTipPopoverController as a UIViewController and this presentationDelegate requires a TipUIPopoverViewController.
I can't define a TipUIPopoverViewController property or else the whole class will require iOS 17.0 and I can't limit my whole app like that (just having the Tips in iOS 17.0 is ok).

Then I added the following method:

func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
    if #available(iOS 17.0, *) {
        if presentationController is UIPopoverPresentationController {
            FreezeTip.pauseTipDisplayed = true
        }
    } else {
        // Fallback on earlier versions - do nothing
    }
}

Here again, that didn't help and it seems like this method is never called when the user dismisses the popover by clicking outside of it.
Did I declare the delegate incorrectly?

I tried many more things that I researched, but they all gave me constant errors.

I did implement a simple workaround for now. In handlePauseTip I added the following line:

pauseTipContoller.isModalInPresentation = true

That would disable dismissing the popover when clicking outside of it and the only way to dismiss it is to click on the X. Doing so everything works correctly and the 2nd popover shows up right on time. But I think it's a lesser user experience as I wouldn't want to force the user to dismiss one way only.
So I do want to catch it when the user clicks outside of the popup.

Any ideas would be very welcome. Thanks!



No comments:

Post a Comment