19
votes

Requirement

I have a WKWebView and would like to remove the system menu items (Copy, Define, Share...) from the Edit Menu and present my own.

I am targeting iOS 8 and 9. I am currently testing with the Xcode 7.0.1 simulator (iOS 9) and my iPhone 6 running iOS 9.0.2.

Standard Method Does Not Work

I know the standard way of achieving this is by subclassing WKWebView and implementing -canPerformAction:withSender:. However, I have found that with WKWebView -canPerformAction:withSender: is not being called for the copy: or define: actions. This appears to be a known bug (WKWebView and UIMenuController).

Example app: https://github.com/dwieringa/WKWebViewCustomEditMenuBug

@implementation MyWKWebView

- (BOOL)canPerformAction:(SEL)action withSender:(id)sender
{
    NSLog(@"ACTION: %@", NSStringFromSelector(action));

    if (action == @selector(delete:))
    {
        // adding Delete as test (works)
        return YES;
    }

    // trying to remove everything else (does NOT work for Copy, Define, Share...)
    return NO;
}

- (void)delete:(id)sender
{
    NSLog(@"Delete menu item selected");
}

@end

Output: (note no copy: or define: action)

2015-10-20 12:28:32.864 WKWebViewCustomEditMenuBug[45804:21121480] ACTION: cut:
2015-10-20 12:28:32.865 WKWebViewCustomEditMenuBug[45804:21121480] ACTION: select:
2015-10-20 12:28:32.865 WKWebViewCustomEditMenuBug[45804:21121480] ACTION: selectAll:
2015-10-20 12:28:32.865 WKWebViewCustomEditMenuBug[45804:21121480] ACTION: paste:
2015-10-20 12:28:32.866 WKWebViewCustomEditMenuBug[45804:21121480] ACTION: delete:
2015-10-20 12:28:32.866 WKWebViewCustomEditMenuBug[45804:21121480] ACTION: _promptForReplace:
2015-10-20 12:28:32.866 WKWebViewCustomEditMenuBug[45804:21121480] ACTION: _transliterateChinese:
2015-10-20 12:28:32.867 WKWebViewCustomEditMenuBug[45804:21121480] ACTION: _showTextStyleOptions:
2015-10-20 12:28:32.907 WKWebViewCustomEditMenuBug[45804:21121480] ACTION: _addShortcut:
2015-10-20 12:28:32.908 WKWebViewCustomEditMenuBug[45804:21121480] ACTION: _accessibilitySpeak:
2015-10-20 12:28:32.908 WKWebViewCustomEditMenuBug[45804:21121480] ACTION: _accessibilitySpeakLanguageSelection:
2015-10-20 12:28:32.908 WKWebViewCustomEditMenuBug[45804:21121480] ACTION: _accessibilityPauseSpeaking:
2015-10-20 12:28:32.909 WKWebViewCustomEditMenuBug[45804:21121480] ACTION: makeTextWritingDirectionRightToLeft:
2015-10-20 12:28:32.909 WKWebViewCustomEditMenuBug[45804:21121480] ACTION: makeTextWritingDirectionLeftToRight:

Planned Workaround

My desire now is to completely hide the edit menu and replace it with a custom menu using QBPopupMenu.

My problem is that I have not been able to find a way to hide or disable the standard Edit menu. I have found some suggestions to hide it with [UIMenuController sharedMenuController].menuVisible = NO; on UIMenuControllerWillShowMenuNotification, but I have not been able to get this to work. It has no affect with WillShowMenu. I can hide it in DidShowMenu but by that point it is too late and I get a menu flash.

I have also tried to locate it outside the visible area using [[UIMenuController sharedMenuController] setTargetRect:CGRectMake(0, 0, 1, 1) inView:self.extraView];, but again doing so with WillShowMenu has no affect and with DidShowMenu it is too late.

Experiments available here: https://github.com/dwieringa/WKWebViewEditMenuHidingTest

What am I missing? Is there another way to disable or hide the standard editting menu for WKWebView?

12
Out of curiosity have you filed a bug report with Apple for this?Ryan
@Ryan, no not yet. Thanks for your answer below. I just tried it and I'm still seeing the menu when I long press on text under iOS 9 on my iPhone 6. Have you tried it with WKWebView on iOS? I verified with Web Inspector that the new CSS setting is applied to body.davew
@davew any success with this?Jed Grant
@JedGrant no, I've been living with the standard Editing menu for now. On Nov 19, I briefly tried Ryan's suggestion, but didn't get it to work and haven't had time to dig deeper.davew
iOS 13 beta 1 appears to fix this!Tom Hamming

12 Answers

6
votes

Based on your workaround, I found out that:

-(void)menuWillShow:(NSNotification *)notification
{
    NSLog(@"MENU WILL SHOW");

    dispatch_async(dispatch_get_main_queue(), ^{
        [[UIMenuController sharedMenuController] setMenuVisible:NO animated:NO];
    });

}

Will prevent the menu from flashing 90% of the times.. Still not good enough, but it's another workaround before we find a decent solution.

6
votes

Try making your view controller become first responder and stop it from resigning first responder

- (BOOL)canResignFirstResponder {
    return NO;
}

- (BOOL)canBecomeFirstResponder {
    return YES;
}

https://github.com/dwieringa/WKWebViewEditMenuHidingTest/pull/1

2
votes

I Fixed it after some observation.

In -canPerformAction:withSender: I am returning NO for _share and _define options as I don't need them in my project. It works as expected on selection of word for first time, but shows up the options from second time.

Simple fix: Add [self becomeFirstResponder]; in tapGuesture or Touch delegate methods

-(BOOL)canPerformAction:(SEL)action withSender:(id)sender {
    SEL defineSEL = NSSelectorFromString(@"_define:");
    if(action == defineSEL){
        return NO;
    }

    SEL shareSEL = NSSelectorFromString(@"_share:");
    if(action == shareSEL){
        return NO;
    }
    return YES;
}

// Tap gesture delegate method
- (void)singleTap:(UITapGestureRecognizer *)sender {
    lastTouchPoint = [sender locationInView:self.webView];
    [self becomeFirstResponder]; //added this line to fix the issue//
}
2
votes

Hey guys after spending a hours on it, i found dirty solution with %100 success rate.

Logic is; detect when UIMenuController did shown and update it.

In your ViewController(containing WKWebView) add UIMenuControllerDidShowMenu observer in viewDidLoad() like this;

override func viewDidLoad() {
super.viewDidLoad()
       NotificationCenter.default.addObserver(
                         self,
                         selector: #selector(uiMenuViewControllerDidShowMenu),
                         name: NSNotification.Name.UIMenuControllerDidShowMenu,
                         object: nil)
}

Don't forget to remove observer in deinit.

    deinit {
    NotificationCenter.default.removeObserver(
                       self,
                       name: NSNotification.Name.UIMenuControllerDidShowMenu,
                       object: nil)
    }

And in your selector, update UIMenuController like this:

func uiMenuViewControllerDidShowMenu() {
        if longPress {
            let menuController = UIMenuController.shared
            menuController.setMenuVisible(false, animated: false)
            menuController.update() //You can only call this and it will still work as expected but i also call setMenuVisible just to make sure.
        }
    }

In your ViewController who ever calls the UIMenuController, this method will get called. I am developing browser app so i have also searchBar and user may want to paste text to there. Because of that i detect longPress in my webview and check if UIMenuController is summoned by WKWebView.

This solution will behave like in gif. You can see menu for a second but you can't tap it. You can try to tap it before it fades away but you won't succeed. Please try and tell me your results.

I hope it helps someone.

Cheers.

enter image description here

2
votes

This bug is actually caused by the actions being added in the WKContentView, which is a private class. You could add a UIView extension to work around it like this:

import UIKit

extension UIView {

    open override class func initialize() {
        guard NSStringFromClass(self) == "WKContentView" else { return }

        swizzleMethod(#selector(canPerformAction), withSelector: #selector(swizzledCanPerformAction))
    }

    fileprivate class func swizzleMethod(_ selector: Selector, withSelector: Selector) {
        let originalSelector = class_getInstanceMethod(self, selector)
        let swizzledSelector = class_getInstanceMethod(self, withSelector)
        method_exchangeImplementations(originalSelector, swizzledSelector)
    }

    @objc fileprivate func swizzledCanPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
        return false
    }
}
2
votes

I tried the solution from Stephan Heilner but it didn't compile in Swift 4.

This is my implementation to disable the menuController in a WKWebView that works with Swift 4.

In my WKWebView subclass, I added these property and function :

var wkContentView: UIView? {
    return self.subviewWithClassName("WKContentView")
}


private func swizzleResponderChainAction() {
    wkContentView?.swizzlePerformAction()
}

Then, I added an extension in the same file, but out of the WKWebView subclass :

// MARK: - Extension used for the swizzling part linked to wkContentView (see above)
extension UIView {

    /// Find a subview corresponding to the className parameter, recursively.
    func subviewWithClassName(_ className: String) -> UIView? {

        if NSStringFromClass(type(of: self)) == className {
            return self
        } else {
            for subview in subviews {
                return subview.subviewWithClassName(className)
            }
        }
        return nil
    }

    func swizzlePerformAction() {
        swizzleMethod(#selector(canPerformAction), withSelector: #selector(swizzledCanPerformAction))
    }

    private func swizzleMethod(_ currentSelector: Selector, withSelector newSelector: Selector) {
        if let currentMethod = self.instanceMethod(for: currentSelector),
            let newMethod = self.instanceMethod(for:newSelector) {
            let newImplementation = method_getImplementation(newMethod)
            method_setImplementation(currentMethod, newImplementation)
        } else {
            print("Could not find originalSelector")
        }
    }

    private func instanceMethod(for selector: Selector) -> Method? {
        let classType = type(of: self)
        return class_getInstanceMethod(classType, selector)
    }

    @objc private func swizzledCanPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
        return false
    }
}

And finally, I called the swizzleResponderChainAction() function from the initializer (you can either override the designated initializer, or create a convenience one):

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

    swizzleResponderChainAction()
}

Now, the WKWebView does not crash anymore when using a UIMenuController.

1
votes

pragma mark - WKNavigationDelegate

- (void)webView:(WKWebView *)webView didFinishNavigation:(null_unspecified WKNavigation *)navigation {
    // Add:
    // Disable LongPress and Selection, no more UIMenucontroller
    [self.wkWebView evaluateJavaScript:@"document.documentElement.style.webkitUserSelect='none'" completionHandler:nil];
    [self.wkWebView evaluateJavaScript:@"document.documentElement.style.webkitTouchCallout='none'" completionHandler:nil]; }
1
votes

Here's my final solution, adapted from the solutions posted here. The key is to listen for the UIMenuControllerWillShowMenu notification and then Dispatch.main.async to hide the menu. This seems to do the trick to avoid the flashing menu.

My example uses a UITextField, but it should be easily adapted to a WKWebView.

class NoMenuTextField: UITextField {

    override func didMoveToSuperview() {
        super.didMoveToSuperview()
        if superview == nil {
            deregisterForMenuNotifications()
        } else {
            registerForMenuNotifications()
        }
    }

    func registerForMenuNotifications() {
        NotificationCenter.default.addObserver(forName: Notification.Name.UIMenuControllerWillShowMenu,
                                               object: nil,
                                               queue: OperationQueue.main)
        { _ in
            DispatchQueue.main.async {
                UIMenuController.shared.setMenuVisible(false, animated: false)
                UIMenuController.shared.update()
            }
        }
    }

    func deregisterForMenuNotifications() {
        NotificationCenter.default.removeObserver(self,
                                                  name: Notification.Name.UIMenuControllerWillShowMenu,
                                                  object: nil)
    }
}
1
votes

Subclass WKWebView and override canPerformAction to return false:

class WebView : WKWebView {
    override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
        return false
    }
}
0
votes

One way that I've used is to simply disable the menu using CSS. The CSS property is called -webkit-touch-callout: none;. You can apply it to the top level element and disable it for the whole page or any child element and disable it with more precision. Hope that helps.

0
votes

In iOS 11, I have found a simple solution by an extension of WKWebView. I have not checked to see if this will work in earlier versions of iOS. The following is a simple example with one menu item.

import UIKit
import WebKit

extension WKWebView {

    override open func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
        switch action {
        case #selector(highlightHandler):
            return true
        default:
            return false
        }
    }

    func createEditMenu() { // Should be called once
        let highlight = UIMenuItem(title: "Highlight", action: #selector(highlightHandler))
        menuItems.append(highlight)
        UIMenuController.shared.menuItems = [highlight]
    }

    @objc func highlightHandler(sender: UIMenuItem) {
        print("highlight clicked")
    }
}
0
votes

Swift 5.2

NotificationCenter.default.addObserver(self, selector: #selector(willShowMenu(_:)), name: UIMenuController.willShowMenuNotification, object: nil)

...

@objc private func willShowMenu(_ notification: NSNotification) {
    DispatchQueue.main.async {
        UIView.performWithoutAnimation {
            UIMenuController.shared.hideMenu()
        }
    }
}