CoordinatorLayout之Behavior的相关实例

仿知乎底部导航栏隐藏效果

       效果如下:

      AppBarLayout在向上滑动的时候会隐藏,向下滑动的时候会展示,需要给AppBarLayout的子View设置app:layout_scrollFlags=”scroll|enterAlways”,核心代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar">

<RelativeLayout
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/colorPrimary"
app:layout_scrollFlags="scroll|enterAlways">

...
</RelativeLayout>

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

      这里使用RadioGroup来实现tab的切换,可以看到,向上滑动的时候它会隐藏,向下滑动的时候会显示,其实只是给其设置了behavior。

第一种实现方式

       代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
* 知乎效果底部behavior 依赖于 AppBarLayout
*/
public class FooterBehaviorDependAppBar extends CoordinatorLayout.Behavior<View> {

public static final String TAG = "FooterBehavior";

public FooterBehaviorDependAppBar(Context context, AttributeSet attrs) {
super(context, attrs);
}

//当 dependency instanceof AppBarLayout 返回TRUE,将会调用onDependentViewChanged()方法
@Override
public boolean layoutDependsOn(@NonNull CoordinatorLayout parent, @NonNull View child, @NonNull View dependency) {
return dependency instanceof AppBarLayout;
}

@Override
public boolean onDependentViewChanged(@NonNull CoordinatorLayout parent, @NonNull View child, @NonNull View dependency) {
//根据dependency top值的变化改变 child 的 translationY
float translationY = Math.abs(dependency.getTop());
child.setTranslationY(translationY);
Log.i(TAG, "onDependentViewChanged: " + translationY);
return true;
}
}

       child是我们要改变的坐标的view,它会随着dependency坐标的改变而改变。把这个FooterBehaviorDependAppBar设置给RadioGroup的时候,这时候child就是RadioGroup,而dependency就是AppBarLayout,因为我们在layoutDependsOn方法里面,返回dependency instanceof AppBarLayout,即当dependency是AppBarLayout或者AppBarLayout的子类的时候返回true。之所以RadioGroup在向上滑动的时候会隐藏,向下滑动的时候会显示,是因为在onDependentViewChanged方法里,动态地根据dependency的top值改变RadioGroup的translationY值。

第二种实现方式

       主要是根据onStartNestedScroll()和onNestedPreScroll()方法来实现的,开始滑动时,判断是否是垂直滑动,如果是返回true,返回true会接着调用onNestedPreScroll()等一系列方法。

1
2
3
4
5
//判断滑动的方向 我们需要垂直滑动
@Override
public boolean onStartNestedScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull View child, @NonNull View directTargetChild, @NonNull View target, int axes) {
return (axes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
}

       在onNestedPreScroll()方法里面,根据显示规则来决定是否显示target,即向上滑动时,如果滑动的距离超过target的高度并且当前是可见的状态下,执行动画隐藏target;当前向下滑动并且View是不可见的情况下,执行动画显示target。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//根据滑动的距离显示和隐藏footer view
@Override
public void onNestedPreScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull View child, @NonNull View target, int dx, int dy, @NonNull int[] consumed) {

if (dy > 0 && sinceDirectionChange < 0 || dy < 0 && sinceDirectionChange > 0) {
child.animate().cancel();
sinceDirectionChange = 0;
}
sinceDirectionChange += dy;
int visibility = child.getVisibility();
if (sinceDirectionChange > child.getHeight() && visibility == View.VISIBLE) {
hide(child);
} else {
if (sinceDirectionChange < 0) {
show(child);
}
}
}

       完整代码见FooterBehavior

两种实现方法比较

  • 第一种方法主要是重写layoutDependsOn和onDependentViewChanged这两个方法,在layoutDependsOn判断dependency是否是AppBarLayout的实现类,所以会导致child依赖于AppBarLayout,实现简单但是灵活性不是太强。

  • 第二种方法主要是重写onStartNestedScroll和onNestedPreScroll这两个方法,判断是否是垂直滑动,是的话就进行处理,灵活性大大增强。

  • 需要注意的是不管是第一种方法还是第二种,都需要重写带两个构造方法的函数。

仿虾米音乐的首页效果

       效果如下:

       这个页面从上到下大致可以分为searchbar、header、banner、list四个部分,还有一个部分bottom是一直在底部没有互动的,先暂时不管。这里将list定为愿意接受滑动事件的部分,这几个部分的依赖关系为:searchbar→header→banner→list。

List

       先从依赖的根源开始写相应的behavior,即ListBehavior。从上面的gif图来看,初始位置是在searchbar、header、banner的下面的,当滑动到最上面的时候在header的下面,所以可以在onLayoutChild里,先确定能显示列表最大的时候的位置,用child.layout来确定宽和高。再通过setY把它放到初始位置。

1
2
3
4
5
6
7
@Override
public boolean onLayoutChild(@NonNull CoordinatorLayout parent, @NonNull View child, int layoutDirection) {
//定位list的初始位置,并进行第一次的位移
child.layout(0, headerHeight, child.getMeasuredWidth(), parent.getMeasuredHeight() - bottomHeight);
child.setY(searchBarHeight + headerHeight + bannerHeight);
return true;
}

       这几个height都会在behavior构造的时候初始化。首先在onStartNestedScroll直接返回true,表示愿意接收滑动事件。然后在onNestedPreScroll开始处理滑动事件,这里需要根据list当前的位置和滑动的方向来判断是否需要消费这个滑动事件。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
@Override
public void onNestedPreScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull View child, @NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {
if (startPos) {
if (scrollMode == 0) {
if (child.getY() == headerHeight) {
//当list处于页面的顶部,需要根据滑动的方向来确定是滚动模式还是嵌套滑动模式
if (dy > 0) {
//向上滑动 进入滚动模式
scrollMode = 1;
} else {
//向下滑动 进入嵌套滑动模式
scrollMode = -1;
}
} else {
//当list不处于页面顶部,进入嵌套滑动模式
scrollMode = -1;
}
}
//是滚动模式,不触发嵌套滑动 直接返回
if (scrollMode > 0) return;
if (dy > 0) {
if (child.getY() > headerHeight) {
//只有未到达顶部 list才能嵌套滑动 不然滑不动界面
float targetPos = child.getY() - dy;
if (targetPos > headerHeight) {
child.setY(targetPos);
} else {
child.setY(headerHeight);
}
}
} else {
if (child.getY() < searchBarHeight + headerHeight + bannerHeight) {
//只有未到达底部 list才能嵌套滑动 不然滑不动界面
float targetPos = child.getY() - dy;
if (targetPos < searchBarHeight + headerHeight + bannerHeight) {
child.setY(targetPos);
} else {
child.setY(searchBarHeight + headerHeight + bannerHeight);
}
}
}
//嵌套滑动 无论什么情况 都要消费掉所有事件
consumed[1] = dy;
}
}

       在onInterceptTouchEvent中,当接受到DOWN事件时,重置一些状态。当列表滑到顶部的时候,需要断掉滑动。然后再次执行滑动,才能继续滑动效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
public boolean onInterceptTouchEvent(@NonNull CoordinatorLayout parent, @NonNull View child, @NonNull MotionEvent ev) {
if (ev.getAction() == 0) {
if (child.getScrollY() > 0) {
//List没有滚到顶部,先进行自己滚动,不触发嵌套滑动
startPos = false;
} else {
//List滚动到顶部,待确定触发嵌套滑动
startPos = true;
}
//重置一下 是否滚动模式
scrollMode = 0;
}
return super.onInterceptTouchEvent(parent, child, ev);
}

       Banner的思路就很简单,首先在layoutDependsOn里确定依赖关系。

1
2
3
4
@Override
public boolean layoutDependsOn(@NonNull CoordinatorLayout parent, @NonNull View child, @NonNull View dependency) {
return dependency.getId() == R.id.view_content;
}

       然后在onDependentViewChanged里,根据dependency的位置来更新自己的位置。

1
2
3
4
5
6
7
8
9
10
@Override
public boolean onDependentViewChanged(@NonNull CoordinatorLayout parent, @NonNull View child, @NonNull View dependency) {
if (dependency.getY() >= headerHeight && dependency.getY() <= headerHeight + bannerHeight) {
child.setAlpha((dependency.getY() - headerHeight) / bannerHeight);
child.setY(headerHeight);
} else if (dependency.getY() > headerHeight + bannerHeight) {
child.setY(dependency.getY() - bannerHeight);
}
return true;
}

       HeaderBehavior也先确定依赖关系:

1
2
3
4
@Override
public boolean layoutDependsOn(@NonNull CoordinatorLayout parent, @NonNull View child, @NonNull View dependency) {
return dependency.getId() == R.id.img_banner;
}

       然后再根据dependency的位置来更新自己的位置:

1
2
3
4
5
6
7
8
9
@Override
public boolean onDependentViewChanged(@NonNull CoordinatorLayout parent, @NonNull View child, @NonNull View dependency) {
if (dependency.getY() - child.getMeasuredHeight() > 0) {
child.setY(dependency.getY() - child.getMeasuredHeight());
} else {
child.setY(0);
}
return true;
}

       代码如下:

1
2
3
4
5
6
7
8
9
10
11
@Override
public boolean layoutDependsOn(@NonNull CoordinatorLayout parent, @NonNull View child, @NonNull View dependency) {
return dependency.getId() == R.id.img_header;
}

@Override
public boolean onDependentViewChanged(@NonNull CoordinatorLayout parent, @NonNull View child, @NonNull View dependency) {
float progress = dependency.getY() / getSearchBarHeight();
child.setAlpha(progress * progress);
return true;
}

       其实这个方案也不是唯一的,比如可以把header和banner当成一个child,然后根据位置信息控制banner的透明;愿意接受滑动事件的部分也可以不是list,是header或者其它。

       代码详见CoordinatorLayoutTest

参考资料:
gdutxiaoxu 自定义Behavior —— 仿知乎,FloatActionButton隐藏与展示
Xugter CoordinatorLayout系列(三):Behavior的实践

Fork me on GitHub