5

Edit: per the request of the commenters, we've created a small project that you can use to reproduce the issue. https://github.com/Coursicle/ReproducingiOS16PopUpControllerBug. Notice that when you run this project on iOS 15 or below, the pop-up responds to taps. On iOS 16, it does not respond to any taps.

We have a view controller that allows users to set notification preferences for a channel in our app. The view slides up from the bottom of the screen and dims the rest of screen with a semi-transparent background view. In iOS 15 and below, it worked as expected: you could dismiss the view by tapping the background or select a new option and tap "Apply". However, in iOS 16 the view and the semi-transparent background view are unresponsive to any taps.

What's most surprising is that we have nearly identical view controllers (ones that have a semi-transparent background view, that are presented as a popover from the UITabBarController, etc.) which work fine in both iOS 15 and iOS 16. And there doesn't appear to be any significant difference between the working and non-working views in iOS 16.

Things we've tried:

  • Having view controllers other than the UITabBarController present the pop-up.
  • Putting a UIWindow sendEvent breakpoint, which indicates the taps are hitting the correct elements, e.g. <UILabel: 0x133db3e10; frame = (78 0; 208 48); text = 'Every Post'; backgroundColor = UIExtendedGrayColorSpace 0 0; layer = <_UILabelLayer: 0x60000233c0a0>.
  • Inspecting the view hierarchy visually, which indicates that the view and the background are top-most and thus should be responding to events. Here's the image.
  • Explicitly setting isUserInteractionEnabled to true for various elements on the view and on the background view.

Here's what it looks like: Visual example

Here's the code that creates and displays the popup:

let rootController = UIApplication.shared.windows.first!.rootViewController as! CustomTabBar
let popover = NotificationSettingsPopUpController(customTabBarController: rootController, delegateController: self)
popover.presentationController?.delegate = self
popover.displayFilterPopUp()

Here's the code for the pop-up:

class NotificationSettingsPopUpController: UIViewController {
    
    var customTabBarController : CustomTabBar
    let backgroundView : UIView = UIView()
    let filterPopUpViewSlideDuration = 0.2
    var filterPopUpViewHeight = CGFloat(0.52*Double(deviceScreenHeight))
    let filterPopUpViewWidth = CGFloat(deviceScreenWidth)
    let filterPopUpViewRadius = CGFloat(10.0)
    let filterOptionsContainer = UIStackView()
    
    // These variables will be used to handle the pan gesture
    // to drag the class filter view on and off screen.
    lazy var startingPosition = filterPopUpViewHeight*2.3
    lazy var finalPosition = filterPopUpViewHeight*1.5
    lazy var turningPointToShow = finalPosition*1.4
    lazy var turningPointToHide = finalPosition*1.2
    
    // Header Sizing Variables
    let headerLabel : UILabel = UILabel()
    var headerLabelTopMargin : CGFloat = 28
    let headerLabelSideMargin : CGFloat = 20
    
    // Header Sizing Variables
    let descriptionLabel : UILabel = UILabel()
    var descriptionLabelTopMargin : CGFloat = 45
    let descriptionLabelSideMargin : CGFloat = 40
    
    // Apply Button Variables
    let applyButton = UIButton()
    var applyButtonWidth: CGFloat = CGFloat(deviceScreenWidth)*0.85
    var applyButtonHeight: CGFloat = 45
    var applyButtonFontSize: CGFloat = 18
    let applyButtonColor = UIColor.init(hex: "#207af3")
    let applyButtonColorLight = UIColor.init(hex: "#3686f3")
    
    // Sort option buttons
    let everyPostOption : MenuOptionsView = MenuOptionsView()
    let topDailyOption : MenuOptionsView = MenuOptionsView()
    let topWeeklyOption : MenuOptionsView = MenuOptionsView()
    let topMonthlyOption : MenuOptionsView = MenuOptionsView()
    let neverOption : MenuOptionsView = MenuOptionsView()
    
    var currentlySelectedOption : String
    
    // Delegate Controller
    var delegateController : PostsInChannelViewController
    
    init(customTabBarController: CustomTabBar, delegateController: PostsInChannelViewController){
        
        // Set the initial sort option
        self.currentlySelectedOption = "Top Weekly Post"
        if let settings = getSettingsForChannel(delegateController.channel.id){
            if settings.keys.contains("notificationPreference"){
                self.currentlySelectedOption = settings["notificationPreference"] ?? "Top Weekly Post"
            }
        }
        
        // set delegate controller
        self.delegateController = delegateController
        
        // set viewToReturnTo so the correct view is displayed
        // once the class filter view is dismissed
        self.customTabBarController = customTabBarController
        super.init(nibName: nil, bundle: nil)
        
        // add class filter view and background to the window
        customTabBarController.view.addSubview(backgroundView)
        customTabBarController.view.addSubview(view)
        
        // display translucent black backdrop to hide classes view
        // when class filter view is being displayed
        backgroundView.backgroundColor = UIColor(hex: "#000000", alpha: 0)
        backgroundView.frame = CGRect(x: 0, y: -deviceScreenHeight, width: deviceScreenWidth, height: deviceScreenHeight)
        
        // adjust layout variables based on screen size
        if UIDevice().screenType == .iPhones_6_6s_7_8 || UIDevice().screenType == .iPhone_12Mini {
            filterPopUpViewHeight = CGFloat(0.55*Double(deviceScreenHeight))
            startingPosition = filterPopUpViewHeight*2.3
            finalPosition = filterPopUpViewHeight*1.5
            turningPointToShow = finalPosition*1.4
            turningPointToHide = finalPosition*1.2
        }
        
        if UIDevice().screenType == .iPhones_6_6s_7_8{
            filterPopUpViewHeight = CGFloat(0.65*Double(deviceScreenHeight))
        }
        
        if UIDevice().screenType == .iPhone_XSMax_ProMax || UIDevice().screenType == .iPhone_12ProMax{
            filterPopUpViewHeight = CGFloat(0.47*Double(deviceScreenHeight))
        }
        
        // position class filter view off-screen initially (tried to do this
        // with layout anchors but seemed more complicated than it was
        // worth - could try doing it again if we find it's necessary)
        view.frame = CGRect(x: 0, y: CGFloat(deviceScreenHeight), width: filterPopUpViewWidth, height: filterPopUpViewHeight)
        view.layer.cornerRadius = filterPopUpViewRadius
        view.backgroundColor = .white
    }
    
    // add gesture recognizers so that when user taps outside of
    // class filter view or swipes down, the class filter view is dismissed
    override func viewDidLoad() {
        
        backgroundView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(dismissMyself)))
        
        // Set up the header label
        view.addSubview(headerLabel)
        headerLabel.text = "Notify me"
        headerLabel.font = .systemFont(ofSize: 24, weight: .bold)
        headerLabel.sizeToFit()
        if (UIDevice().screenType == .iPhone_XSMax_ProMax || UIDevice().screenType == .iPhones_X_XS_12MiniSimulator || UIDevice().screenType == .iPhone_XR_11) && iOSIsOld {
            headerLabelTopMargin += 15
        }
        headerLabel.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            headerLabel.leftAnchor.constraint(equalTo: view.leftAnchor, constant: headerLabelSideMargin),
            headerLabel.topAnchor.constraint(equalTo: view.topAnchor, constant: headerLabelTopMargin),
        ])
        
        setUpFilterOptions()
        
        setUpApplyButton()
    }
    
    func setUpApplyButton() {
        // Create apply now button
        applyButton.translatesAutoresizingMaskIntoConstraints = false
        applyButton.layer.cornerRadius = applyButtonHeight/2
        
        if(UIDevice().screenType == .iPhones_5_5s_5c_SE){applyButtonFontSize -= 4}
        applyButton.titleLabel?.font = UIFont.systemFont(ofSize: applyButtonFontSize, weight: UIFont.Weight.regular)
        applyButton.setTitle("Apply", for: .normal)
        applyButton.setTitleColor(UIColor.white, for: .normal)
        applyButton.backgroundColor = applyButtonColor
        
        // Add gesture recognizer for the apply button
        applyButton.addTarget(self, action: #selector(applyButtonTapped), for: .touchUpInside)
        applyButton.addTarget(self, action: #selector(applyButtonTouchedDown), for: .touchDown)

        // Add apply button to the view and set position
        view.addSubview(applyButton)
        NSLayoutConstraint.activate([
            applyButton.topAnchor.constraint(equalTo: filterOptionsContainer.bottomAnchor, constant: 22),
            applyButton.widthAnchor.constraint(equalToConstant: applyButtonWidth),
            applyButton.heightAnchor.constraint(equalToConstant: applyButtonHeight),
            applyButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
        ])
    }

    func setUpFilterOptions() {
        
        filterOptionsContainer.translatesAutoresizingMaskIntoConstraints = false
        filterOptionsContainer.axis  = NSLayoutConstraint.Axis.vertical
        filterOptionsContainer.distribution  = UIStackView.Distribution.fillEqually
        filterOptionsContainer.alignment = UIStackView.Alignment.center
        filterOptionsContainer.spacing = 5
        
        view.addSubview(filterOptionsContainer)
        
        filterOptionsContainer.addArrangedSubview(everyPostOption)
        filterOptionsContainer.addArrangedSubview(topDailyOption)
        filterOptionsContainer.addArrangedSubview(topWeeklyOption)
        filterOptionsContainer.addArrangedSubview(topMonthlyOption)
        filterOptionsContainer.addArrangedSubview(neverOption)
        
        everyPostOption.iconView.text = String.fontAwesomeIcon(name: .bell)
        topDailyOption.iconView.text = String.fontAwesomeIcon(name: .bell)
        topWeeklyOption.iconView.text = String.fontAwesomeIcon(name: .bell)
        topMonthlyOption.iconView.text = String.fontAwesomeIcon(name: .bell)
        neverOption.iconView.text = String.fontAwesomeIcon(name: .bell)
        
        everyPostOption.infoLabel.text = "Every Post"
        topDailyOption.infoLabel.text = "Top Daily Post"
        topWeeklyOption.infoLabel.text = "Top Weekly Post"
        topMonthlyOption.infoLabel.text = "Top Monthly Post"
        neverOption.infoLabel.text = "Never"
        
        everyPostOption.addTarget(self, action: #selector(selectNewSortOption(_:)), for: .touchUpInside)
        topDailyOption.addTarget(self, action: #selector(selectNewSortOption(_:)), for: .touchUpInside)
        topWeeklyOption.addTarget(self, action: #selector(selectNewSortOption(_:)), for: .touchUpInside)
        topMonthlyOption.addTarget(self, action: #selector(selectNewSortOption(_:)), for: .touchUpInside)
        neverOption.addTarget(self, action: #selector(selectNewSortOption(_:)), for: .touchUpInside)
        
        switch(currentlySelectedOption) {
        case "Every Post":
            everyPostOption.backgroundColor = UIColor.init(hex: "#F2F2F2")
            everyPostOption.checkmarkIconView.isHidden = false
        case "Top Daily Post":
            topDailyOption.backgroundColor = UIColor.init(hex: "#F2F2F2")
            topDailyOption.checkmarkIconView.isHidden = false
        case "Top Weekly Post":
            topWeeklyOption.backgroundColor = UIColor.init(hex: "#F2F2F2")
            topWeeklyOption.checkmarkIconView.isHidden = false
        case "Top Monthly Post":
            topMonthlyOption.backgroundColor = UIColor.init(hex: "#F2F2F2")
            topMonthlyOption.checkmarkIconView.isHidden = false
        case "Never":
            neverOption.backgroundColor = UIColor.init(hex: "#F2F2F2")
            neverOption.checkmarkIconView.isHidden = false
        default:
            assert(false)
            return
        }

        NSLayoutConstraint.activate([
            filterOptionsContainer.topAnchor.constraint(equalTo: headerLabel.bottomAnchor, constant: 20),
            filterOptionsContainer.centerXAnchor.constraint(equalTo: view.centerXAnchor),
        ])
    }
    
    
    @objc func applyButtonTapped(_ sender: UIButton) {
        
        // Store the preference in user defaults so that
        // we can display what setting they have next time they open it
        storeSettingsForChannel(settingName: "notificationPreference", settingValue: currentlySelectedOption, channel: delegateController.channel.id)
        
        // send the preference to the server
        if let uuid = getUUID(){
            setUserChannelNotificationPreference(uuid: uuid, channelID: delegateController.channel.id, preference: currentlySelectedOption)
        }
        
        // Dismiss Filter modal screen
        dismissMyself()
    }
    
    @objc func applyButtonTouchedDown(_ sender: UIButton){
        sender.backgroundColor = applyButtonColorLight
    }
    
    @objc func selectNewSortOption(_ sender: MenuOptionsView) {
        everyPostOption.backgroundColor = .white
        topDailyOption.backgroundColor = .white
        topWeeklyOption.backgroundColor = .white
        topMonthlyOption.backgroundColor = .white
        neverOption.backgroundColor = .white
    
        everyPostOption.checkmarkIconView.isHidden = true
        topDailyOption.checkmarkIconView.isHidden = true
        topWeeklyOption.checkmarkIconView.isHidden = true
        topMonthlyOption.checkmarkIconView.isHidden = true
        neverOption.checkmarkIconView.isHidden = true
        
        sender.backgroundColor = UIColor.init(hex: "#F2F2F2")
        sender.checkmarkIconView.isHidden = false
        
        currentlySelectedOption = sender.infoLabel.text ?? "Top Weekly Post"
    }
    
    // this function sets up and displays the class filter view -
    // this gets called from the home view controller
    func displayFilterPopUp() {
        backgroundView.frame = CGRect(x: 0, y: 0, width: deviceScreenWidth, height: deviceScreenHeight)
        // slide in class filter view with animation
        UIView.animate(withDuration: filterPopUpViewSlideDuration, delay: 0, options: .curveEaseOut, animations: {
            self.backgroundView.backgroundColor = UIColor(hex: "#000000", alpha: 0.5)
            self.view.frame = CGRect(x: 0, y: CGFloat(deviceScreenHeight)-self.filterPopUpViewHeight, width: self.filterPopUpViewWidth, height: self.filterPopUpViewHeight)
        })
    }
    
    // This function slides the class filter view off-screen and fades out the backgroundView
    override func dismissMyself() {
        UIView.animate(withDuration: filterPopUpViewSlideDuration, animations: {
            self.backgroundView.alpha = 0
            self.view.frame = CGRect(x: 0, y: CGFloat(deviceScreenHeight), width: self.filterPopUpViewWidth, height: self.filterPopUpViewHeight)
            
            self.delegateController.headerLabel.textColor = .black
            self.delegateController.headerCaret.textColor = .black
        }, completion : { finished in
            self.backgroundView.frame = CGRect(x: 0, y: deviceScreenHeight, width: deviceScreenWidth, height: deviceScreenHeight)
            self.view.removeFromSuperview()
        })
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}
monstermac77
  • 256
  • 3
  • 24
  • There is no SwiftUI here at all. Delete that tag please. – matt Aug 07 '22 at 18:24
  • Untappable views are usually caused by a view being outside the bounds of a superview. – matt Aug 07 '22 at 18:30
  • @matt how can I ensure I'm initializing the view controller (and its children views) inside the bounds of its superview? – monstermac77 Aug 07 '22 at 18:37
  • Inside the modal pop-up function, I printed `self.backgroundView.frame` and `self.backgroundView.superview?.bounds` and they're identical. I also tested `self.backgroundView.superview!.bounds.intersection(self.backgroundView.frame)` and they fully intersect. – monstermac77 Aug 07 '22 at 18:54
  • @monstermac77 Did u check your view hierarchy when u present you bottomsheet controller? – Muhammad Hasan Irshad Aug 13 '22 at 09:49
  • @MuhammadHasanIrshad is that not this? https://imgur.com/a/eyAijA5 – monstermac77 Aug 13 '22 at 15:52
  • 3
    Too many dependencies missed - needed either MRE or access to project to debug. – Asperi Aug 13 '22 at 16:15
  • @monstermac77 Yes. I need access to your project now. – Muhammad Hasan Irshad Aug 16 '22 at 11:36
  • @Asperi how do I provide access to the project in a secure way? We don't want our entire codebase leaked. – monstermac77 Aug 16 '22 at 20:45
  • provide the code such that others can reproduce the issue. do not need the whole codebase – udi Aug 17 '22 at 01:59
  • @Asperi and others, we've just created a small repository (link at the top of the post) that you can use to reproduce the issue. – monstermac77 Aug 17 '22 at 15:15
  • @matt I believe you may be interested in the Github link we posted above, using it we were able to isolate the issue to iOS 16 (it runs fine on iOS 15 and earlier). – monstermac77 Aug 17 '22 at 15:22
  • @monstermac77 Supercool. You'll also be able to submit that to Apple as part of the bug report. – matt Aug 17 '22 at 15:24
  • @matt Just did! Hopefully they'll be able to fix it before release or someone here will at least be able to find a workaround. I'd imagine this is going to affect a lot more developers than us. We're probably not alone in thinking something like this could crop up in a new iOS. – monstermac77 Aug 17 '22 at 15:44
  • @matt given your background, figured you'd be interested in Asperi's answer. Just pinging you here to make sure you see it :) – monstermac77 Aug 17 '22 at 16:18
  • Right, so, similar to my answer here: https://stackoverflow.com/a/50436583/341994 But it is not clear to me why the issue did not manifest in earlier versions. – matt Aug 17 '22 at 22:49

1 Answers1

4

The fix is to add popover controller to controllers hierarchy

let popover = FlagPopUpController(delegateController: self)
popover.presentationController?.delegate = self
popover.displayFlagPopUp()
self.addChild(popover)              // << here !!
popover.didMove(toParent: self)     // << here !!

Tested with Xcode 14b5 / iOS 16

Asperi
  • 228,894
  • 20
  • 464
  • 690
  • That was it! If you get a chance, could you explain what the issue was and why it started happening in iOS 16? – monstermac77 Aug 17 '22 at 16:15
  • According to doc: "This relationship is necessary when embedding the child view controller’s view into the current view controller’s content... This method is only intended to be called by an implementation of a custom container (!!!) view controller.", but why it was allowed before ... is to Apple. – Asperi Aug 17 '22 at 16:29
  • it was my issue as well Xcode 14.1 / iOS 16 Container in the first init view controller :) Thank you sir! – Anthony Marchenko Nov 15 '22 at 18:21