优雅的监听软键盘隐藏

背景

  1. Android软键盘的显示和隐藏,从开始做直播这块,就一直困扰着我。
  2. 从布局挤压,到输入区显示不全,再到闪屏以及卡顿,这里的坑让我跌倒无数次。
  3. 各种布局监听,回调,代码冗余、复杂、与业务强耦合无法复用,还是没有很好的解决键盘的弹出和隐藏
  4. 为了给用户更好的操作体验,决定找到一种最优解决方案。

科普基础知识-WindowSoftInputMode

Activity 的主窗口与包含屏幕软键盘的窗口的交互方式。改属性的设置影响两个方面:

  • 当Activity成为用户注意的焦点时软键盘的状态-隐藏还是可见
  • 对Activity主窗口所做的调整-是否将其尺寸调小以为软键盘腾出空间,或者当窗口部分被软键盘遮挡时是否平移其那内容使当前焦点可见。

该设置必须是下标所列的值之一,或者一个state...值加上一个adjust...值的组合。在任一一组设置多个值(例如,多个state...值)都会产生未定义结果。各个值之间使用垂直条(|)分割。

说明
stateUnspecified 不指定软键盘的状态(隐藏还是可见)。 将由系统选择合适的状态,或依赖主题中的设置。这是对软键盘行为的默认设置。
stateUnchanged 当 Activity 转至前台时保留软键盘最后所处的任何状态,无论是可见还是隐藏。
stateHidden 当用户选择 Activity 时 — 也就是说,当用户确实是向前导航到 Activity,而不是因离开另一 Activity 而返回时 — 隐藏软键盘。
stateAlwaysHidden 当 Activity 的主窗口有输入焦点时始终隐藏软键盘。
stateVisible 在正常的适宜情况下(当用户向前导航到 Activity的主窗口时)显示软键盘。
stateAlwaysVisible 当用户选择 Activity 时 — 也就是说,当用户确实是向前导航到 Activity,而不是因离开另一 Activity 而返回时 — 显示软键盘。
adjustUnspecified 不指定 Activity 的主窗口是否调整尺寸以为软键盘腾出空间,或者窗口内容是否进行平移以在屏幕上显露当前焦点。 系统会根据窗口的内容是否存在任何可滚动其内容的布局视图来自动选择其中一种模式。 如果存在这样的视图,窗口将进行尺寸调整,前提是可通过滚动在较小区域内看到窗口的所有内容。这是对主窗口行为的默认设置。
adjustResize 始终调整 Activity 主窗口的尺寸来为屏幕上的软键盘腾出空间
adjustPan 不调整 Activity 主窗口的尺寸来为软键盘腾出空间, 而是自动平移窗口的内容,使当前焦点永远不被键盘遮盖,让用户始终都能看到其输入的内容。 这通常不如尺寸调正可取,因为用户可能需要关闭软键盘以到达被遮盖的窗口部分或与这些部分进行交互。

官方解释

网上解决方案

第一种方案

由于Activity.onKeyDownn()是监听不到向下的按键,所以自定义Edittext,重写onKeyPreIme方法

/**
 * 拦截键盘向下按键的 EditTextView
 */
public class TextEditTextView extends DmtEditText {
    public TextEditTextView(Context context) {
        super(context);
    }

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

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


    @Override
    public boolean onKeyPreIme(int keyCode, KeyEvent event) {
        if (keyCode == KeyEvent.KEYCODE_BACK && event.getAction() == 1 && onKeyBoardHideListener != null) {
            onKeyBoardHideListener.onKeyHide();
        }
        return super.onKeyPreIme(keyCode, event);
    }

    /**
     * 键盘监听接口
     */
    private OnKeyBoardHideListener onKeyBoardHideListener;

    public void setOnKeyBoardHideListener(OnKeyBoardHideListener onKeyBoardHideListener) {
        this.onKeyBoardHideListener = onKeyBoardHideListener;
    }

    public interface OnKeyBoardHideListener {
        void onKeyHide();
    }
}

为什么重写onKeyDown()方法,监听不到虚拟键的向下按键,而重写EditTextView的onKeyPreIme可以监听到,这篇博客写的很明白。

第二种方案

使用ViewTreeObserver.OnGlobalLayoutListener来监听整个布局的变化,但是有问题,点击软键盘的“向下”按键,不会回调这个函数。

View.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener(){
 
    //当键盘弹出隐藏的时候会 调用此方法。
    @Override
    public void onGlobalLayout() {
        final Rect rect = new Rect();
        activity.getWindow().getDecorView().getWindowVisibleDisplayFrame(rect);
        final int screenHeight = activity.getWindow().getDecorView().getRootView().getHeight();
        final int heightDifference = screenHeight - rect.bottom;
        boolean visible = heightDifference > screenHeight / 3;
        if(visible){
            Log.i(TAG,"软键盘显示");
        }else {
            Log.i(TAG,"软键盘隐藏");
        }
    }
});

第三种方案

将布局撑满全屏,来监听onMeasure()的变化,这种方式可以生效,但是和业务耦合性太大,而且在全面屏的时候处理比较麻烦,就不贴代码了

总结

基本上所有监听软键盘的方式,都是通过上面的三种方式实现的。实现效果比较好是Android键盘面板冲突 布局闪动处理方案

所有的方式都是在本页面上去弹起软键盘,软键盘是dialog,而且页面的逻辑和复杂程度有各种情况,非常难以考虑。真是让人抓耳挠腮呀。

打个响指,换种思路

没有非要在一个页面内完成软键盘的调度,我们可以另起炉灶,去实现这个功能。

使用DialogFragment来实现软键盘功能

这种情况还是与业务有一定的关联,我不敢说这种方案能够解决所有情况下的软键盘使用问题,但是能解决很多情况下的使用。

先说一下我的使用场景,因为我是负责直播模块的开发,软键盘的弹出和隐藏时的输入部分ui是不相同。使用抖音的直播举个例子

可以看到软键盘在打开和关闭的时候是不同的ui,那么就可以使用DialogFragment来实现功能。我们最主要实现就是监听软键盘的弹出和隐藏,弹出问题不大,这里最深的坑就是监听键盘的消失,先总结一下键盘消失的场景:

  1. 点击空白区域消失
  2. 点击虚拟键能向下按键隐藏键盘
  3. 点击软键盘向下按钮隐藏键盘
  4. 点击发送按钮后,消失键盘

ps: 2和3,看上去好像是一样的啊,大家肯定有一些疑惑,看图说话

从张图中可以清晰的看出来两者的区别,其实android的原生键盘是没有向下的按钮的,各个第三方的输入法自己实现的,andorid中没有回调可以监听到这个事件(坑爹啊)。而且
ViewTreeObserver 监听不到点击这个按钮时的布局变化(坑爹啊!!!),我只是在mix2手机上测试的,其他的手机类型我不敢确保也是同样的问题。

来来来,在做个小结

  • 软键盘的弹出时可以监听的
  • 消失几种情况中,除了软键盘的向下按键其他都可以做到监听,或者可以拿到触发的时机。

搜了一大圈,好像没有找到比较好的解决方案,怎么办呢?但是别人家的直播都是可以做到的呀,这时看到了View.getWindowVisibleDisplayFrame()方法,来看下官方的解释

/**
     * Retrieve the overall visible display size in which the window this view is
     * attached to has been positioned in.  This takes into account screen
     * decorations above the window, for both cases where the window itself
     * is being position inside of them or the window is being placed under
     * then and covered insets are used for the window to position its content
     * inside.  In effect, this tells you the available area where content can
     * be placed and remain visible to users.
     *
     * <p>This function requires an IPC back to the window manager to retrieve
     * the requested information, so should not be used in performance critical
     * code like drawing.
     *
     * @param outRect Filled in with the visible display frame.  If the view
     * is not attached to a window, this is simply the raw display size.
     */
    public void getWindowVisibleDisplayFrame(Rect outRect) {
        if (mAttachInfo != null) {
            try {
                mAttachInfo.mSession.getDisplayFrame(mAttachInfo.mWindow, outRect);
            } catch (RemoteException e) {
                return;
            }
            // XXX This is really broken, and probably all needs to be done
            // in the window manager, and we need to know more about whether
            // we want the area behind or in front of the IME.
            final Rect insets = mAttachInfo.mVisibleInsets;
            outRect.left += insets.left;
            outRect.top += insets.top;
            outRect.right -= insets.right;
            outRect.bottom -= insets.bottom;
            return;
        }
        // The view is not attached to a display so we don't have a context.
        // Make a best guess about the display size.
        Display d = DisplayManagerGlobal.getInstance().getRealDisplay(Display.DEFAULT_DISPLAY);
        d.getRectSize(outRect);
    }

大概意思:这个api是用来获取窗口可视区域大小的。该大小会收到系统状态栏、软键盘和虚拟按键的影响。在应用开发中可以利用该api来获取状态栏的高度,软键盘的高度和虚拟按键的高度。

解决方案:
既然没有回调能够拿到虚拟键盘的向下操作,那么我们就轮询监听窗口的大小,由于这个dialogFragment的生命周期只是在输入的时候存在,那么就以为这这个轮询时间也不会太长,100ms轮询一次来监听窗口变化的大小,完美解决(如果还有其他优雅的解决方案,请告诉我)。

用DialogFragment实现输入区域的好处:

  1. 输入部分的逻辑与其他业务本分的逻辑隔离,实现解耦
  2. DialogFragment也是一个dialog,可以单独处理键盘弹出时的逻辑

有了以上两点考虑,我就开始动手写代码

Version 1

源代码就不贴了,太长了,而且大部分和业务相关,所有的业务逻辑和功能逻辑全部写在DialogFragment中,看上去没什么问题,如果突然有一天,另外的一个地方要做到类似的逻辑,又要重新写一遍功能逻辑,完全不能复用呀,这样的实现方案是不行的,打回去重做。嗯,需要将业务逻辑和功能逻辑分割开,这样可以很大程度上的复用当前代码。

Version 2

首先要定义一个接口,来定义此类功能的统一调用方式,所有要实现此功能的类都要实现这个接口

public interface IKeyBoard {

    // EditTextView需要继承TextEditTextView,TextEditTextView是用来监听虚拟按键向下的操作
    TextEditTextView getEditTextView();

    // 根布局
    View getRoot();

    // dialogFragment 销毁时调用,也就是隐藏时
    void onDismiss();
}

自定义EditTextView

/**
 * 拦截键盘向下按键的 EditTextView
 */
public class TextEditTextView extends DmtEditText {
    public TextEditTextView(Context context) {
        super(context);
    }

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

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


    @Override
    public boolean onKeyPreIme(int keyCode, KeyEvent event) {
        if (keyCode == KeyEvent.KEYCODE_BACK && event.getAction() == 1 && onKeyBoardHideListener != null) {
            onKeyBoardHideListener.onKeyHide();
        }
        return super.onKeyPreIme(keyCode, event);
    }

    /**
     * 键盘监听接口
     */
    private OnKeyBoardHideListener onKeyBoardHideListener;

    public void setOnKeyBoardHideListener(OnKeyBoardHideListener onKeyBoardHideListener) {
        this.onKeyBoardHideListener = onKeyBoardHideListener;
    }

    public interface OnKeyBoardHideListener {
        void onKeyHide();
    }
}

最主要的类来了:

/**
 * 这个类时用来实现输入框随软键盘弹出的情况,这个dialogFragment只负责弹起键盘的操作,不负责具体的ui显示和逻辑,
 * 应该实现一个UI类来实现相应的显示和逻辑部分。
 * <p>
 * 使用方法:
 * 1. 需要实现{@link IKeyBoard}
 * 2. UI类需要包含{@link TextEditTextView}
 * 3. 如果UI需要监听声明周期,需要实现{@link LifecycleObserver}
 * <p>
 * ps:父类的Fragment或者Activity  window的setSoftInputMode设置为{@link WindowManager} SOFT_INPUT_ADJUST_NOTHING
 *
 * @author liyachao
 * @date 2018/4/17
 */

public class KeyBoardDialogFragment extends DialogFragment implements TextEditTextView.OnKeyBoardHideListener,
        WeakHandler.IHandler {
    private static final String TAG = "KeyBoardDialogFragment";

    private IKeyBoard mKeyBoardView;
    private TextEditTextView mTextEditTextView;
    private boolean softKeyBoardIsVisible;
    private Activity mActivity;
    private WeakHandler mHandler;
    private Rect mRect = new Rect();


    public static KeyBoardDialogFragment newInstance(IKeyBoard keyBoard) {
        KeyBoardDialogFragment fragment = new KeyBoardDialogFragment();
        Bundle args = new Bundle();
        fragment.setArguments(args);
        fragment.setKeyBoardView(keyBoard);
        return fragment;
    }

    /**
     * 安全检查
     * @param keyBoardView 业务逻辑的view
     */
    public void setKeyBoardView(IKeyBoard keyBoardView) {
        if (keyBoardView == null) {
            throw new RuntimeException("keyBoardView must not be null");
        } else if (keyBoardView.getEditTextView() == null) {
            throw new RuntimeException("keyBoardView must has EditTextView");
        } else if (keyBoardView.getRoot() == null) {
            throw new RuntimeException("keyBoardView must has root layout");
        }
        mKeyBoardView = keyBoardView;
        mTextEditTextView = keyBoardView.getEditTextView();
    }

    /**
     * 设置主题 input_dialog_style_large的具体设置如下
     * <style name="input_dialog_style_large" parent="@android:style/Theme.Dialog">
     *         <item name="android:windowBackground">@color/transparent</item> //winndow 背景为透明色 
     *         <item name="android:windowNoTitle">true</item> // 没有title
     *         <item name="android:backgroundDimEnabled">false</item> // 没有默认的背景色
     *         <item name="android:windowAnimationStyle">@style/keyboard_dialog_animation</item> //window动画,可以不设置
     *     </style>
     * 业务逻辑view,注册DialogFragment声明周期
     * 
     */
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setStyle(STYLE_NO_TITLE, R.style.input_dialog_style_large);
        if (mKeyBoardView == null || mKeyBoardView.getRoot() == null) {
            dismiss();
            return;
        }
        if (mKeyBoardView.getRoot() instanceof LifecycleObserver) {
            getLifecycle().addObserver((LifecycleObserver) mKeyBoardView.getRoot());
        }
        mHandler = new WeakHandler(this);
    }


    @Override
    public void onAttach(Context activity) {
        super.onAttach(activity);
        mActivity = (Activity) activity;
    }


    @Nullable
    @Override
    public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        return mKeyBoardView.getRoot();
    }

    @Override
    public void onActivityCreated(Bundle savedInstanceState) {
        super.onActivityCreated(savedInstanceState);
        mTextEditTextView.setOnKeyBoardHideListener(this);
        initWindowParams();
    }

    /**
     * 设置window属性
     */
    public void initWindowParams() {
        Window window = getDialog().getWindow();
        if (window == null) {
            return;
        }
        WindowManager.LayoutParams lp = getDialog().getWindow().getAttributes();
        lp.dimAmount = 0;
        lp.flags |= WindowManager.LayoutParams.FLAG_DIM_BEHIND;
        lp.height = ViewGroup.LayoutParams.WRAP_CONTENT;
        lp.width = ViewGroup.LayoutParams.MATCH_PARENT;
        lp.gravity = Gravity.BOTTOM;
        window.setBackgroundDrawable(new ColorDrawable(0));
        window.setAttributes(lp);
        window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);
    }

    @Override
    public Dialog onCreateDialog(Bundle savedInstanceState) {
        Dialog dialog = super.onCreateDialog(savedInstanceState);
        dialog.setCanceledOnTouchOutside(true);
        return dialog;
    }

    @Override
    public void onResume() {
        super.onResume();
        mHandler.sendEmptyMessageDelayed(1, 100);
    }

    @Override
    public void onStop() {
        super.onStop();
        dismissAllowingStateLoss();
        mHandler.removeMessages(1);
    }

    @Override
    public void onDestroy() {
        super.onDestroy();
    }

    @Override
    public void onDismiss(DialogInterface dialog) {
        super.onDismiss(dialog);
        mKeyBoardView.onDismiss();
    }

    @Override
    public void onKeyHide() {
        dismiss();
    }

    public void onGlobalLayout() {
        Window window = getDialog().getWindow();
        if (window != null) {
            mRect.setEmpty();
            window.getDecorView().getWindowVisibleDisplayFrame(mRect);
            int screenHeight = UIUtils.getScreenHeight(getContext());
            int heightDifference = screenHeight - (mRect.bottom - mRect.top);
            if (heightDifference > screenHeight / 3) {
                Log.d(TAG, "键盘弹出");
                softKeyBoardIsVisible = true;
            } else {
                if (softKeyBoardIsVisible) {
                    Log.d(TAG, "键盘隐藏");
                    dismiss();
                    softKeyBoardIsVisible = false;
                }
            }
        }
    }

    @Override
    public void handleMsg(Message msg) {
        if (msg.what == 1) {
            onGlobalLayout();
            mHandler.sendEmptyMessageDelayed(1, 100);
        }
    }
}

具体的实现就是上面了,大部分做了注释,也不需要解释了,基本上可以解决大部分场景,可以根据自己具体的业务逻辑做一些改动。demo就不给大家了,上面基本上就可以了。

demo下载

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章