聊天输入框跳闪的解决方案

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

完整代码戳这里

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容