Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

WKWebview with the new iOS13 modal crash when a file picker is invoked

I have a webview in a modal view controller on iOS13. When the user tries to upload an image to the webview, it crashes.

This is the exception I'm getting:

2019-09-30 17:50:10.676940+0900 Engage[988:157733] * Terminating app due to uncaught exception 'NSGenericException', reason: 'Your application has presented a UIDocumentMenuViewController (). In its current trait environment, the modalPresentationStyle of a UIDocumentMenuViewController with this style is UIModalPresentationPopover. You must provide location information for this popover through the view controller's popoverPresentationController. You must provide either a sourceView and sourceRect or a barButtonItem. If this information is not known when you present the view controller, you may provide it in the UIPopoverPresentationControllerDelegate method -prepareForPopoverPresentation.' * First throw call stack: (0x18926c98c 0x188f950a4 0x18cb898a8 0x18cb939b4 0x18cb914f8 0x18d283b98 0x18d2737c0 0x18d2a3594 0x1891e9c48 0x1891e4b34 0x1891e5100 0x1891e48bc 0x193050328 0x18d27a6d4 0x1002e6de4 0x18906f460) libc++abi.dylib: terminating with uncaught exception of type NSException

I'm not sure where could I set this delegate...

I made a sample project: https://github.com/ntnmrndn/WKUploadFormCrash And filled a bug report to Apple

like image 779
Antzi Avatar asked Sep 30 '19 09:09

Antzi


4 Answers

As @jshapy8 correctly stated, you need to override the present() method and setting the .sourceView/.sourceFrame/.barButtonItem manually. But you need to keep in mind that in case the UIViewController that holds the WkWebView is presented by a UINavigationController, the UINavigationController is responsible for presenting other UIViewController.

Unless you are on an iPad.

So in fact you need to override the present() method in your UINavigationController as well as in the UIViewController which holds the WkWebView.

In the example below, the UIViewController which holds the WkWebView is called WebVC.

In your UINavigationController you need to add:

  override func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) {
    if let webVC = viewControllers.filter({ $0 is WebVC }).first as? WebVC {
      webVC.setUIDocumentMenuViewControllerSoureViewsIfNeeded(viewControllerToPresent)
    }
    super.present(viewControllerToPresent, animated: flag, completion: completion)
  }

And in your WebVC you need to add:

  override func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) {
    setUIDocumentMenuViewControllerSoureViewsIfNeeded(viewControllerToPresent)
    super.present(viewControllerToPresent, animated: flag, completion: completion)
  }

  func setUIDocumentMenuViewControllerSoureViewsIfNeeded(_ viewControllerToPresent: UIViewController) {
    if #available(iOS 13, *), viewControllerToPresent is UIDocumentMenuViewController && UIDevice.current.userInterfaceIdiom == .phone {
      // Prevent the app from crashing if the WKWebView decides to present a UIDocumentMenuViewController while it self is presented modally.
      viewControllerToPresent.popoverPresentationController?.sourceView = webView
      viewControllerToPresent.popoverPresentationController?.sourceRect = CGRect(x: webView.center.x, y: webView.center.y, width: 1, height: 1)
    }
  }

So you can use the new iOS 13 modal presentation style and upload files without crashing 😃

Edit: This crashing behavior seems to be (another) iOS 13 bug, because this is only a problem on iPhones not on iPads (just tested it on iPads with iOS 12 & 13. It kinda looks like the apple engineers simply forgot that in case the WKWebView is presented with their new modal presentation style, the UIDocumentMenuViewController is presented with UIModalPresentationPopover style, even on phones, which was until iOS 13 simply not the case.

I updated my code so now it sets the .sourceView/.sourceFrame/.barButtonItem only for phone types, because tablet types will be handled by iOS it self correctly.

like image 93
2h4u Avatar answered Oct 18 '22 06:10

2h4u


I also encountered a similar crash.

You may fix it by setting modalPresentationStyle to .fullScreen.

like image 42
tichise Avatar answered Oct 18 '22 06:10

tichise


Details

  • Swift 5.1, Xcode 11.2.1

Solution

import UIKit
import WebKit

protocol WebViewTapGestureRecognizable {
    var lastWebViewTapPosition: CGPoint { get }
}

extension WebViewTapGestureRecognizable where Self: UIViewController {
    func prepareForPresent(_ viewControllerToPresent: UIViewController) {
         if viewControllerToPresent is UIDocumentMenuViewController || viewControllerToPresent is UIDocumentPickerViewController,
            UIDevice.current.userInterfaceIdiom == .phone,
            let popoverPresentationController = viewControllerToPresent.popoverPresentationController {
                popoverPresentationController.sourceView = view
                let origin = (self as WebViewTapGestureRecognizable).lastWebViewTapPosition
                popoverPresentationController.sourceRect = CGRect(origin: origin, size: CGSize(width: 1, height: 1))
        }
    }
}

class WebView: WKWebView {
    private(set) var lastWebViewTapPosition: CGPoint = CGPoint(x: 0, y: 0)

    override init(frame: CGRect, configuration: WKWebViewConfiguration) {
        super.init(frame: frame, configuration: configuration)
        setupGestureRecognizer()
    }

    required init?(coder: NSCoder) { super.init(coder: coder) }
}

extension WebView: WebViewTapGestureRecognizable, UIGestureRecognizerDelegate {

    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
                            shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        return true
    }

    @objc func webViewTapped(_ sender: UITapGestureRecognizer) {
        lastWebViewTapPosition = sender.location(in: superview ?? self)
    }

    private func setupGestureRecognizer() {
        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(webViewTapped(_:)) )
        tapGesture.delegate = self
        addGestureRecognizer(tapGesture)
    }
}

Usage

For UIViewController

import UIKit

class ViewController: UIViewController {
    private weak var webView: WebView!
}

extension ViewController: WebViewTapGestureRecognizable {
    var lastWebViewTapPosition: CGPoint { return webView.lastWebViewTapPosition }
    override open func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) {
        self.prepareForPresent(viewControllerToPresent)
        super.present(viewControllerToPresent, animated: flag, completion: completion)
    }
}

For NavigationController

import UIKit

class NavigationController: UINavigationController {}

extension NavigationController: WebViewTapGestureRecognizable {
    var lastWebViewTapPosition: CGPoint { return (visibleViewController as? WebViewTapGestureRecognizable)?.lastWebViewTapPosition ?? .zero }
    override open func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) {
        self.prepareForPresent(viewControllerToPresent)
        super.present(viewControllerToPresent, animated: flag, completion: completion)
    }
}

Full sample

Do not forget to paste here the solution code

///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

class RootViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .white
        let stackView = UIStackView()
        stackView.axis = .vertical
        view.addSubview(stackView)
        stackView.translatesAutoresizingMaskIntoConstraints = false
        stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
        stackView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true

        var button = UIButton()
        button.setTitle("Present VC", for: .normal)
        button.setTitleColor(.blue, for: .normal)
        button.addTarget(self, action: #selector(buttonTouchedUpInside1), for: .touchUpInside)
        stackView.addArrangedSubview(button)

        button = UIButton()
        button.setTitle("Present NavVC", for: .normal)
        button.setTitleColor(.blue, for: .normal)
        button.addTarget(self, action: #selector(buttonTouchedUpInside2), for: .touchUpInside)
        stackView.addArrangedSubview(button)
    }

    @objc func buttonTouchedUpInside1() {
        present(ViewController(), animated: true, completion: nil)
    }

    @objc func buttonTouchedUpInside2() {
        present(NavigationController(rootViewController: ViewController()), animated: true, completion: nil)
    }
}

///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

class ViewController: UIViewController {

    private weak var webView: WebView!

    override func viewDidLoad() {
        super.viewDidLoad()
        createWebView()
    }

    private func createWebView() {
        let webView = WebView(frame: .zero,
                              configuration: WKWebViewConfiguration())
        view.addSubview(webView)
        self.webView = webView
        //webView.navigationDelegate = self
        webView.translatesAutoresizingMaskIntoConstraints = false
        webView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
        webView.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true
        view.bottomAnchor.constraint(equalTo: webView.bottomAnchor).isActive = true
        view.rightAnchor.constraint(equalTo: webView.rightAnchor).isActive = true

        let urlString = "https://www.w3schools.com/tags/tryit.asp?filename=tryhtml5_input_type_file"
        webView.load(URLRequest(url: URL(string: urlString)!))
    }
}

extension ViewController: WebViewTapGestureRecognizable {
    var lastWebViewTapPosition: CGPoint { return webView.lastWebViewTapPosition }
    override open func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) {
        self.prepareForPresent(viewControllerToPresent)
        super.present(viewControllerToPresent, animated: flag, completion: completion)
    }
}


///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////


import UIKit

class NavigationController: UINavigationController {}

extension NavigationController: WebViewTapGestureRecognizable {
    var lastWebViewTapPosition: CGPoint { return (visibleViewController as? WebViewTapGestureRecognizable)?.lastWebViewTapPosition ?? .zero }
    override open func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) {
        self.prepareForPresent(viewControllerToPresent)
        super.present(viewControllerToPresent, animated: flag, completion: completion)
    }
}

Results

enter image description here

enter image description here

like image 2
Vasily Bodnarchuk Avatar answered Oct 18 '22 04:10

Vasily Bodnarchuk


As the error message states, UIKit is deciding that the UIDocumentMenuController should be a popover, and therefore it expects either the sourceView and sourceFrame or the barButtonItem of that popover to be set (the crash is precisely because those haven't been set).

If you want to keep the iOS 13 modal presentation style without having to revert to just using .fullScreen, in the view controller which embeds the WKWebView you can override present to grab a reference to the UIDocumentMenuViewController and set properties on its popoverPresentationController:

class WebViewController: UIViewController {
    override open func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) {
        if #available(iOS 13, *), viewControllerToPresent is UIDocumentMenuViewController {
            viewControllerToPresent.popoverPresentationController?.barButtonItem = // reference to a bar button item you want to present the popover from
            // OR
            viewControllerToPresent.popoverPresentationController?.sourceView = // whatever view you want to present the popover from
            viewControllerToPresent.popoverPresentationController?.sourceFrame = // that view's frame
        }

        super.present(viewControllerToPresent, animated: flag, completion: completion)
    }
}
like image 1
jshapy8 Avatar answered Oct 18 '22 06:10

jshapy8