1. 程式人生 > >android 使用 InputConnection 監聽並攔截軟鍵盤的退格鍵

android 使用 InputConnection 監聽並攔截軟鍵盤的退格鍵

之前在做專案的時候有個需求是監聽使用者點選軟鍵盤的退格鍵並在必要的時候攔截這個點選事件,以便在輸入框刪除文字的時候實現一些特殊的功能。當時我所能想到的常規方法是使用View.setOnKeyListener( View.OnKeyListener ll)方法,監聽EditText上的key event:

editText.setOnKeyListener(new View.OnKeyListener() {
            @Override
            public boolean onKey(View v, int keyCode, KeyEvent event) {
               if
(keyCode == KeyEvent.KEYCODE_DEL && event.getAction() == KeyEvent.ACTION_DOWN){ if(isIntercepted/*是否攔截退格鍵事件*/){ return true; } } return false; } });

這個方案在搜狗輸入法上是沒有問題的,但是在谷歌輸入法上卻無效,即在谷歌輸入法上點選退格鍵,這個監聽器的onKey()方法不會被回撥的,當時很納悶於是看了這個方法的註釋:

    /**
     * Register a callback to be invoked when a hardware key is pressed in this view.
     * Key presses in software input methods will generally not trigger the methods of
     * this listener.
     * @param l the key listener to attach to this view
     */
    public void setOnKeyListener
(OnKeyListener l) { getListenerInfo().mOnKeyListener = l; }

這段註釋的大概意思是:該方法可為View 註冊一個 按鍵的監聽器,用於讓View監聽實體鍵的各種點選事件,通常點選虛擬鍵不會觸發這個監聽器的回撥方法。然而軟鍵盤上的按鍵也是虛擬鍵,為何搜狗輸入法會觸發這個回撥呢?我又看了這個OnKeyListener 介面的註釋:

    /**
     * Interface definition for a callback to be invoked when a hardware key event is
     * dispatched to this view. The callback will be invoked before the key event is
     * given to the view. This is only useful for hardware keyboards; a software input
     * method has no obligation to trigger this listener.
     */
    public interface OnKeyListener {
        /**
         * Called when a hardware key is dispatched to a view. This allows listeners to
         * get a chance to respond before the target view.
         * <p>Key presses in software keyboards will generally NOT trigger this method,
         * although some may elect to do so in some situations. Do not assume a
         * software input method has to be key-based; even if it is, it may use key presses
         * in a different way than you expect, so there is no way to reliably catch soft
         * input key presses.
         *
         * @param v The view the key has been dispatched to.
         * @param keyCode The code for the physical key that was pressed
         * @param event The KeyEvent object containing full information about
         *        the event.
         * @return True if the listener has consumed the event, false otherwise.
         */
        boolean onKey(View v, int keyCode, KeyEvent event);
    }

這注釋很長,大概意思就是說這個監聽器是用於監聽實體鍵的key event的,雖然輸入法也可以發出key event,但是這種事是看緣分的。比如搜狗輸入法就是基於keyEvent和EditText互動的,但谷歌輸入法就不會發出keyEvent來告知EditText有輸入事件,所以用這個監聽器來監聽軟鍵盤的輸入和點選事件是不靠譜的!

那谷歌輸入是如何和輸入框互動的呢?這個時候就要提到一個類 InputConnection,這個類的註釋是這樣的:

 The InputConnection interface is the communication channel from an
 {@link InputMethod} back to the application that is receiving its
 input. It is used to perform such things as reading text around the
 cursor, committing text to the text box, and sending raw key events
 to the application.

大概意思就是:InputConnection 是輸入法和應用內View(通常是EditText)互動的通道,輸入法的文字輸入和刪改事件,包括key event事件都是通過InputConnection傳送給EditText。示意圖如下:
這裡寫圖片描述

InputConnection有幾個關鍵方法,通過重寫這幾個方法,我們基本可以攔截軟鍵盤的所有輸入和點選事件:

//當輸入法輸入了字元,包括表情,字母、文字、數字和符號等內容,會回撥該方法
public boolean commitText(CharSequence text, int newCursorPosition) 

//當有按鍵輸入時,該方法會被回撥。比如點選退格鍵時,搜狗輸入法應該就是通過呼叫該方法,
//傳送keyEvent的,但谷歌輸入法卻不會呼叫該方法,而是呼叫下面的deleteSurroundingText()方法。  
public boolean sendKeyEvent(KeyEvent event);   

//當有文字刪除操作時(剪下,點選退格鍵),會觸發該方法 
public boolean deleteSurroundingText(int beforeLength, int afterLength) 

//結束組合文字輸入的時候,回撥該方法
public boolean finishComposingText();

那麼,假如實現了一個 InputConnection子類,該如何傳遞給EditText使用呢?在EditText和輸入法建立連線的時候,EditText的onCreateInputConnection()方法會被觸發:

    /**
     * Create a new InputConnection for an InputMethod to interact
     * with the view.  The default implementation returns null, since it doesn't
     * support input methods.  You can override this to implement such support.
     * This is only needed for views that take focus and text input.
     *
     * <p>When implementing this, you probably also want to implement
     * {@link #onCheckIsTextEditor()} to indicate you will return a
     * non-null InputConnection.</p>
     *
     * <p>Also, take good care to fill in the {@link android.view.inputmethod.EditorInfo}
     * object correctly and in its entirety, so that the connected IME can rely
     * on its values. For example, {@link android.view.inputmethod.EditorInfo#initialSelStart}
     * and  {@link android.view.inputmethod.EditorInfo#initialSelEnd} members
     * must be filled in with the correct cursor position for IMEs to work correctly
     * with your application.</p>
     *
     * @param outAttrs Fill in with attribute information about the connection.
     */
    public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
        return null;
    }

註釋表明:當輸入法要和指定View建立連線的時候,系統會通過該方法返回一個InputConnection 例項給輸入法。所以我們要複寫EditText的這個方法,返回我們自己的InputConnection 。但實際上EditText的父類TextView已經複寫該方法了,並返回了一個 EditableInputConnection 例項,這個類是隱藏的,而且是專門用來連線文字框和輸入法的,如果我們要複寫一個InputConnection,那麼就要完完全全地把EditableInputConnection 功能給照搬下來,否則EditText功能無法正常使用,這成本太高了而且也不好維護。
所幸 android 提供了InputConnection 的代理類,

/**
 * <p>Wrapper class for proxying calls to another InputConnection.  Subclass and have fun!
 */
public class InputConnectionWrapper implements InputConnection {
    private InputConnection mTarget;
    final boolean mMutable;
    @InputConnectionInspector.MissingMethodFlags
    private int mMissingMethodFlags;

    ....

     public boolean deleteSurroundingText(int beforeLength, int afterLength) {
        return mTarget.deleteSurroundingText(beforeLength, afterLength);
    }

     public boolean commitText(CharSequence text, int newCursorPosition) {
        return mTarget.commitText(text, newCursorPosition);
    }

     public boolean sendKeyEvent(KeyEvent event) {
        return mTarget.sendKeyEvent(event);
    }    
}

通過這個實現這個代理類,我們就既可以保留EditableInputConnection 的功能,又可以實現對輸入事件的監聽,示意圖如下:
這裡寫圖片描述

實現程式碼如下(已攔截退格鍵為例):

package iel.tzy.watcher;

import android.view.KeyEvent;
import android.view.View;
import android.view.inputmethod.InputConnection;
import android.view.inputmethod.InputConnectionWrapper;

/**
 * Created by tu zhen yu on 2017/12/1.
 * {@link InputConnection} 是輸入法和View互動的紐帶。
 * {@link InputConnectionWrapper} 是 InputConnection 的代理類,可以代理EditText的InputConnection,監聽和攔截軟鍵盤的各種輸入事件。
 * 注:用 {@link View#setOnKeyListener(View.OnKeyListener)} 監聽軟鍵盤的按鍵點選事件對有些鍵盤無效(比如谷歌輸入法),
 * 最好用InputConnection去監聽。
 */

public class TInputConnection extends InputConnectionWrapper {

    private BackspaceListener mBackspaceListener;

    /**
     * Initializes a wrapper.
     * <p>
     * <p><b>Caveat:</b> Although the system can accept {@code (InputConnection) null} in some
     * places, you cannot emulate such a behavior by non-null {@link InputConnectionWrapper} that
     * has {@code null} in {@code target}.</p>
     *
     * @param target  the {@link InputConnection} to be proxied.
     * @param mutable set {@code true} to protect this object from being reconfigured to target
     *                another {@link InputConnection}.  Note that this is ignored while the target is {@code null}.
     */
    public TInputConnection(InputConnection target, boolean mutable) {
        super(target, mutable);
    }

    public interface BackspaceListener {
        /**
         * @return true 代表消費了這個事件
         * */
        boolean onBackspace();
    }

    /**
     * 當軟鍵盤刪除文字之前,會呼叫這個方法通知輸入框,我們可以重寫這個方法並判斷是否要攔截這個刪除事件。
     * 在谷歌輸入法上,點選退格鍵的時候不會呼叫{@link #sendKeyEvent(KeyEvent event)},
     * 而是直接回調這個方法,所以也要在這個方法上做攔截;
     * */
    @Override
    public boolean deleteSurroundingText(int beforeLength, int afterLength) {
            if(mBackspaceListener != null){
                if(mBackspaceListener.onBackspace()){
                 return true;
                }
            }

        return super.deleteSurroundingText(beforeLength, afterLength);
    }

    public void setBackspaceListener(BackspaceListener backspaceListener) {
        this.mBackspaceListener = backspaceListener;
    }

    /**
     * 當在軟體盤上點選某些按鈕(比如退格鍵,數字鍵,回車鍵等),該方法可能會被觸發(取決於輸入法的開發者),
     * 所以也可以重寫該方法並攔截這些事件,這些事件就不會被分發到輸入框了
     * */
    @Override
    public boolean sendKeyEvent(KeyEvent event) {
        if( event.getKeyCode() == KeyEvent.KEYCODE_DEL && event.getAction() == KeyEvent.ACTION_DOWN){
            if(mBackspaceListener != null && mBackspaceListener.onBackspace()){
                return true;
            }
        }
        return super.sendKeyEvent(event);
    }
}

在EditText上要複寫onCreateInputConnection()方法:

package iel.tzy.watcher;

import android.content.Context;
import android.util.AttributeSet;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;

/**
 * Created by tuzhenyu on 2017/12/21.
 */

public class TEditText extends android.support.v7.widget.AppCompatEditText {

    private TInputConnection inputConnection;

    public TEditText(Context context) {
        super(context);
        init();
    }

    public TEditText(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public TEditText(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private void init(){
        inputConnection = new TInputConnection(null,true);
    }

    /**
     * 當輸入法和EditText建立連線的時候會通過這個方法返回一個InputConnection。
     * 我們需要代理這個方法的父類方法生成的InputConnection並返回我們自己的代理類。
     * */
    @Override
    public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
        inputConnection.setTarget(super.onCreateInputConnection(outAttrs));
        return inputConnection;
    }

    public void setBackSpaceLisetener(TInputConnection.BackspaceListener backSpaceLisetener){
        inputConnection.setBackspaceListener(backSpaceLisetener);
    }

我還在TInputConnection 中定義了一個監聽器:

public interface BackspaceListener {
        /**
         * @return true 代表消費了這個事件
         * */
        boolean onBackspace();
    }

呼叫者可通過註冊這監聽器,處理退格鍵的點選事件:

TEditText.setBackSpaceLisetener(TInputConnection.BackspaceListener ll)

主要程式碼都寫完了,現在可以結合一個場景來使用。假設產品有個奇葩需求,要求在編輯框中,不能通過空格退格鍵刪除 “@”字元,那麼我們可以這樣實現:

package iel.tzy;

import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.text.Editable;
import android.view.Gravity;
import android.widget.Toast;

import iel.tzy.watcher.TEditText;
import iel.tzy.watcher.TInputConnection;

public class MainActivity extends AppCompatActivity {

    private TEditText editText;
    TInputConnection.BackspaceListener backspaceListener = new TInputConnection.BackspaceListener() {
        @Override
        public boolean onBackspace() {
            Editable editable = editText.getText();

            if(editable.length() == 0){
                return false;
            }

            int index = Math.max(0,editText.getSelectionStart() - 1);

            if(editable.charAt(index) == '@'){
                Toast toast = Toast.makeText(MainActivity.this,"無法刪除@字元~",Toast.LENGTH_SHORT);
                toast.setGravity(Gravity.CENTER,0,0);
                toast.show();
                return true;
            }
            return false;
        }
    };
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        editText = findViewById(R.id.edit_text);
        editText.setBackSpaceLisetener(backspaceListener);
    }
}

結果示例:
這裡寫圖片描述

謝謝閱讀,詳情請見 原始碼