在键盘显示的时候使用 UIMenuController 弹出菜单,保持键盘显示且可输入的状态。
实现方法有
修改响应链(推荐)
遵循 UIKeyInput 协议
自定义 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}