優雅的監聽軟鍵盤隱藏

背景

  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下載

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