ConstraintLayout + fitsSystemWindows 引起的控件错位问题

2,763 阅读2分钟

前言

这两天新写一个Demo时发现了几个UI问题:

  • fragment内顶部AppBarLayout的高度缺失(缺失高度即系统状态栏高度), 切换Fragment时才会恢复;
  • RecyclerView顶部缩入了AppBarLayout中, 缩入高度即系统状态栏高度, 而且也在切换Fragment时才会恢复;

现象如下

  • AppBarLayout高度缺失: AppBarLayoutAppBar高度缺失
  • RecyclerView顶部缩入了AppBar: RecyclerView缩入

布局文件如下

  • activity布局
<LinearLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        tools:context=".activity.MainActivity">

    <androidx.viewpager.widget.ViewPager
            android:id="@+id/viewPager"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="1"
            android:overScrollMode="never"/>

    <com.google.android.material.tabs.TabLayout
            android:id="@+id/tabLayout"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="@color/colorButtonNormal"
            app:tabIndicator="@null"
            app:tabIndicatorHeight="0dp"
            app:tabMode="fixed"/>

</LinearLayout>
  • fragment布局
<androidx.constraintlayout.widget.ConstraintLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

    <com.google.android.material.appbar.AppBarLayout
            android:id="@+id/appBarLayout"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:fitsSystemWindows="true" /** 注意 **/
            app:layout_constraintTop_toTopOf="parent">

        <androidx.appcompat.widget.Toolbar
                android:id="@+id/toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                android:theme="@style/ActionBar"
                app:popupTheme="@style/ThemeOverlay.AppCompat.Light"
                app:navigationIcon="@drawable/icon_search"
                app:title=" ">

            <androidx.appcompat.widget.AppCompatTextView
                    android:id="@+id/tvBarTitle"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_gravity="center"
                    android:text="多媒体稿"
                    android:singleLine="true"
                    android:textColor="@color/white"
                    android:textSize="20sp"/>
        </androidx.appcompat.widget.Toolbar>

    </com.google.android.material.appbar.AppBarLayout>

    <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/rcvDraft"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            app:layout_constraintTop_toBottomOf="@id/appBarLayout"
            app:layout_constraintBottom_toBottomOf="parent"/>

</androidx.constraintlayout.widget.ConstraintLayout>

解决路程

考虑几个UI错位的高度均是系统状态栏高度, 而且在切换fragment之后就恢复, 猜测是Activity创建FragmentAppBarLayoutWindowInsetsCompat还没有来得及更新, 导致AppBarLayoutpaddingTop计算错误导致, 所以测试了一下延迟设置的方式.

  • AppBarLayout中的WindowInsetsCompat更新操作

        public AppBarLayout(Context context, AttributeSet attrs, int defStyleAttr) {
            ViewCompat.setOnApplyWindowInsetsListener(
                this,
                new androidx.core.view.OnApplyWindowInsetsListener() {
                  @Override
                  public WindowInsetsCompat onApplyWindowInsets(View v, WindowInsetsCompat insets) {
                    return onWindowInsetChanged(insets); // 更新WindowInsetsCompat
                  }
                });
        }    
        @Override
        protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
            final int heightMode = MeasureSpec.getMode(heightMeasureSpec);
            if (ViewCompat.getFitsSystemWindows(this)) {
                // 简化大量代码
                int newHeight = getMeasuredHeight();
                newHeight += getTopInset();
                setMeasuredDimension(getMeasuredWidth(), newHeight);
            }
        }   
        final int getTopInset() {
            return lastInsets != null ? lastInsets.getSystemWindowInsetTop() : 0;
        } 
    
  • 修改Activity中设置Fragment方式为post, 解决TabBarLayout高度显示错误

        override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    
        // 解决TabBarLayout不能fitSystem的问题
        tabLayout.post {
            initFragments()
            viewPager.adapter = MainPagerAdapter(fragmentList, supportFragmentManager)
            tabLayout.setupWithViewPager(viewPager)
            initTabs()
        }
    }
    
  • 修改Fragment中更新RecyclerView方式为post, 解决RecyclerView顶部缩入了AppBarLayout问题

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        // 解决RCV缩入了AppbarLayout的问题
        rcvDraft.post {
            rcvDraft.adapter = draftAdapter
        }
    }
    

修改之后问题确实消失了, 但是感觉不应该, fitsSystemWindowsAppBarLayout都出现了这么长时间了, 不应该还会有这样的问题, 又搜了一下, 果然发现端倪了.

根本原因

说出来都生气, 妈个鸡, 原来是ConstraintLayout的bug, 出问题时使用的是androidx.constraintlayout:constraintlayout:2.0.0-beta2, 在Maven仓库中找到最新的正式版(1.1.3)更换后就没有问题了, 根本不用什么post.

正常

可能这里有小伙伴要问了, 为什么ConstraintLayout内部控件的fitsSystemWindows要受其父布局影响呢?

简单说就是WindowInsets是自父布局到子布局传递的, 就像TouchEvent一样, 涉及到消耗和传递, 这里就不重复讲了, 详细点的可以看下这篇文章: 带你彻底弄懂状态栏透明的细节 —— 深入分析 fitsSystemWindows

另外, 还有一个RecyclerView.Adapter#notifyDataSetChanged()无效/必须滑动一下才会更新的问题, 也是因为这个ConstraintLayout版本的bug.