iOS 使用 UIMenuController 且不隱藏鍵盤的方法


iOS 使用 UIMenuController 且不隱藏鍵盤的方法

在鍵盤顯示的時候使用 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 action
func 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 load
var 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 = self
menu.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
}

轉載請注明出處:http://www.cnblogs.com/silence-cnblogs/p/6824426.html


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM