Android软键盘与布局的协调-不同的效果与实现方案的探讨

4,592 阅读10分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第7天,点击查看活动详情

前言

在之前软键盘的高度的文章中,我们定义了一些兼容性相对比较好的工具类。

我们是把布局单独提取出来,然后放在软键盘上面跟随动画,那么如果是布局中的EditText,该如何适配软键盘呢?

之前的文章我们提到过我们可以通过添加Flag,和替换滚动布局等方式来适配软键盘,但是这几种方式都不是那么完美,如果想要一些定制的效果,我们又该如何适配布局中的 EditText或View 的软键盘高度适配呢?

接下来我们看看各种不同的布局与不同处理方式。

软键盘的Flag方案

其实大家或多或少的都知道,一个Activity中当软键盘弹起之后,我们的内容布局要做怎样的变化,是根据我们添加 windowSoftInputMode 属性来决定的。

而我们常用的几个 windowSoftInputMode 就是 adjustUnspecified adjustResize adjustPan adjustNoting 四种,其中更高频的其实就是两种 adjustResize 和 adjustPan。

两者的区别:

image.png

在一个默认的布局示例中,我们可以看看他们的区别:

布局是为普通的固定布局,顶部一个TextView,下面一个ImageView,一个EditText。

当清单文件中设置为 android:windowSoftInputMode="adjustPan"时:

softinput_01.gif

当我们把清单文件设置为 android:windowSoftInputMode="adjustResize"时:

softinput_02.gif

但是当我们把布局调整为滚动布局ScrollView之后,设置清单文件配置为 android:windowSoftInputMode="adjustPan"时:

softinput_03.gif

这个效果就是刚刚好,所以我们通常得出一个结论:

想要EditText适配软键盘,不能滚动的布局中我们使用 adjustPan, 而在能滚动的布局中,我们使用 adjustResize。

adjustPan 属性为了空出软键盘的位置,自动平移窗口的内容。而 adjustResize 会重新绘制布局,如果能滚动则会滚动到对应的位置,相当的智能。

所以老师也是教我们这么使用的,固定搭配!那我们就只能这么固定用了吗?又有没有其他别的方式呢?

约束在底部的方法

根据上面的效果图我们知道 adjustSpan 是平移布局,adjustResize 是重新绘制一个新的显示区域。

那么我们可以根据 adjustResize 这一个特性,我们把布局固定在底部即可。 LinearLayout RelativeLayout FrameLayout ConstraintLayout 都可以做到这个效果。RelativeLayout FrameLayout ConstraintLayout 三者只需要把布局约束在底部即可,而 LinearLayout 我们可以通过权重来实现这个效果。

如下的布局,可以使用多种方式实现

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:orientation="vertical"
    android:background="@color/white"
    android:layout_height="match_parent">
        
        <View
            android:layout_weight="1"
            android:layout_width="1dp"
            android:layout_height="0dp"/>

<!--    <RelativeLayout-->
<!--        android:layout_width="wrap_content"-->
<!--        android:layout_height="wrap_content"-->
<!--        android:layout_alignParentBottom="true"-->
<!--        android:layout_centerHorizontal="true"-->
<!--        android:layout_gravity="center_horizontal|bottom">-->

        <EditText
            android:id="@+id/editText"
            android:layout_gravity="center_horizontal|bottom"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_alignParentStart="true"
            android:layout_marginTop="0dp"
            android:layout_marginBottom="0dp"
            android:hint="输入内容" />

<!--    </RelativeLayout>-->


</LinearLayout>

默认的布局如下:

image.png

当弹出软键盘之后:

image.png

可以看到 adjustResize 之后我们的布局为黑色框的部分了,如果是顺序排列的,那么就会出现上面那种没有变化的效果,而我们把EditText永远约束在布局底部,就算Resize了,我们还是在底部,就能间接的实现软键盘在键盘上面的效果。

image.png

那我的布局不方便约束在底部,或者我的布局就是顺序排列的,那我就想实现顶部的图片不动,让EditText在软键盘上面,能不能做?

手动偏移的方法

可以的,我们同时也需要设置 adjustResize 模式,在重新绘制的时候,我们动态的计算当前父容器的高度,父容器的当前位置,指定View的位置等信息,我们就能计算当前View需要偏移的位置,手动的位移指定的布局。

首先我们需要拿到需要适配的View和它的父布局,为了适配,我们需要拿到底部导航栏的高度

    public void adjustETWithSoftInput(final View anyView, final ISoftInputChanged listener) {
        if (anyView == null || listener == null)
            return;

        //根View
        final View rootView = anyView.getRootView();
        if (rootView == null) return;

        getNavigationBarHeight(anyView, new NavigationBarCallback() {
            @Override
            public void onHeight(int height, boolean hasNav) {

                SoftInputUtil.this.navigationHeight = height;

                //anyView为需要调整高度的View,理论上来说可以是任意的View
                SoftInputUtil.this.anyView = anyView;
                SoftInputUtil.this.rootView = rootView;
                SoftInputUtil.this.listener = listener;
                SoftInputUtil.this.isNavigationBarShow = hasNav;
                SoftInputUtil.this.myListener = new myListener();

                rootView.addOnLayoutChangeListener(myListener);

            }
        });

    }

getNavigationBarHeight 方法具体的实现在我们之前的文章中有讲到过。不熟悉的可以看这:Android导航栏的处理

拿到导航栏高度之后,我们通过监听父布局的 LayoutChangeListener 监听,由于我们使用 adjustResize 模式,我们的布局会重新布局,所以每次软键盘弹起和收回的时候都会回调到这个方法,我们的逻辑判断则写到对应的监听中。

 //RootView的监听回调
    class myListener implements View.OnLayoutChangeListener {

        @Override
        public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) {

            int rootHeight = rootView.getHeight();

            Rect rect = new Rect();
            //获取当前可见部分,默认可见部分是除了状态栏和导航栏剩下的部分
            rootView.getWindowVisibleDisplayFrame(rect);

            //还是要每次回调的时候判断是否有导航栏
            if (rootHeight - rect.bottom == navigationHeight) {
                //如果可见部分底部与屏幕底部刚好相差导航栏的高度,则认为有导航栏
                isNavigationBarShow = true;
            } else if (rootHeight - rect.bottom == 0) {
                //如果可见部分底部与屏幕底部平齐,说明没有导航栏
                isNavigationBarShow = false;
            }

            //判断软键盘是否展示并计算软键盘的高度
            boolean isSoftInputShow = false;
            int softInputHeight = 0;
            //如果有导航栏,则要去除导航栏的高度
            int mutableHeight = isNavigationBarShow ? navigationHeight : 0;
            if (rootHeight - mutableHeight > rect.bottom) {
                //除去导航栏高度后,可见区域仍然小于屏幕高度,则说明键盘弹起了
                isSoftInputShow = true;
                //键盘高度
                softInputHeight = rootHeight - mutableHeight - rect.bottom;
                if (SoftInputUtil.this.softInputHeight != softInputHeight) {
                    softInputHeightChanged = true;
                    SoftInputUtil.this.softInputHeight = softInputHeight;
                } else {
                    softInputHeightChanged = false;
                }
            }

            //获取目标View的位置坐标
            int[] location = new int[2];
            anyView.getLocationOnScreen(location);

            if (isSoftInputShowing != isSoftInputShow || (isSoftInputShow && softInputHeightChanged)) {
                if (listener != null) {
                    //第三个参数为该View需要调整的偏移量
                    //此处的坐标都是相对屏幕左上角(0,0)为基准的
                    listener.onChanged(isSoftInputShow, softInputHeight, location[1]- rect.bottom  + anyView.getHeight() );
                }

                isSoftInputShowing = isSoftInputShow;
            }

        }
    }

获取到父布局的高度和可见矩阵,我们就可以计算是否有导航栏和软键盘的高度。获取到当前Viewd的坐标之后,拿到Y坐标加上当前View的高度,减去当前可见矩阵的高度,就是我们需要的偏移量。

image.png

使用起来也很简单。

    override fun init() {
        
        val etInput = findViewById<EditText>(R.id.et_input)
        etInput.bringToFront()

        softInputUtil.adjustETWithSoftInput(etInput) { isSoftInputShow, softInputHeight, viewOffset ->

            if (isSoftInputShow) {
                etInput.translationY = etInput.translationY - viewOffset
            } else {
                etInput.translationY = 0f;
            }

        }

    }

    override fun onDestroy() {
        super.onDestroy()

        softInputUtil.releaseETWithSoftInput()
    }

我们手动的设置偏移 translationY 即可实现

现在我们设置 windowSoftInputMode 为 adjustResize 然后我们可以对比一下 adjustPan 的效果:

adjustPan效果:

softinput_01.gif

adjustResize + 自定义偏移效果:

softinput_05.gif

可以看到不同点就是顶部的图片和标题栏不会往上滚动,缺点是使用起来相对麻烦一点,需要自己计算和位移。

列表中自动定位逻辑

在之前的演示中,我们设置默认的 windowSoftInputMode 或者指定为 adjustResize 的时候,在我们滚动布局中是可以很好的支持的。那么在列表中使用软键盘会怎么样?

当然了我们一般不会在列表的Item中直接使用EditText,有复用问题,就算我们解决了性能也没有那么好,我们通常的做法是像微信朋友圈一样,使用一个按钮触发一个输入弹窗,在弹窗中使用软键盘。

那么这种输入框布局下面是软键盘的做法,其实有几种做法,我们之前的文章讲软键盘的高度的一文中我们介绍了一种布局附着在软键盘的做法,而另一种做法就是使用滚动布局来实现了。

由于滚动布局天然就能很好的支持软键盘和EditText的联动,所以我们直接在Dialog的布局中使用滚动布局即可完成指定的效果。

<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fillViewport="true">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

        <View
            android:layout_width="1dp"
            android:layout_height="0dp"
            android:layout_weight="1" />

        <View
            android:layout_width="match_parent"
            android:layout_height="1dp"
            android:background="#d2d2d2">

        </View>

        <LinearLayout
            android:id="@+id/dialog_layout_comment"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="@color/white">

            <EditText
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_weight="1" />

            <Button
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="确认" />

        </LinearLayout>


    </LinearLayout>

</ScrollView>

Dialog中我们就能使用布局了

@SuppressLint("ClickableViewAccessibility")
public class ReviewDialog extends Dialog {

    public ReviewDialog(Context context) {
        this(context, R.style.quick_option_dialog);
    }

    //两个参数的构造方法实现具体的逻辑
    public ReviewDialog(Context context, int themeResId) {
        super(context, themeResId);

        View view = LayoutInflater.from(context).inflate(R.layout.dialog_review, null);


        requestWindowFeature(Window.FEATURE_NO_TITLE);  //设置没有标题
        //设置触摸一下整个View.让其可取消。触摸(不是点击)对话框任意地方取消对话框
        view.setOnTouchListener((View view1,  MotionEvent motionEvent) -> {
            dismiss();
            return true;  //消费掉此次触摸事件
        });

        //View设置完成,赋值给dialog对话框
        super.setContentView(view);

    }


    /**
     * 对话框被创建调用的方法
     */
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        //设置对话框显示的位置.在底部显示
        getWindow().setGravity(Gravity.BOTTOM);
        //设置对话框的宽度,填充屏幕
        WindowManager wm = getWindow().getWindowManager();
        Display display = wm.getDefaultDisplay();
        int width = display.getWidth();
        //获取对话框的属性
        WindowManager.LayoutParams params = getWindow().getAttributes();
        params.width = width; //和屏幕一样宽
        getWindow().setAttributes(params);
    }
}

我们定义RV和Item的布局,然后填充一些假数据,看看效果试试:

    override fun init() {

        val datas = mutableListOf<String>()
        for (i in 1..20) {
            datas.add(i.toString())
        }

        findViewById<RecyclerView>(R.id.rv_list)
            .vertical()
            .bindData(datas, R.layout.item_soft_input_demo) { holder, t, position ->
                holder.getView<TextView>(R.id.tv_review).click {
                    showReviewDialog(it,position)
                }
            }

    }

    private fun showReviewDialog(view: View, position: Int) {
        ReviewDialog(mActivity)
            .show()
    }

效果:

softinput_06.gif

我们点击评论,使用滚动布局的弹窗来控制软键盘与EditText,弹出弹窗之后软键盘和输入框完美的契合。当然如果你想Dialog弹出的时候自动出现软键盘,那么直接在Dialog的onCreate中给EidtText开启软键盘即可。

虽然能实现效果了,但是现在还有问题!什么问题? 我点击评论按钮,弹框和软键盘的高度加起来把我的评论按钮挡住了,微信朋友圈的做法是会自动滚动列表,让评论按钮在输入框的上面。

我们结合之前手动偏移的方法稍微修改一下,让RV滚动一下即可。

image.png

我们让软键盘自动弹起,并延时350毫秒获取软键盘弹出之后的高度,为什么是350毫秒,因为我的Dialog动画theme是300毫秒,我们让软键盘展示出来再处理就相对简单一点。否则还需要做布局变化的监听相对麻烦一点。

    private fun showReviewDialog(view: View, position: Int) {
        val rvReviewY = getY(view)
        val rvReviewHeight = view.height

        val dialog = ReviewDialog(mActivity)
        dialog.show()


     view.postDelayed({
        //等待弹窗弹起自后再获取到Y的高度,就是加上了软键盘之后的高度了
        val etReviewY = getY(dialog.findViewById<LinearLayout>(R.id.dialog_layout_comment))

        val offsetY = rvReviewY - etReviewY + rvReviewHeight

        rvList.smoothScrollBy(0, offsetY)

      }, 350)

    }

    private fun getY(view: View): Int {
        val rect = IntArray(2)
        view.getLocationOnScreen(rect)
        return rect[1]
    }

这样的效果就和微信朋友圈的效果比较类似了:

softinput_07.gif

总结

很多方案都是网上现有的方案,这里我也是做了一些归纳与整理。

本文也只是记录了应用层的设置,如果对源码感兴趣可以去搜索查看 ViewRootImpl 类,在其中的 public void handleMessage(Message msg) 方法中有对应的Flag处理,其中一些重点的方法 dispatchApplyInsets performTraversals dispatchOnPreDraw scrollToRectOrFocus 等,如果大家有兴趣可以自行查阅。

常用的几种效果大致就是这些了,一般的效果我们使用 固定布局+ adjustPan 或 滚动布局 + adjustResize 即可实现默认的效果了。

如果想要一些特殊的效果,我们就能设置 adjustResize 之后自行实现一些一些位移和定制的效果,计算位移的一些公式都是相对固定和简单的一些用法。

而在列表中我们可以通过View依附软键盘的方式,也可以使用 SrollView+EditText 的方式来实现效果。都可以满足需求效果是一致的。

Ok,本文的全部代码已经开源,想查看效果可以点击源码运行测试哦。

本文的环境与设备都是基于API30实现,由于隔离在家了没有那么多的设备参与测试,如果有兼容性问题欢迎大家反馈呀。

惯例,如有错漏还请指出,如果有更好的方案也欢迎留言区交流。

如果感觉本文对你有一点点的启发,还望你能点赞支持一下,你的支持是我最大的动力。

Ok,这一期就此完结。