在开发IM(即时聊天通讯)中不可避免要设计一些聊天窗口页面,在输入框、表情按钮以及焦点切换时手机界面会不可避免会碰到一些非常僵硬的闪动问题,而这些在iOS据说自带平滑过渡,而Android却没有这些优化,而且经笔者测试第三方IM的Demo都没怎么优化,所以只能我们自己动手来啦~
在聊天界面我们一般会分成若干个层级,顶部区域(聊天者的姓名),内容区域(聊天记录),底部区域(输入框,按钮,Emoji面板等)。
我们简单来设计一个界面,代码如下:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/root_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<android.support.v4.widget.SwipeRefreshLayout
android:id="@+id/swipe_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<android.support.v7.widget.RecyclerView
android:id="@+id/recyclerview"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</android.support.v4.widget.SwipeRefreshLayout>
</RelativeLayout>
<View
android:layout_width="match_parent"
android:layout_height="1px"
android:background="@android:color/darker_gray" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="60dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingLeft="10dp"
android:paddingRight="10dp">
<EditText
android:id="@+id/et_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
<CheckBox
android:id="@+id/cbx_emoji"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Emoji" />
<Button
android:id="@+id/btn_send"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="发送" />
</LinearLayout>
<LinearLayout
android:id="@+id/pannel"
android:layout_width="match_parent"
android:layout_height="200dp"
android:gravity="center"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="模拟表情面板" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
运行起来是这个样子
我们给他加上一些必要的监听事件,就别再在意界面太丑了(;¬_¬)
rootLayout = (MeasureLinearLayout) findViewById(R.id.root_layout);
swipeLayout = (SwipeRefreshLayout) findViewById(R.id.swipe_layout);
recyclerview = (RecyclerView) findViewById(R.id.recyclerview);
etContent = (EditText) findViewById(R.id.et_content);
cbxEmoji = (CheckBox) findViewById(R.id.cbx_emoji);
btnSend = (Button) findViewById(R.id.btn_send);
pannel = (LinearLayout) findViewById(R.id.pannel);
recyclerview.setLayoutManager(new LinearLayoutManager(this));
recyclerview.setHasFixedSize(true);
recyclerview.setAdapter(new TestAdapter());
cbxEmoji.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
if (cbxEmoji.isChecked()) {
//显示Emoji面板
pannel.setVisibility(View.VISIBLE);
etContent.clearFocus();
KeyBoardUtils.hideKeyboard(etContent);
} else {
//隐藏
pannel.setVisibility(View.GONE);
etContent.requestFocus();
KeyBoardUtils.showKeyboard(etContent);
}
}
});
recyclerview.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_DOWN) {
//点击列表部分就清除焦点并初始化状态
if (pannel.getVisibility() == View.VISIBLE) {
pannel.setVisibility(View.GONE);
cbxEmoji.setChecked(false);
} else {
etContent.clearFocus();
KeyBoardUtils.hideKeyboard(etContent);
}
}
return false;
}
});
swipeLayout.setOnRefreshListener(new SwipeRefreshLayout.OnRefreshListener() {
@Override
public void onRefresh() {
swipeLayout.setRefreshing(false);
}
});
etContent.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_DOWN && !etContent.isFocused()) {
//触摸输入框时弹出输入法
cbxEmoji.setChecked(false);
pannel.setVisibility(View.GONE);
KeyBoardUtils.showKeyboard(etContent);
}
return false;
}
});
代码很简单,我们跑起来看看效果
转换出来的Gif帧数比较低,不过我们也可以看得更仔细,软键盘显示和隐藏都不和我们的
pannel
面板同步,每次都慢一拍显得十分突兀,那么我们就来手动让它们同步。我们可以发现界面被软键盘挤压的时候界面的高度也被迫发生了变化,那么这个就是我们的切入口,我们简单的自定义一个控件
public class MeasureLinearLayout extends LinearLayout {
public MeasureLinearLayout(Context context) {
super(context);
}
public MeasureLinearLayout(Context context, AttributeSet attrs) {
super(context, attrs);
}
public MeasureLinearLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
Log.e("width", MeasureSpec.getSize(widthMeasureSpec) + "");
Log.e("height", MeasureSpec.getSize(heightMeasureSpec) + "");
}
}
再测量的时候把测量信息输出,我们把他代替我们之前Activity的布局下的根节点LinearLayout
,我们在来触发一次挤压界面:
03-30 19:03:08.050 12117-12117/com.tk.iminputdemo E/width: 1080
03-30 19:03:08.050 12117-12117/com.tk.iminputdemo E/height: 1677
03-30 19:03:08.050 12117-12117/com.tk.iminputdemo E/width: 1080
03-30 19:03:08.050 12117-12117/com.tk.iminputdemo E/height: 776
我们可以发现当要显示软键盘的时候第一次的时候测量出的值就是控件原有的值,第二次测出来就是被挤压后的值,我们之前在Activity中写的逻辑都在onMeasure
出结果以后才执行,那么我们再得知结果以前呢?
我们新建一个类KeyBoardObservable
,在我们自定义的布局里面监听onMeasure
结果,
public MeasureLinearLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
keyBoardObservable = new KeyBoardObservable();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
keyBoardObservable.beforeMeasure(heightMeasureSpec);
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
public class KeyBoardObservable {
private static final String TAG = "KeyBoardObservable";
private int lastHeight;
private List<KeyBoardObserver> observers;
private boolean keyBoardVisibile;
/**注册监听
*
* @param listener
*/
public void register(@NonNull KeyBoardObserver listener) {
if (observers == null) {
observers = new ArrayList<>();
}
observers.add(listener);
}
/**抢先测量
*
* @param heightMeasureSpec
*/
public void beforeMeasure(int heightMeasureSpec) {
int height = View.MeasureSpec.getSize(heightMeasureSpec);
Log.d(TAG, "height: " + height);
if (lastHeight == 0) {
lastHeight = height;
return;
}
if (lastHeight == height) {
//没有发生挤压
return;
}
final int offset = lastHeight - height;
if (Math.abs(offset) < DensityUtil.dp2px(80)) {
//80 dp 挤压阈值
return;
}
if (offset > 0) {
Log.d(TAG, "软键盘显示了");
keyBoardVisibile = true;
} else {
Log.d(TAG, "软键盘隐藏了");
keyBoardVisibile = false;
}
update(keyBoardVisibile);
lastHeight = height;
}
public boolean isKeyBoardVisibile() {
return keyBoardVisibile;
}
/**
* 通知更新
* @param keyBoardVisibile
*/
private void update(final boolean keyBoardVisibile) {
if (observers != null) {
for (KeyBoardObserver observable : observers) {
observable.update(keyBoardVisibile);
}
}
}
}
public interface KeyBoardObserver {
void update(boolean keyBoardVisibile);
}
代码不多,很简单采用了观察者模式来设计,因为我们用这种方式相当于可以注册了软键盘的状态(不知道Android API什么时候会有)。如果2次测量结果没有发生变化或者小于阈值(随便设的,防止一些偶然性的界面变化),那么我们Activity的业务逻辑就要改了,因为从上面的实验结果我们看出来setVisibile的调用时机不能简单的和显示/隐藏软键盘一块随便出没。
我们先来处理开启表情面板的CheckBox,逻辑如下:
if (cbxEmoji.isChecked()) {
//想要显示面板
etContent.clearFocus();
if (rootLayout.getKeyBoardObservable().isKeyBoardVisibile()) {
//当前软键盘为 挂起状态
//隐藏软键盘并显示面板
KeyBoardUtils.hideKeyboard(etContent);
} else {
//显示面板
pannel.setVisibility(View.VISIBLE);
}
} else {
//想要关闭面板
//挂起软键盘,并隐藏面板
etContent.requestFocus();
KeyBoardUtils.showKeyboard(etContent);
}
分以下几个点:
- 当我们要显示面板的时候要判断当前的状态,如果是软键盘挂起的状态(焦点在EditText上),那此时要平滑的让软键盘隐藏,并且显示面板
- 如果软键盘未挂起,也就是初始化状态,那就直接显示面板了
- 想要关闭面板的时候,那此时要平滑的让软键盘显示,并且隐藏面板
以上都是笔者在使用微信时得出的简化版业务逻辑
然后我们在Activity中注册监听:
rootLayout.getKeyBoardObservable().register(this);
@Override
public void update(boolean keyBoardVisibile) {
if (keyBoardVisibile) {
//软键盘挂起
pannel.setVisibility(View.GONE);
cbxEmoji.setChecked(false);
} else {
//回复原样
if (cbxEmoji.isChecked()) {
pannel.setVisibility(View.VISIBLE);
}
}
}
在接受到软键盘观察者的信息后,如果当前软键盘为挂起状态我们就把面板隐藏,如果软键盘要变为隐藏状态,且此时是需要显示面板的,就平滑显示面板。
由于我们此时已经能监听到软键盘的状态了,那EditText
的Touch事件就没必要监听了。
我们再来运行下看看效果:
果然同步了,仿佛像日落下风平浪静的港湾一般,那么的令人陶醉
(๑•̀ㅂ•́)و✧
但是好像我们还是触礁了(°ー°〃),由于面板的高度和软键盘的高度不一致,还是不和谐的地方,这然身为强迫症的笔者怎么能忍?
由于Android中并没有常规API来获取软键盘高度,更何况我们已经用这种方式能监听到软键盘的状态了,获取高度简直就是信手拈来,我们建一个工具类用来保存软键盘的数据:
public class SharePrefenceUtils {
public static final String KEYBOARD = "keyboard";
public static final String HEIGHT = "height";
/**
* 保存软键盘高度
*
* @param context
* @param height
*/
public static void saveKeyBoardHeight(@NonNull Context context, int height) {
context.getSharedPreferences(KEYBOARD, Context.MODE_PRIVATE).edit().putInt(HEIGHT, height).commit();
}
/**
* 获取软键盘高度,默认为界面一半高度
*
* @param context
* @return
*/
public static int getKeyBoardHeight(@NonNull Context context) {
int defaultHeight = context.getResources().getDisplayMetrics().heightPixels >> 1;
return context.getSharedPreferences(KEYBOARD, Context.MODE_PRIVATE).getInt(HEIGHT, defaultHeight);
}
}
在我们的软键盘观察者的方法也要相应的修改
public void beforeMeasure(Context context, int heightMeasureSpec) {
//上文的代码,这里就省略了
int keyBoardHeight = Math.abs(offset);
SharePrefenceUtils.saveKeyBoardHeight(context, keyBoardHeight);
update(keyBoardVisibile, keyBoardHeight);
lastHeight = height;
}
我们需要一个上下文Context
,界面被挤压的形变高度也就是我们要获取软键盘的高度了。
在Activity中我们也要相应的修改
pannel = (LinearLayout) findViewById(R.id.pannel);
//初始化高度,和软键盘一致,初值为手机高度一半
pannel.getLayoutParams().height = SharePrefenceUtils.getKeyBoardHeight(this);
@Override
public void update(boolean keyBoardVisibile, int keyBoardHeight) {
if (keyBoardVisibile) {
//软键盘挂起
pannel.setVisibility(View.GONE);
cbxEmoji.setChecked(false);
} else {
//回复原样
if (cbxEmoji.isChecked()) {
if (pannel.getLayoutParams().height != keyBoardHeight) {
pannel.getLayoutParams().height = keyBoardHeight;
}
pannel.setVisibility(View.VISIBLE);
}
}
}
我们最后来看下效果
OK,跳闪问题我们就这样解决了,这是一种监听根布局挤压,在重新onMeasure
时手动是软键盘和界面刷新同步的方案,在项目有需要时我们只要继承别的布局重写下onMeasure
就可以了,非常方便。