在键盘显示的时候使用 UIMenuController 弹出菜单,保持键盘显示且可输入的状态。

实现方法有

  1. 修改响应链(推荐)

  2. 遵循 UIKeyInput 协议

  3. 自定义 Menu controller

前两种方法的代码已上传 GitHub:https://github.com/Silence-GitHub/MenuControllerDemo
第 3 种方法的 GitHub 链接:https://github.com/Silence-GitHub/SWMenuController

在此之前,介绍 UIMenuController 的使用方法,以及键盘会隐藏的原因。

如果只要实现功能,看第 1 种方法的代码就可以,正文基本不用看。如果要理解响应链(Responder chain)相关的原理,先看 Apple 的文档 Understanding Responders and the Responder Chain

UIMenuController 的使用方法

自定义一个需要显示 UIMenuController 的视图,以 UIButton 为例,自定义类 ShowMenuButton

class ShowMenuButton: UIButton {    // Return true so that menu controller can display
    override var canBecomeFirstResponder: Bool { return true }    
    // Return true to show menu for given action
    // Action is in UIResponderStandardEditActions
    override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {        return action == #selector(copy(_:))
    }    
    override func copy(_ sender: Any?) {        print(#function)
    }
}

ShowMenuButton 必须重载 canBecomeFirstResponder 属性,返回 true 才能显示菜单(UIMenuController)。第一响应者(First responder)才能处理菜单,如果 canBecomeFirstResponder 返回 false,不能成为第一响应者,菜单不会显示。

重载 canPerformAction(_:withSender:) 方法,过滤需要显示的菜单按钮(UIMenuItem)。参数 action 有 copy(_:)、paste(_:) 等 UIResponderStandardEditActions 协议的方法。对需要进行的操作返回 true,显示菜单按钮(以上代码显示“Copy”菜单按钮);对不需要的操作返回 false,尝试隐藏菜单按钮(菜单按钮不一定隐藏,如果响应链中有其他响应者返回 true,此菜单按钮仍然会显示)。此方法在默认情况下(没有实现此方法的时候),如果当前类实现了相应的 action,就会返回 true;如果没有实现相应的 action,则调用下一个响应者的此方法。如果不实现此方法(或此方法返回 false),响应链上有响应者也没实现此方法(或此方法返回 true)但实现了 copy(_:) 方法,则“Copy”菜单按钮会显示。建议实现此方法,至少在响应链的这一层控制菜单按钮。

实现与需要显示的菜单按钮对应的 action 方法,以上代码为 copy(_:) 方法。当菜单按钮被点击,action 方法会被发送。如果没有实现 canPerformAction(_:withSender:) 方法,UIKit 会沿着响应链寻找实现 action 的响应者,把 action 方法发给实现 action 的响应者。一旦实现了 canPerformAction(_:withSender:) 方法且返回 true,action 方法就会发送给当前响应者,不会沿着响应链去找实现 action 的响应者,所以必须实现相应的 action 方法。

在控制器(UIViewController)中,让自定义的 ShowMenuButton 监听点击事件

button.addTarget(self, action: #selector(showMenuButtonClicked(_:)), for: .touchUpInside)

点击 button 弹出菜单

@objc private func showMenuButtonClicked(_ button: UIButton) {    // Let button become first responder so that menu can display
    button.becomeFirstResponder()    // Only one UIMenuController instance
    let menu = UIMenuController.shared    // Custom menu item can perform custom action
    let customItem = UIMenuItem(title: "Custom item", action: #selector(customItemDidSelect))    // Set custom menu item
    menu.menuItems = [customItem]    // Sets the area in a view above or below which the editing menu is positioned
    menu.setTargetRect(button.frame, in: view)    // Show menu
    menu.setMenuVisible(true, animated: true)
}// Custom menu item actionfunc customItemDidSelect() {    print(#function)
}

在使用 UIMenuController 之前,使 button 成为第一响应者,菜单才能显示。

控制器没有实现 canPerformAction(_:withSender:) 方法,实现了 customItemDidSelect,从 button 开始沿着响应链可以找到当前控制器,因此自定义菜单按钮可以显示。如果控制器实现 canPerformAction(_:withSender:) 方法且返回 false,则自定义菜单按钮不会显示。

如有需要,隐藏菜单

UIMenuController.shared.setMenuVisible(false, animated: true)

注意,UIMenuController 只有一个实例,隐藏后 menuItems 还保留显示时的值,下次在其他地方显示还会出现旧的自定义菜单按钮,因此要在适当的时候更新 menuItems 属性。

UITextView、UITextField 成为第一响应者(点击输入框,准备输入),键盘会显示。输入框不是第一响应者,键盘会隐藏。由于要显示菜单的自定义控件调用 becomeFirstResponder() 方法,成为第一响应者,则输入框就不是第一响应者,所以键盘隐藏。

不隐藏键盘的方法

修改响应链(推荐)

这是目前最好的方法,代码量最少。可以正常使用 UIMenuController,并且键盘能正常显示、输入,输入框的光标仍然闪烁。

方法思路来自:http://stackoverflow.com/questions/13601643/uimenucontroller-hides-the-keyboard
然而,那些代码还有 bug,这里会解决。既然输入框失去第一响应者,键盘会隐藏,那就让输入框保持第一响应者。通过改变响应链,让菜单事件传递给能处理的响应者。

以 UITextView 为例,自定义类 CustomResponderTextView

class CustomResponderTextView: UITextView {    weak var overrideNext: UIResponder?    
    override var next: UIResponder? {        if let responder = overrideNext { return responder }        return super.next
    }    
    override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {        if overrideNext != nil { return false }        return super.canPerformAction(action, withSender: sender)
    }
}

重载 next 属性,改变响应链。重载 canPerformAction(_:withSender:) 方法,在响应链改变时都返回 false。

控制器的代码需要修改

// Init text view when view did loadvar textView: CustomResponderTextView!@objc private func showMenuButtonClicked(_ button: UIButton) {    if textView.isFirstResponder {        // Change responder chain
        textView.overrideNext = button        // Observe "will hide" to do some cleanup
        // Do not use "did hide" which is not fast enough
        NotificationCenter.default.addObserver(self, selector: #selector(menuControllerWillHide), name: Notification.Name.UIMenuControllerWillHideMenu, object: nil)
    } else {
        button.becomeFirstResponder()
    }    let menu = UIMenuController.shared    let customItem = UIMenuItem(title: "Custom item", action: #selector(customItemDidSelect))
    menu.menuItems = [customItem]
    menu.setTargetRect(button.frame, in: view)
    menu.setMenuVisible(true, animated: true)
}    
func customItemDidSelect() {    print(#function)
}    
@objc private func menuControllerWillHide() {    // Change responder chain back
    textView.overrideNext = nil
    // Prevent custom menu items from displaying in text view
    UIMenuController.shared.menuItems = nil
    // Remove notification observer
    NotificationCenter.default.removeObserver(self, name: Notification.Name.UIMenuControllerWillHideMenu, object: nil)
}

如果 text view 不是第一响应者,键盘没显示,和原来一样。如果 text view 是第一响应者,改变响应链,让输入框的下一个响应者(next)成为 button。菜单要显示哪些按钮,从第一响应者 text view 开始,沿着响应链,通过 canPerformAction(_:withSender:) 方法判断。虽然 text view 的 canPerformAction(_:withSender:) 方法返回 false,但 button 的 canPerformAction(_:withSender:) 方法对 copy(_:) 方法返回 true,所以会显示“Copy”菜单按钮。点击“Copy”菜单按钮,button会执行 copy(_:) 方法。控制器也在这条响应链上,实现了 customItemDidSelect 方法,没实现 canPerformAction(_:withSender:) 方法,则 canPerformAction(_:withSender:) 方法默认对 customItemDidSelect 方法返回 true,所以会显示自定义菜单按钮。点击自定义菜单按钮,控制器会执行 customItemDidSelect 方法。

监听菜单消失,在将要消失时,恢复响应链,清除自定义菜单按钮,移除通知监听。

输入框自己也可以显示菜单。如果先点击 button,然后点击 text view,让 text view 显示菜单,自定义菜单按钮仍然显示。因为还没有监听菜单消失,所以没有清除自定义菜单按钮。因此,监听键盘显示

NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow), name: Notification.Name.UIKeyboardWillShow, object: nil)

在键盘将要显示时清除自定义菜单按钮,在控制器释放前移除通知监听

@objc private func keyboardWillShow() {    // Prevent custom menu item from displaying in text view
    UIMenuController.shared.menuItems = nil}deinit {    NotificationCenter.default.removeObserver(self)
}

遵循 UIKeyInput 协议

这个方法一定会显示键盘,不能隐藏键盘。同时,输入框的光标不闪烁。一般情况下能正常输入,但系统中文输入法只响应部分按键(回车、空格等)。

方法思路来自:http://stackoverflow.com/questions/4282964/becomefirstresponder-without-hiding-keyboard/4284675#4284675
在 GitHub 上也有这个方法的代码示例:https://github.com/jaredsinclair/UIMenuControllerTest
虽然这里会修复那些代码的 bug,但输入框光标不闪烁等问题依然存在。遵循 UIKeyInput 协议的 UIResponder 成为第一响应者,键盘就会弹出。

以 UIButton 为例,自定义类 KeyInputButton

protocol KeyInputButtonDelegate: class {    func keyInputButtonHasText(_ button: KeyInputButton) -> Bool
    func keyInputButton(_ button: KeyInputButton, didInsertText text: String)
    func keyInputButtonDidDeleteBackward(_ button: KeyInputButton)}class KeyInputButton: UIButton, UIKeyInput {    // Return true so that menu controller can display
    override var canBecomeFirstResponder: Bool { return true }    
    // Return true to show menu for given action
    // Action is in UIResponderStandardEditActions
    override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {        return action == #selector(copy(_:))
    }    
    override func copy(_ sender: Any?) {        print(#function)
    }    
    // MARK: - UIKeyInput
    
    weak var delegate: KeyInputButtonDelegate?    
    var hasText: Bool {        if let d = delegate {            return d.keyInputButtonHasText(self)
        }        return false
    }    
    // SOGOU, system English, system emoji input method work
    // System Chinese input method typing some characters dose not call this method (but some characters call, e.g "\n" and " ")
    func insertText(_ text: String) {
        delegate?.keyInputButton(self, didInsertText: text)
    }    
    func deleteBackward() {
        delegate?.keyInputButtonDidDeleteBackward(self)
    }
}

UIKeyInput 协议的方法与键盘输入相关。hasText 方法表示有没有文本。deleteBackward 方法当键盘的删除键点击时调用。insertText(_:) 方法在键盘输入时调用。让控制器成为 button 的 delegate,把这些方法传给 text view (UITextView,不用自定义)

func keyInputButtonHasText(_ button: KeyInputButton) -> Bool {    return textView.hasText
}func keyInputButton(_ button: KeyInputButton, didInsertText text: String) {
    textView.insertText(text)
}func keyInputButtonDidDeleteBackward(_ button: KeyInputButton) {
    textView.deleteBackward()
}

点击显示菜单

@objc private func showMenuButtonClicked(_ button: UIButton) {
    button.becomeFirstResponder()    
    NotificationCenter.default.addObserver(self, selector: #selector(menuControllerWillHide), name: Notification.Name.UIMenuControllerWillHideMenu, object: nil)    
    let menu = UIMenuController.shared    let customItem = UIMenuItem(title: "Custom item", action: #selector(customItemDidSelect))
    menu.menuItems = [customItem]
    menu.setTargetRect(button.frame, in: view)    // Display immediately may disappear soon, so display after a little time
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 
        menu.setMenuVisible(true, animated: true)
    }
}    
func customItemDidSelect() {    print(#function)
}@objc private func menuControllerWillHide() {    // Prevent custom menu items from displaying in text view
    UIMenuController.shared.menuItems = nil
    NotificationCenter.default.removeObserver(self, name: Notification.Name.UIMenuControllerWillHideMenu, object: nil)
}

由于 button 成为第一响应者时键盘一定会显示,所以每次都可以让 button 调用 becomeFirstResponder 方法。

依然要监听菜单消失,清除自定义菜单按钮,移除通知监听。

需要注意的是,UIMenuController 的 setMenuVisible(_:animated:) 方法要延迟调用,否则菜单可能刚出现就消失。

自定义 Menu controller

由于之前尝试其他方法不满意(当时修改响应链的方法还有问题),于是查找自定义的菜单。找到一个:https://github.com/camelcc/MenuPopOverView
自己也写了一个:https://github.com/Silence-GitHub/SWMenuController
以下介绍自己写的 SWMenuController,先看效果图

基本够用,但是和 UIMenuController 还是有差距(例如动画效果、自动调整字体大小等)。

实现原理是,继承 UIView,添加 UIButton 作为菜单按钮,添加到 window 来显示。

与 UIMenuController 相似,但所有菜单按钮都要自定义,传入菜单按钮标题的数组

let menu = SWMenuController()
menu.delegate = selfmenu.menuItems = ["Copy", "Paste", "Select", "Select all", "Look up", "Search", "Delete"]
menu.setTargetRect(frame, in: view)
menu.setMenuVisible(true, animated: true)

实现 SWMenuControllerDelegate 方法,处理第 index 个菜单按钮的点击事件(index 从 0 开始)

func menuController(_ menu: SWMenuController, didSelected index: Int) {    print(menu.menuItems[index])    // Do something for menu at index}