JKeyboardPanelSwitch 原理分析

这是一位微信出来的哥们儿写的开源项目,解决表情 Panel 和键盘冲突的问题。本人之前做布丁动画时一直有这个困扰,看到这个项目,真是有种春风拂过泸沽湖的感觉。

项目地址在这里 https://github.com/Jacksgong/JKeyboardPanelSwitch

在分析原理前,先看一下为什么我们要使用 JKeyboardPanelSwitch,反之就是,如果不使用 JKeyboardPanelSwitch,我们会遇到哪些问题。

假设我们现在要自己实现一个如 Demo 所示的页面,我们会如何做呢?

  1. 计算键盘高度,根据键盘高度设置 Panel 高度;
  2. 监听键盘,当键盘显示时,如果 Panel 可见,则隐藏;
  3. 解决 Panel 可见情况下,键盘突然弹起,Panel 隐藏不及时的情况;

上面是我列出的可能遇到的问题,所以理想情况下 JKeyboardPanelSwitch 应该包含上面的 4 种解决方案。

然后我们带着问题,去看源码。我喜欢从入口开始,也就是 Demo 里面的 Activity.

首先是布局的 xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<cn.dreamtobe.kpswitch.widget.KPSwitchRootLinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/rootView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">


<include layout="@layout/view_message_list" />

<include layout="@layout/layout_send_message_bar" />


<cn.dreamtobe.kpswitch.widget.KPSwitchPanelLinearLayout
android:id="@+id/panel_root"
style="@style/Panel"
android:visibility="gone">


<include layout="@layout/merge_panel_content" />
</cn.dreamtobe.kpswitch.widget.KPSwitchPanelLinearLayout>

</cn.dreamtobe.kpswitch.widget.KPSwitchRootLinearLayout>

很明显,布局使用了两个定制的 LinearLayout,KPSwitchRootLinearLayout 和 KPSwitchPanelLinearLayout. KPSwitchRootLinearLayout 是整个页面的布局,KPSwitchPanelLinearLayout 则是 Panel 的布局。

首先看 KPSwitchRootLinearLayout, 继承了 LinearLayout,在 onMeasure 稍有处理。

1
2
3
4
5
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
conflictHandler.handleBeforeMeasure(MeasureSpec.getSize(widthMeasureSpec),
MeasureSpec.getSize(heightMeasureSpec));
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}

每次 measure 都会调用 conflictHandler.handleBeforeMeasure(int, int)
看 ConflictHandler (部分精简).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public void handleBeforeMeasure(final int width, int height) {
if (height < 0) {
return;
}
if (mOldHeight < 0) {
mOldHeight = height;
return;
}
final int offset = mOldHeight - height;
if (offset == 0) {
return;
}
if (Math.abs(offset) == mStatusBarHeight) {
return;
}
mOldHeight = height;
final IPanelConflictLayout panel = getPanelLayout(mTargetRootView);
if (offset > 0) {
panel.handleHide();
} else if (panel.isKeyboardShowing()) {
if (panel.isVisible()) {
panel.handleShow();
}
}
}

界面生成就会第一次 measure,此时 mOldHeight < 0,所以结束后,mOldHeight = height; 之后每次显示 / 隐藏键盘都会再次触发 measure,计算 offset,更新 mOldHeight,通知 Panel 处理.

然后再看 KPSwitchPanelLinearLayout, 它实现了 IPanelConflictLayout 接口。

1
2
3
4
5
6
7
8
9
@Override
public void handleShow() {
super.setVisibility(View.VISIBLE);
}

@Override
public void handleHide() {
panelLayoutHandler.handleHide();
}

KPSwitchPanelLayoutHandler,

1
2
3
public void handleHide() {
this.mIsHide = true;
}

可见仅仅是进行了一个标记。

接下来继续看 KPSwitchPanelLinearLayout,它的 onMeasure 调用了 panelLayoutHandler.processOnMeasure(int ,int)

1
2
3
4
5
6
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
final int[] processedMeasureWHSpec = panelLayoutHandler.processOnMeasure(widthMeasureSpec,
heightMeasureSpec);

super.onMeasure(processedMeasureWHSpec[0], processedMeasureWHSpec[1]);
}

看 KPSwitchPanelLayoutHandler,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public int[] processOnMeasure(int widthMeasureSpec, int heightMeasureSpec) {
if (mIsHide) {
panelLayout.setVisibility(View.GONE);
/**
* The current frame will be visible nil.
*/

widthMeasureSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.EXACTLY);
heightMeasureSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.EXACTLY);
}

processedMeasureWHSpec[0] = widthMeasureSpec;
processedMeasureWHSpec[1] = heightMeasureSpec;

return processedMeasureWHSpec;
}

也就是说,在 Panel 的 measure 过程中,如果发现 mIsHide 为 true,则设置 width = height = 0, 否则会正常显示 Panel.

至此我们发现,整套逻辑走完了,是在处理我们之前列出的几个问题中的第 3 点.

剩下的几点其实都就比较简单,在 Activity 的入口直接就可以找到,主要是通过设置一个 OnGlobalLayoutListener 来监听窗体大小变化来推算键盘高度,这就不细说了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
...
KeyboardUtil.attach(this, mPanelRoot,
new KeyboardUtil.OnKeyboardShowingListener() {
@Override
public void onKeyboardShowing(boolean isShowing) {
Log.d(TAG, String.format("Keyboard is %s", isShowing ? "showing" : "hiding"));
}
});

KPSwitchConflictUtil.attach(mPanelRoot, mPlusIv, mSendEdt,
new KPSwitchConflictUtil.SwitchClickListener() {
@Override
public void onClickSwitch(boolean switchToPanel) {
if (switchToPanel) {
mSendEdt.clearFocus();
} else {
mSendEdt.requestFocus();
}
}
});
...
}

KeyboardUtil.attach(),

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void attach(final Activity activity, IPanelHeightTarget target,
/** Nullable **/OnKeyboardShowingListener listener)
{

final ViewGroup contentView = (ViewGroup) activity.findViewById(android.R.id.content);
final boolean isFullScreen = ViewUtil.isFullScreen(activity);
final boolean isTranslucentStatus = ViewUtil.isTranslucentStatus(activity);
final boolean isFitSystemWindows = ViewUtil.isFitsSystemWindows(activity);

contentView.getViewTreeObserver().
addOnGlobalLayoutListener(
new KeyboardStatusListener(isFullScreen, isTranslucentStatus,
isFitSystemWindows,
contentView, target, listener));
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@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.bottom - r.top) + statusBarHeight;
} else {
userRootView.getWindowVisibleDisplayFrame(r);
displayHeight = (r.bottom - r.top);
}

calculateKeyboardHeight(displayHeight);
calculateKeyboardShowing(displayHeight);

previousDisplayHeight = displayHeight;
}