之前在做项目的时候有个需求是监听用户点击软键盘的退格键并在必要的时候拦截这个点击事件,以便在输入框删除文本的时候实现一些特殊的功能。当时我所能想到的常规方法是使用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);
}
}
结果示例:
谢谢阅读,详情请见 源码