最近准备做个输入法表情语音合在一起的输入控件,看到了网上有JKeyboardPanelSwitch(地址),就拉下来学习了下。JKeyboardPanelSwitch可以保证在输入法键盘和其他键盘之间切换不抖动,效果非常好,而且解决了很多适配问题。
本文对此代码进行学习。
实现好之后,输入法键盘也会使用这个区域,实现了自定义键盘和输入法键盘公用一块区域,使得切换起来不抖动,输入条的位置不变化。具体效果如下所示,图来自github。
首先定义几个部分,编辑框所在这行为bar,照片位置这块为panel,输入法的键盘我们称为keyboard,如上图所示。实际上,最核心的问题就是panel与keyboard可以随意切换,而保持bar的位置不变。
要解决这个问题,我们首先得有点基础知识。
一般来说输入法的弹起与收缩,我们是不知道的,在“输入法键盘和编辑框焦点”这篇文章我们曾经说过,windowSoftInputMode为adjustResize,当输入法弹起或者收缩的时候会调用根布局的onSizeChanged方法,可以通过这个方法来监控输入法。而onSizeChanged是在的layout()内的setFrame()内的sizeChange()内调用的,这是layout的前期,在onLayout之前,能不能提前知道输入法被调起呢?当然可以,onLayout之前必定有onMeasure,我们可以在onMeasure里监控高度的变化从而知道输入法是否弹起。
public void layout(int l, int t, int r, int b) {
if ((mPrivateFlags3 & PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT) != 0) {
onMeasure(mOldWidthMeasureSpec, mOldHeightMeasureSpec);
mPrivateFlags3 &= ~PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT;
}
int oldL = mLeft;
int oldT = mTop;
int oldB = mBottom;
int oldR = mRight;
boolean changed = isLayoutModeOptical(mParent) ?
setOpticalFrame(l, t, r, b) : setFrame(l, t, r, b);
if (changed || (mPrivateFlags & PFLAG_LAYOUT_REQUIRED) == PFLAG_LAYOUT_REQUIRED) {
onLayout(changed, l, t, r, b);
mPrivateFlags &= ~PFLAG_LAYOUT_REQUIRED;
简单的说,我们可以设置windowSoftInputMode为adjustResize,通过监控跟布局的onMeasure方法,来知道输入法的弹起与收缩。
这在大部分情况是OK的,但也有失效的时候,后文会补充。
keyboard的高度是由输入法app决定的,panel高度是我们在代码里可以设置的。我们刚才说了onMeasure里可以监控到keyboard,那么keyboard弹起,必然导致整个布局收缩,收缩多少可以在onMeasure里得到数据,收缩的值就是输入法的高度,这样我们就得到了输入法的高度,我们可以把这个值存起来(存到文件或db或sharepreference),并且设置给panel。
不同类型的activity对keyboard弹起有不同处理方法,所以根据状态栏是否透明,是否全屏,是否fitsSystemWindows将activity分类别,
非全屏主题 或者 透明状态栏主题并且在fitsSystemWindows=true 情况下参考ChattingResolvedActivity
全屏主题 或者 透明状态栏主题并且在 fitsSystemWindows=false 情况下参考ChattingResolvedHandleByPlaceholderActivity
布局是这样子的,核心就是最外层用KPSwitchRootLinearLayout这个类,然后底部放一个KPSwitchPanelLinearLayout,这个布局里面可以放自定义的面板内容(比如表情,语音,附加内容),要保持这个布局和keyboard占据同样位置。
我们先来看下panel切换到keyboard的过程.这个过程需要完全2件事,隐藏panel,显示keyboard,但是keyboard的显示有个过程,所以会产生抖动,如何才能解决抖动呢?在keyboard真正要展示出来的时候,隐藏panel就可以。下面看看代码怎么实现的。
点击编辑框,可以导致keyboard弹出,导致重新布局,KPSwitchRootLinearLayout开始onMeasure
//KPSwitchRootLinearLayout#onMeasure
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
conflictHandler.handleBeforeMeasure(MeasureSpec.getSize(widthMeasureSpec),
MeasureSpec.getSize(heightMeasureSpec));
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
这个onMeasure在真正调用super.onMeasure(widthMeasureSpec, heightMeasureSpec);之前做了些处理,很关键。我们来看看handleBeforeMeasure干了什么,KPSwitchRootLayoutHandler这个类我改了一点,和github不太一致,后文会贴出此类源码。可以看到,输入法弹起,offset必然大于0,会调用 mPanelLayout.handleHide();实际上是把KPSwitchPanelLayoutHandler内的mIsHide 置为true;
//KPSwitchRootLayoutHandler#handleBeforeMeasure
public void handleBeforeMeasure(final int width, int height) {
// 由当前布局被键盘挤压,获知,由于键盘的活动,导致布局将要发生变化。
。。。
final int offset = mOldHeight - height;
if (offset == 0) {
Log.d(TAG, "" + offset + " == 0 break;");
return;
}
if (Math.abs(offset) == mStatusBarHeight) {
Log.w(TAG, String.format("offset just equal statusBar height %d", offset));
// 极有可能是 相对本页面的二级页面的主题是全屏&是透明,但是本页面不是全屏,因此会有status bar的布局变化差异,进行调过
// 极有可能是 该布局采用了透明的背景(windowIsTranslucent=true),而背后的布局`full screen`为false,
// 因此有可能第一次绘制时没有attach上status bar,而第二次status bar attach上去,导致了这个变化。
return;
}
。。。
// 检测到真正的 由于键盘收起触发了本次的布局变化
if (offset > 0) {
//键盘弹起 (offset > 0,高度变小)
mPanelLayout.handleHide();
} else if (mPanelLayout.isKeyboardShowing()) {
// 1. 总得来说,在监听到键盘已经显示的前提下,键盘收回才是有效有意义的。
// 2. 修复在Android L下使用V7.Theme.AppCompat主题,进入Activity,默认弹起面板bug,
// 第2点的bug出现原因:在Android L下使用V7.Theme.AppCompat主题,并且不使用系统的ActionBar/ToolBar,V7.Theme.AppCompat主题,还是会先默认绘制一帧默认ActionBar,然后再将他去掉(略无语)
//键盘收回 (offset < 0,高度变大)
if (mPanelLayout.isVisible()) {
// the panel is showing/will showing
mPanelLayout.handleShow();
}
}
}
搞了半天,只是把KPSwitchPanelLayoutHandler内的mIsHide 置为true;,就做了这么一点事。这有什么意义啊?注意此时panel并没有设置为gone。
我们接着看,此时panel是visiblle的,KPSwitchRootLinearLayout的onMeasure必然会导致panel被onMeasure,这的panel是KPSwitchPanelLinearLayout。看看他的measure
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
final int[] processedMeasureWHSpec = panelLayoutHandler.processOnMeasure(widthMeasureSpec,
heightMeasureSpec);
super.onMeasure(processedMeasureWHSpec[0], processedMeasureWHSpec[1]);
}
似曾相识啊,又搞了个handler,接着往下看,因为mIsHide被刚刚被设置为true,所以panel会变成gone,并且修改widthMeasureSpec和heightMeasureSpec的值,让他们的size为0,这样接着去measure panel的child,child就不会有宽高了,否则会有问题,虽然不显示(因为gone),但是还占着空间(因为measure的时候把高度算进去了),如下图所示。panel切键盘,作者巧妙的利用了一个view在measure时把自己置gone,不会触发requestLayout,来使得切换不闪。
public int[] processOnMeasure(int widthMeasureSpec, int heightMeasureSpec) {
if (mIsHide) {
panelLayout.setVisibility(View.GONE);
/**
* The current frame will be visible nil.
*/
//强行给上0,0,是为了让child在measure的时候得到结果都是0,否则会有问题,虽然不显示(因为gone),但是还占着空间,如下图所示
widthMeasureSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.EXACTLY);
heightMeasureSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.EXACTLY);
}
processedMeasureWHSpec[0] = widthMeasureSpec;
processedMeasureWHSpec[1] = heightMeasureSpec;
return processedMeasureWHSpec;
}
再来看看keyboard切换到panel的。点击“加号”按钮,会导致keyboard切换到panel,代码如下,核心代码是switchPanelAndKeyboard
//cn.dreamtobe.kpswitch.util.KPSwitchConflictUtil#attach
@Override
public void onClick(View v) {
final boolean switchToPanel = switchPanelAndKeyboard(panelLayout, focusView);
if (switchClickListener != null) {
switchClickListener.onClickSwitch(switchToPanel);
}
}
看下边代码,此时panel为gone,所以switchToPanel为true,会走showPanel
public static boolean switchPanelAndKeyboard(final View panelLayout, final View focusView) {
boolean switchToPanel = panelLayout.getVisibility() != View.VISIBLE;
if (!switchToPanel) {
showKeyboard(panelLayout, focusView);
} else {
showPanel(panelLayout);
}
return switchToPanel;
}
再看showPanel,这里很简单的做了2个事,panel显示,keyboard隐藏。啊??这不是会抖动的吗?没错,这么写是会抖动的,可是注意这里的setVisibility被重写了。
public static void showPanel(final View panelLayout) {
final Activity activity = (Activity) panelLayout.getContext();
panelLayout.setVisibility(View.VISIBLE);
if (activity.getCurrentFocus() != null) {
KeyboardUtil.hideKeyboard(activity.getCurrentFocus());
}
}
从这里可以看到如果filterSetVisibility返回true了就根本不调用实际的setVisibility。
@Override
public void setVisibility(int visibility) {
if (panelLayoutHandler.filterSetVisibility(visibility)) {
return;
}
super.setVisibility(visibility);
}
从下边可以看出此时满足isKeyboardShowing() && visibility == View.VISIBLE,所以必定返回true,这里把mIsHide设置为false然后返回true。
public boolean filterSetVisibility(final int visibility) {
if (visibility == View.VISIBLE) {
this.mIsHide = false;
}
if (visibility == panelLayout.getVisibility()) {
return true;
}
/**
* For handling Keyboard->Panel.
*
* Will be handled on {@link KPSwitchRootLayoutHandler#handleBeforeMeasure(int, int)} ->
* {@link IPanelConflictLayout#handleShow()} Delay show, until the {@link KPSwitchRootLayoutHandler} discover
* the size is changed by keyboard-show. And will show, on the next frame of the above
* change discovery.
*/
if (isKeyboardShowing() && visibility == View.VISIBLE) {
return true;
}
return false;
}
if (mPanelLayout.isVisible()) {
// the panel is showing/will showing
mPanelLayout.handleShow();
}
注意,这里的isVisible也不是真正意义上的view是否显示,看代码,我们刚才把mIsHide设置为false,那isVisible()就会返回true。导致mPanelLayout.handleShow()被调用。在handleShow()内部真正把view设置为true。
@Override
public boolean isVisible() {
return !mIsHide;
}
//KPSwitchConflictUtil
if (isHandleByPlaceholder(activity)) {
focusView.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_UP) {
/**
* Show the fake empty keyboard-same-height panel to fix the conflict when
* keyboard going to show.
* @see KPSwitchConflictUtil#showKeyboard(View, View)
*/
panelLayout.setVisibility(View.INVISIBLE);
}
return false;
}
});
}
//KPSwitchConflictUtil
public static void showPanel(final View panelLayout) {
final Activity activity = (Activity) panelLayout.getContext();
panelLayout.setVisibility(View.VISIBLE);
if (activity.getCurrentFocus() != null) {
KeyboardUtil.hideKeyboard(activity.getCurrentFocus());
}
}
@Override
protected void onPause() {
super.onPause();
panelRoot.recordKeyboardStatus(getWindow());
}
@Override
public void onGlobalLayout() {
final View userRootView = contentView.getChildAt(0);
final View contentParentView = (View) contentView.getParent();
// Step 1. calculate the current display frame's height.
Rect r = new Rect();
final int displayHeight;
if (isTranslucentStatus) {
contentParentView.getWindowVisibleDisplayFrame(r);
displayHeight = (r.height()) + statusBarHeight;
} else {
//得到的区域不包括状态栏,底部导航栏,输入法键盘
userRootView.getWindowVisibleDisplayFrame(r);
displayHeight = r.height();
}
calculateKeyboardHeight(displayHeight);
calculateKeyboardShowing(displayHeight);
previousDisplayHeight = displayHeight;
}
核心思想就是在onGlobalLayout被触发的时候,获取一个displayHeight的高度,然后根据高度来判断输入法是否弹起(calculateKeyboardShowing),输入法键盘高度是否调整(calculateKeyboardHeight)。
if (isTranslucentStatus) {
contentParentView.getWindowVisibleDisplayFrame(r);
displayHeight = (r.height()) + statusBarHeight;
} else {
//得到的区域不包括状态栏,底部导航栏,输入法键盘
userRootView.getWindowVisibleDisplayFrame(r);
displayHeight = r.height();
}
第二部calculateKeyboardHeight的过程中也有类似的代码,根据activity的透明度,全屏,isFitsSystem信息来进行分别对待
int keyboardHeight;
if (KPSwitchConflictUtil.isHandleByPlaceholder(isFullScreen, isTranslucentStatus,
isFitSystemWindows)) {
// the height of content parent = contentView.height + actionBar.height
final View actionBarOverlayLayout = (View) contentView.getParent();
keyboardHeight = actionBarOverlayLayout.getHeight() - displayHeight;
Log.d(TAG, String.format("action bar over layout %d display height: %d",
((View) contentView.getParent()).getHeight(), displayHeight));
} else {
keyboardHeight = Math.abs(displayHeight - previousDisplayHeight);
}
第三部calculateKeyboardShowing也有类似的代码
if (KPSwitchConflictUtil.isHandleByPlaceholder(isFullScreen, isTranslucentStatus,
isFitSystemWindows)) {
if (!isTranslucentStatus &&
actionBarOverlayLayoutHeight - displayHeight == this.statusBarHeight) {
// handle the case of status bar layout, not keyboard active.
isKeyboardShowing = lastKeyboardShowing;
} else {
isKeyboardShowing = actionBarOverlayLayoutHeight > displayHeight;
}
} else {
final int phoneDisplayHeight = contentView.getResources().getDisplayMetrics().heightPixels;
if (!isTranslucentStatus &&
phoneDisplayHeight == actionBarOverlayLayoutHeight) {
// no space to settle down the status bar, switch to fullscreen,
// only in the case of paused and opened the fullscreen page.
Log.w(TAG, String.format("skip the keyboard status calculate, the current" +
" activity is paused. and phone-display-height %d," +
" root-height+actionbar-height %d", phoneDisplayHeight,
actionBarOverlayLayoutHeight));
return;
}
if (maxOverlayLayoutHeight == 0) {
// non-used.
isKeyboardShowing = lastKeyboardShowing;
} else if (displayHeight >= maxOverlayLayoutHeight) {
isKeyboardShowing = false;
} else {
isKeyboardShowing = true;
}
maxOverlayLayoutHeight = Math.max(maxOverlayLayoutHeight, actionBarOverlayLayoutHeight);
}
/*
* Copyright (C) 2015-2016 Jacksgong(blog.dreamtobe.cn)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package cn.dreamtobe.kpswitch.handler;
import android.annotation.TargetApi;
import android.app.Activity;
import android.graphics.Rect;
import android.os.Build;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;
import cn.dreamtobe.kpswitch.IPanelConflictLayout;
import cn.dreamtobe.kpswitch.util.StatusBarHeightUtil;
import cn.dreamtobe.kpswitch.util.ViewUtil;
/**
* 此部分代码有所改动,与github不一致
* Created by Jacksgong on 3/30/16.
*
* @see cn.dreamtobe.kpswitch.widget.KPSwitchRootFrameLayout
* @see cn.dreamtobe.kpswitch.widget.KPSwitchRootLinearLayout
* @see cn.dreamtobe.kpswitch.widget.KPSwitchRootRelativeLayout
*/
public class KPSwitchRootLayoutHandler {
private final static String TAG = "KPSRootLayoutHandler";
private int mOldHeight = -1;
private final View mTargetRootView;
private final int mStatusBarHeight;
private final boolean mIsTranslucentStatus;
private IPanelConflictLayout mPanelLayout;
public KPSwitchRootLayoutHandler(final View rootView) {
this.mTargetRootView = rootView;
this.mStatusBarHeight = StatusBarHeightUtil.getStatusBarHeight(rootView.getContext());
final Activity activity = (Activity) rootView.getContext();
this.mIsTranslucentStatus = ViewUtil.isTranslucentStatus(activity);
}
@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
public void handleBeforeMeasure(final int width, int height) {
// 由当前布局被键盘挤压,获知,由于键盘的活动,导致布局将要发生变化。
if (mIsTranslucentStatus && Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
if (mTargetRootView.getFitsSystemWindows()) {
// In this case, the height is always the same one, so, we have to calculate below.
final Rect rect = new Rect();
mTargetRootView.getWindowVisibleDisplayFrame(rect);
height = rect.bottom - rect.top;
}
}
Log.d(TAG, "onMeasure, width: " + width + " height: " + height);
if (height < 0) {
return;
}
if (mOldHeight < 0) {
mOldHeight = height;
return;
}
final int offset = mOldHeight - height;
if (offset == 0) {
Log.d(TAG, "" + offset + " == 0 break;");
return;
}
if (Math.abs(offset) == mStatusBarHeight) {
Log.w(TAG, String.format("offset just equal statusBar height %d", offset));
// 极有可能是 相对本页面的二级页面的主题是全屏&是透明,但是本页面不是全屏,因此会有status bar的布局变化差异,进行调过
// 极有可能是 该布局采用了透明的背景(windowIsTranslucent=true),而背后的布局`full screen`为false,
// 因此有可能第一次绘制时没有attach上status bar,而第二次status bar attach上去,导致了这个变化。
return;
}
mOldHeight = height;
// final IPanelConflictLayout panel = getPanelLayout(mTargetRootView);
if(mPanelLayout==null){
getPanelLayout(mTargetRootView);
}
if (mPanelLayout == null) {
Log.w(TAG, "can't find the valid panel conflict layout, give up!");
return;
}
// 检测到真正的 由于键盘收起触发了本次的布局变化
if (offset > 0) {
//键盘弹起 (offset > 0,高度变小)
mPanelLayout.handleHide();
} else if (mPanelLayout.isKeyboardShowing()) {
// 1. 总得来说,在监听到键盘已经显示的前提下,键盘收回才是有效有意义的。
// 2. 修复在Android L下使用V7.Theme.AppCompat主题,进入Activity,默认弹起面板bug,
// 第2点的bug出现原因:在Android L下使用V7.Theme.AppCompat主题,并且不使用系统的ActionBar/ToolBar,V7.Theme.AppCompat主题,还是会先默认绘制一帧默认ActionBar,然后再将他去掉(略无语)
//键盘收回 (offset < 0,高度变大)
if (mPanelLayout.isVisible()) {
// the panel is showing/will showing
mPanelLayout.handleShow();
}
}
}
private IPanelConflictLayout getPanelLayout(final View view) {
if (mPanelLayout != null) {
return mPanelLayout;
}
if (view instanceof IPanelConflictLayout) {
mPanelLayout = (IPanelConflictLayout) view;
return mPanelLayout;
}
if (view instanceof ViewGroup) {
for (int i = 0; i < ((ViewGroup) view).getChildCount(); i++) {
IPanelConflictLayout v = getPanelLayout(((ViewGroup) view).getChildAt(i));
if (v != null) {
mPanelLayout = v;
return mPanelLayout;
}
}
}
return null;
}
}