在開發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
就可以了,非常方便。