渐变式透明标题栏与 CollapsingToolbarLayout 折叠式标题栏

6,728 阅读9分钟

本篇文章的来源是一开始我需要实现类似 IOS 的弹簧动画,当时选择了 ScrollView +头部 Layout 来实现的,实现效果如图:

渐变式透明标题栏

可以看到,顶部标题区可以随着手指滑动而 逐渐 透明或者 逐渐 覆盖,这个效果是我实现的第一个版本的效果,原理也非常简单,首页布局如下:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
              xmlns:tools="http://schemas.android.com/tools"
              android:layout_width="match_parent"
              android:layout_height="match_parent" xmlns:app="http://schemas.android.com/apk/res-auto"
              android:orientation="vertical"
              tools:context=".ui.home.fragment.HomeFragmentD">


    <androidx.core.widget.NestedScrollView
            android:id="@+id/nsv_layout"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:overScrollMode="never">



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

            ………………

        </LinearLayout>

    </androidx.core.widget.NestedScrollView>

    <RelativeLayout
            android:id="@+id/top_layout"
            android:layout_width="match_parent"
            android:layout_height="?android:attr/actionBarSize"
            android:background="@android:color/transparent">

        <TextView
                android:id="@+id/tv_title"
                style="@style/TextAppearance.AppCompat.Widget.ActionBar.Title"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_centerInParent="true"
                android:lines="1"
                android:maxLength="12"
                tools:text="我的"
                android:ellipsize="end"
                android:textColor="@color/black80"
                android:textSize="16sp"/>


    </RelativeLayout>

</RelativeLayout>

接下来我们分为两步:

1、我们需要做的是实现状态栏透明

通过状态栏透明达到沉浸式效果。这个只要也非常简单,直接上代码:

    private fun initStatusBar() {

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
            window.decorView.systemUiVisibility =
                View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
            window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
            window.statusBarColor = Color.TRANSPARENT
            window.decorView.systemUiVisibility =
                View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR

        }

    }

2、对布局添加滑动监听 重头戏

状态栏透明以后,需要做的就是对布局的滑动添加事件,然后在滑动事件中计算滑动的距离,接下来根据滑动的距离设置头部 Layout 的透明度,说起来复杂,代码却只有十来行。

//默认透明度
 private var statusAlpha = 0
 // 添加滑动事件监听
 nsv_layout.setOnScrollChangeListener { _: NestedScrollView?, _: Int, scrollY: Int, _: Int, _: Int ->
            val headerHeight = top_layout.height
            val scrollDistance = Math.min(scrollY, headerHeight)
            statusAlpha = (255F * scrollDistance / headerHeight).toInt()
            setTopBackground()
        }
  // 设置头部透明度
  private fun setTopBackground() {

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            top_layout.setBackgroundColor(Color.argb(statusAlpha, 255, 255, 255))
            val window = activity!!.window
            window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
            window.statusBarColor = Color.argb(statusAlpha, 255, 255, 255)
        }
    }

3、优化 Toplayout 高度

因为我们这里将 Toplayout 替代了 Toolbar ,因此我们需要对 Toplayout增加状态栏的内边距,防止 Toplayout 显示出现异常。代码如下:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            val lp: RelativeLayout.LayoutParams = top_layout.layoutParams as RelativeLayout.LayoutParams
            lp.topMargin = getSystemBarHeight()
            top_layout.layoutParams = lp
        }

这样就可以实现目标效果了,但是上面也两个字叫做“逐渐”,而我现在看到有 APP 实现了当布局滑动到一定高度就直接显示,然后回退到一定高度以后就直接透明。这个逻辑通过上述代码也可以实现,我们仅仅是设置 statusAlpha 的值为0或者255时才刷新 Toplayout 的背景透明度,但是我看见人家通过 CollapsingToolbarLayout 实现的,而 CollapsingToolbarLayout 来自 Material Design包的控件,属于谷歌亲生儿子,于是我就来学习一波 CollapsingToolbarLayout 。

CollapsingToolbarLayout 折叠式标题栏

学习之前先复习一下自己的文章Material Design。文章最后一部分讲到了 CollapsingToolbarLayout 的用法及一些属性名称的用法。

1、CollapsingToolbarLayout 布局

CollapsingToolbarLayout 是不能单独使用的,它必须作为 AppBarLayout 的直接子布局来使用,而 AppBarLayout 又必须作为CoordinatorLayout 的子布局。所以我们的布局应该是这样的:

<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout
    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:fitsSystemWindows="true"
    tools:context=".MainActivity">

    <android.support.design.widget.AppBarLayout
        android:layout_width="match_parent"
        android:layout_height="256dp"
        android:fitsSystemWindows="true">

        <android.support.design.widget.CollapsingToolbarLayout
            android:id="@+id/collapsing_toolbar_layout"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:fitsSystemWindows="true"
            app:contentScrim="?attr/colorPrimary"
            app:expandedTitleMarginStart="38dp"
            app:layout_scrollFlags="scroll|exitUntilCollapsed">

            <ImageView
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:fitsSystemWindows="true"
                android:scaleType="centerCrop"
                android:src="@mipmap/h"
                app:layout_collapseMode="parallax"
                app:layout_collapseParallaxMultiplier="0.7"/>


            <android.support.v7.widget.Toolbar
                android:id="@+id/toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                app:layout_collapseMode="pin"/>

        </android.support.design.widget.CollapsingToolbarLayout>

    </android.support.design.widget.AppBarLayout>

    <android.support.v4.widget.NestedScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:scrollbars="vertical"
        app:layout_behavior="@string/appbar_scrolling_view_behavior">

        <WebView
            android:id="@+id/webview"
            android:layout_width="match_parent"
            android:layout_height="match_parent">

        </WebView>
    </android.support.v4.widget.NestedScrollView>

</android.support.design.widget.CoordinatorLayout>

2、CollapsingToolbarLayout 属性

  • app:contentScrim=”?attr/colorPrimary” 这个属性是指CollapsingToolbarLayout趋于折叠状态或者是折叠状态的时候的背景颜色,因为此时的CollapsingToolbarLayout就是一个简单的ToolBar形状,所以背景色我们还是设置系统默认的背景颜色。
  • app:expandedTitleMarginStart=”38dp” 这个属性是指设置扩张时候(还没有收缩时)title与左边的距离,不设置的时候有一个默认距离,个人感觉默认距离或许会更好。
  • app:layout_scrollFlags=”scroll|exitUntilCollapsed” 这个属性之前已经解释过是什么意思,这里将它从ToolBar给贴到CollapsingToolbarLayout里,是因为它现在做为AppBarLayout的唯一子布局了,所以这个属性就应该上一层赋值。

然后我们对CollapsingToolbarLayout内的ToolBar和ImageView的同一个属性layout_collapseMode赋予了不同的值,这个属性其实有三个值:

  • none:有该标志位的View在页面滚动的过程中会如同普通的Toolbar一样,就是简单的显示与隐藏效果
  • pin:有该标志位的View在页面滚动的过程中会一直停留在顶部,比如Toolbar可以被固定在顶部
  • parellax:有该标志位的View在页面滚动的过程中会产生位移,最后隐藏(这个位移不是垂直方向的直线运动)

ps:上面的 none 和 parellax 属性效果后面也效果图

3、CollapsingToolbarLayout 应用

知道了这些属性以后,那么该如何实现上面的效果呢? 生死看淡,不服就干,直接给代码:

<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
        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"
        tools:context=".ui.home.fragment.HomeFragmentA">

    <com.google.android.material.appbar.AppBarLayout
            android:id="@+id/app_bar"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="@color/white"
            android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
            app:elevation="0dp">
        <com.vincent.baseproject.widget.XCollapsingToolbarLayout
                android:id="@+id/ctl_top_bar"
                android:layout_width="match_parent"
                android:layout_height="256dp"
                app:contentScrim="@color/white"
                app:layout_scrollFlags="scroll|exitUntilCollapsed"
                app:scrimVisibleHeightTrigger="120dp">

            <ImageView
                    android:id="@+id/top_iv_bg"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:paddingTop="150dp"
                    android:scaleType="centerCrop"
                    android:src="@mipmap/bg_launcher"
                    app:layout_collapseMode="parallax"/>

            <androidx.appcompat.widget.Toolbar
                    android:id="@+id/top_toolbar"
                    android:layout_width="match_parent"
                    android:layout_height="?android:attr/actionBarSize"
                    app:layout_collapseMode="pin">

                <LinearLayout android:layout_width="match_parent"...>

            </androidx.appcompat.widget.Toolbar>

        </com.vincent.baseproject.widget.XCollapsingToolbarLayout>

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

    <androidx.core.widget.NestedScrollView...>

</androidx.coordinatorlayout.widget.CoordinatorLayout>

对照上面属性可以知道,ImageView 是位移动画直至隐藏,然后 Toolbar 是始终不变位置。我们看看效果:

如果这么看,依然不清楚的话,那么看看去掉动画是什么样,我们修改 ImageView 布局看看效果:

<ImageView
		android:id="@+id/top_iv_bg"
		android:layout_width="match_parent"
		android:layout_height="wrap_content"
		android:paddingTop="150dp"
		android:scaleType="centerCrop"
		android:src="@mipmap/bg_launcher"
		app:layout_collapseMode="none"/>

仔细看的话,应该可以看见上拉时 ImageView 是被挤上去,下滑的时候又直愣愣放下来,而前面的 parallax 属性产生了一个动画效果,就是上拉的时候头部有挤压效果,但是没有被直接隐藏(即图片的顶部一开始没有被直接隐藏),下滑也是类似,多看两遍效果图还是很明显的。

但是 CollapsingToolbarLayout layout_scrollFlags属性是什么意思呢?这个上面的文章里面也有,还配有效果图。补充一个上面文章没有说清楚的一个选项: snap 。即 CollapsingToolbarLayout 如果使用 layout_scrollFlags 属性的 snap 选项时,需配合其它属性才行:

app:layout_scrollFlags="scroll|snap" 效果如下:

4、CollapsingToolbarLayout 重写——获取自定义属性

现在实现效果之后还有一个问题,就是需要对 CollapsingToolbarLayout 展开与折叠的状态进行回调,不然折叠的时候我们的地区两个字已经被白色覆盖了,需要在折叠的时候设置一个其它的颜色。设置颜色的时候需要说明一个问题,对于系统的 title 是支持属性来设置颜色的,但是我们这里属于自定义头部,因此只能自己想办法通过事件来判断,最后我们找到 CollapsingToolbarLayout 的回调方法:

 public void setScrimsShown(boolean shown, boolean animate) {
        if (this.scrimsAreShown != shown) {
            if (animate) {
                this.animateScrim(shown ? 255 : 0);
            } else {
                this.setScrimAlpha(shown ? 255 : 0);
            }

            this.scrimsAreShown = shown;
        }

    }

由于是回调方法并不是接口回调,因此我们需要继承 CollapsingToolbarLayout 并重写 setScrimsShown 方法才能实现回调的接口,代码如下:

class XCollapsingToolbarLayout : CollapsingToolbarLayout {

     var mListener: OnScrimsListener? = null // 渐变监听
     var isCurrentScrimsShown: Boolean = false  // 当前渐变状态

    constructor(context: Context) : super(context)
    constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)

    override fun setScrimsShown(shown: Boolean, animate: Boolean) {
        super.setScrimsShown(shown, animate)
        if(isCurrentScrimsShown != shown){
            isCurrentScrimsShown = shown
            mListener?.onScrimsStateChange(shown)
        }
    }

    /**
     * CollapsingToolbarLayout渐变监听器
     */
    interface OnScrimsListener {

        /**
         * 渐变状态变化
         *
         * @param shown         渐变开关
         */
        fun onScrimsStateChange(shown: Boolean)
    }


}

实现了自定义,我们就可以通过接口回调在折叠和展开的第一时间来设置我们想要的背景和颜色:

      ctl_top_bar.mListener = object : XCollapsingToolbarLayout.OnScrimsListener {
            override fun onScrimsStateChange(shown: Boolean) {
                if (shown) {
                    homeA_tv_address.setTextColor(
                        ContextCompat.getColor(
                            context!!,
                            com.vincent.baseproject.R.color.black
                        )
                    )
                } else {
                    homeA_tv_address.setTextColor(
                        ContextCompat.getColor(
                            context!!,
                            com.vincent.baseproject.R.color.white
                        )
                    )
                }
                homeA_tv_search.isSelected = shown
            }

        }

效果咋样,瞅一瞅:

OK,目前我们就实现了将第一种头部的渐变修改为 Material Design 设计为瞬间改变。但是我们能不能使用 CollapsingToolbarLayout 来实现头部背景的渐变呢?要实现这个效果,我们需要看看折叠和展开是的标志位是根据什么来判断的?查看源码的 setScrimsShown 方法:

public void setScrimsShown(boolean shown) {
        this.setScrimsShown(shown, ViewCompat.isLaidOut(this) && !this.isInEditMode());
    }

public void setScrimsShown(boolean shown, boolean animate) {
	if (this.scrimsAreShown != shown) {
		if (animate) {
			this.animateScrim(shown ? 255 : 0);
		} else {
			this.setScrimAlpha(shown ? 255 : 0);
		}

		this.scrimsAreShown = shown;
	}

}

这个时候我们在本类全局搜索 setScrimsShown 方法,看看是什么地方传入的 shown ,判断标准是什么?

final void updateScrimVisibility() {
	if (this.contentScrim != null || this.statusBarScrim != null) {
		this.setScrimsShown(this.getHeight() + this.currentOffset < this.getScrimVisibleHeightTrigger());
	}

}

走到这里我们发现,通过正常的办法是没有办法实现渐变的,因为我们需要拿不到高度、偏移值。再查询 updateScrimVisibility 方法发现这个方法被调用的地方也3处:


        
   // 343 行     
  protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
	super.onLayout(changed, left, top, right, bottom);
 ......

	this.updateScrimVisibility();
}

// 653行
 public void setScrimVisibleHeightTrigger(@IntRange(from = 0L) int height) {
	if (this.scrimVisibleHeightTrigger != height) {
		this.scrimVisibleHeightTrigger = height;
		this.updateScrimVisibility();
	}

}

// 734行:(私有类)
 private class OffsetUpdateListener implements OnOffsetChangedListener {
	OffsetUpdateListener() {
	}

	public void onOffsetChanged(AppBarLayout layout, int verticalOffset) {
		......
		CollapsingToolbarLayout.this.updateScrimVisibility();
		......
    }
}

查看源码得知就算我们重写前面两处调用 updateScrimVisibility 的方法,也不能重写第三处。因此正常手段是不能实现渐变的。那么其它非正常手段呢?比如在 setScrimsShown 处使用反射拿到总高度、偏移量,算出一个百分比,也是可以的,但是这样暴力操作也没有必要。除非是特地场景,一般情况下还是不要去反射拿取系统非公开的字段或方法。既然源码设置这些权限修饰符,肯定是有原因的,假设下一个版本修改这个属性的话,APP 就要出问题了!

我还看到通过计算 AppBarLayout 的偏移量来实现头部的渐变,这个奇技淫巧也比我们暴力获取 api 要好得多,参考地址:使用AppBarLayout+CollapsingToolbarLayout实现自定义工具栏折叠效果

一个知识点,从盲区到技能点,完成以后觉得不过如此,但是学习的过程中每个人都是费尽九牛二虎之力才走到熟悉。谨以此文来纪念那些天各个QQ群提问的烤鱼!

源码:

自定义渐变透明式标题栏
CollapsingToolbarLayout 可折叠式标题栏

补充 CollapsingToolbarLayout 属性

可折叠式标题栏 -- CollapsingToolbarLayout 的属性

  • 设置展开之后 toolbar 字体的大小
app:expandedTitleTextAppearance="@style/toolbarTitle"
 
  <style name="toolbarTitle" >
        <item name="android:textSize">12sp</item>
    </style>
  • 设置折叠之后 toolbar 字体的大小
app:collapsedTitleTextAppearance="@style/toolbarTitle"

  <style name="toolbarTitle" >
        <item name="android:textSize">12sp</item>
    </style>
  • 设置展开之后 toolbar 标题各个方向的距离
//展开之后的标题默认在左下方,只有以下这两个属性管用

//距离左边的 margin 值
app:expandedTitleMarginStart="0dp"
//距离下方的 margin 值
app:expandedTitleMarginBottom="0dp"
  • 设置展开之后 toolbar 标题下方居中
app:expandedTitleGravity="bottom|center"
  • 设置标题不移动,始终在 toolbar 上
app:titleEnabled="false"
  • 设置 toolbar 背景颜色
//如果设置状态栏透明的话,状态栏会跟toolbar颜色一致
app:contentScrim="@color/colorPrimaryDark"
  • 设置合并之后的状态栏的颜色
//如果设置状态栏透明,则此属性失效
app:statusBarScrim="@color/colorAccent"