Android进阶CoordinatorLayout协调者布局实现吸顶效果

目录
  • 引言
  • 1 CoordinatorLayout功能介绍
    • 1.1 CoordinatorLayout的依赖交互原理
    • 1.2 CoordinatorLayout的嵌套滑动原理
  • 2 CoordinatorLayout源码分析
    • 2.1 CoordinatorLayout的依赖交互实现
    • 2.2 CoordinatorLayout交互依赖的源码分析
    • 2.3 CoordinatorLayout子控件拦截事件源码分析
    • 2.4 CoordinatorLayout嵌套滑动原理分析

引言

在上一节Android进阶宝典 -- NestedScroll嵌套滑动机制实现吸顶效果中,我们通过自定义View的形式完成了TabBar的吸顶效果,其实除了这种方式之外,MD控件中提供了一个CoordinatorLayout,协调者布局,这种布局同样可以实现吸顶效果,但是很多伙伴们对于CoordinatorLayout有点儿陌生,或者认为它用起来比较麻烦,其实大多数原因是因为对于它的原理不太熟悉,不知道什么时候该用什么样的组件或者behavior,所以首先了解它的原理,就能够对CoordinatorLayout驾轻就熟。

1 CoordinatorLayout功能介绍

首先我们先从源码中能够看到,CoordinatorLayout只实现了parent接口(这里如果不清楚parent接口是干什么的,建议看看前面的文章,不然根本不清楚我讲的是什么),说明CoordinatorLayout只能作为父容器来使用。

public class CoordinatorLayout extends ViewGroup implements NestedScrollingParent2,
        NestedScrollingParent3

所以对于CoordinatorLayout来说,它的主要作用就是用来管理子View或者子View之间的联动交互。所以在上一篇文章中,我们介绍的NestScroll嵌套滑动机制,它其实能够实现child与parent的嵌套滑动,但是是1对1的;而CoordinatorLayout是能够管理子View之间的交互,属于1对多的。

那么CoordinatorLayout能够实现哪些功能呢?

(1)子控件之间的交互依赖;

(2)子控件之间的嵌套滑动;

(3)子控件宽高的测量;

(4)子控件事件拦截与响应;

那么以上所有的功能实现,全部都是依赖于CoordinatorLayout中提供的一个Behavior插件。CoordinatorLayout将所有的事件交互都扔给了Behavior,目的就是为了解耦;这样就不需要在父容器中做太多的业务逻辑,而是通过不同的Behavior控制子View产生不同的行为

1.1 CoordinatorLayout的依赖交互原理

首先我们先看第一个功能,处理子控件之间的依赖交互,这种处理方式其实在很多地方我们都能看到,例如一些小的悬浮窗,你可以拖动它到任何地方,点击让其消失的时候,跟随这个View的其他View也会一并消失。

那么如何使用CoordinatorLayout来实现这个功能呢?首先我们先看一下CoordinatorLayout处理这种事件的原理。

看一下上面的图,在协调者布局中,有3个子View:dependcy、child1、child2;当dependcy的发生位移或者消失的时候,那么CoordinatorLayout会通知所有与dependcy依赖的控件,并且调用他们内部声明的Behavior,告知其依赖的dependcy发生变化了。

那么如何判断依赖哪个控件,CoordinatorLayout-Behavior提供一个方法:layoutDependsOn,接收到的通知是什么样的呢?onDependentViewChanged / onDependentViewRemoved 分别代表依赖的View位置发生了变化和依赖的View被移除,这些都会交给Behavior来处理。

1.2 CoordinatorLayout的嵌套滑动原理

这部分其实还是挺简单的,如果有上一篇文章的基础,那么对于嵌套滑动就非常熟悉了

因为我们前面说过, CoordinatorLayout只能作为父容器,因为只实现了parent接口,所以在CoordinatorLayout内部需要有一个child,那么当child滑动时,首先会把实现传递给父容器,也就是CoordinatorLayout,再由CoordinatorLayout分发给每个child的Behavior,由Behavior来完成子控件的嵌套滑动。

这里有个问题,每个child都一定是CoordinatorLayout的直接子View吗?

剩下的两个功能就比较简单了,同样也是在Behavior中进行处理,就不做介绍了。

2 CoordinatorLayout源码分析

首先这里先跟大家说一下,在看源码的时候,我们最好依托于一个实例的实现,从而带着问题去源码中寻找答案,例如我们在第一节中提到过的CoordinatorLayout的四大功能,可能都会有这些问题:

(1)e.g. 控件之间的交互依赖,为什么在一个child下设置一个Behavior,就能够跟随DependentView的位置变化一起变化,他们是如何做依赖通信的?

(2)我们在XML中设置Behavior,是在什么时候实例化的?

(3)我们既然使用了CoordinatorLayout布局,那么内部是如何区分谁依赖谁呢?依赖关系是如何确定的?

(4)什么时候需要重新 onMeasureChild?什么时候需要重新onLayoutChild?

(5)每个设置Behavior的子View,一定要是CoordinatorLayout的直接子View吗?

那么带着这些问题,我们通过源码来得到答案。

2.1 CoordinatorLayout的依赖交互实现

如果要实现依赖交互效果,首先需要两个角色,分别是:DependentView和子View

class DependentView @JvmOverloads constructor(
    val mContext: Context,
    val attributeSet: AttributeSet? = null,
    val flag: Int = 0
) : View(mContext, attributeSet, flag) {
    private var paint: Paint
    private var mStartX = 0
    private var mStartY = 0
    init {
        paint = Paint()
        paint.color = Color.parseColor("#000000")
        paint.style = Paint.Style.FILL
        isClickable = true
    }
    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
        canvas?.let {
            it.drawRect(
                Rect().apply {
                    left = 200
                    top = 200
                    right = 400
                    bottom = 400
                },
                paint
            )
        }
    }
    override fun onTouchEvent(event: MotionEvent?): Boolean {
        when (event?.action) {
            MotionEvent.ACTION_DOWN -> {
                Log.e("TAG","ACTION_DOWN")
                mStartX = event.rawX.toInt()
                mStartY = event.rawY.toInt()
            }
            MotionEvent.ACTION_MOVE -> {
                Log.e("TAG","ACTION_MOVE")
                val endX = event.rawX.toInt()
                val endY = event.rawY.toInt()
                val dx = endX - mStartX
                val dy = endY - mStartY
                ViewCompat.offsetTopAndBottom(this, dy)
                ViewCompat.offsetLeftAndRight(this, dx)
                postInvalidate()
                mStartX = endX
                mStartY = endY
            }
        }
        return super.onTouchEvent(event)
    }
}

这里写了一个很简单的View,能够跟随手指滑动并一起移动,然后我们在当前View下加一个TextView,并让这个TextView跟着DependentView一起滑动。

class DependBehavior @JvmOverloads constructor(context: Context, attributeSet: AttributeSet) :
    CoordinatorLayout.Behavior<View>(context, attributeSet) {
    override fun layoutDependsOn(
        parent: CoordinatorLayout,
        child: View,
        dependency: View
    ): Boolean {
        return dependency is DependentView
    }
    override fun onDependentViewChanged(
        parent: CoordinatorLayout,
        child: View,
        dependency: View
    ): Boolean {
        //获取dependency的位置
        child.x = dependency.x
        child.y = dependency.bottom.toFloat()
        return true
    }
}

如果想要达到随手的效果,那么就需要给TextView设置一个Behavior,上面我们定义了一个Behavior,它的主要作用就是,当DependentView滑动的时候,通过CoordinatorLayout来通知所有的DependBehavior修饰的View。

在DependBehavior中,我们看主要有两个方法:layoutDependsOn和onDependentViewChanged,这两个方法之前在原理中提到过,layoutDependsOn主要是用来决定依赖关系,看child依赖的是不是DependentView;如果依赖的是DependentView,那么在DependentView滑动的时候,就会通过回调onDependentViewChanged,告知子View当前dependency的位置信息,从而完成联动。

2.2 CoordinatorLayout交互依赖的源码分析

那么接下来,我们看下CoordinatorLayout是如何实现这个效果的。

在看CoordinatorLayout源码之前,我们首先需要知道View的生命周期,我们知道在onCreate的时候通过setContentView设置布局文件,如下所示:

<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <com.lay.learn.asm.DependentView
        android:layout_width="200dp"
        android:layout_height="200dp"/>
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="我是跟随者"
        app:layout_behavior="com.lay.learn.asm.behavior.DependBehavior"
        android:textStyle="bold"
        android:textColor="#000000"/>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

如果我们熟悉setContentView的源码,系统是通过Inflate的方式解析布局文件,然后在onResume的时候显示布局,然后随之会调用onAttachedToWindow将布局显示在Window上,我们看下onAttachedToWindow这个方法。

@Override
public void onAttachedToWindow() {
    super.onAttachedToWindow();
    resetTouchBehaviors(false);
    if (mNeedsPreDrawListener) {
        if (mOnPreDrawListener == null) {
            mOnPreDrawListener = new OnPreDrawListener();
        }
        final ViewTreeObserver vto = getViewTreeObserver();
        vto.addOnPreDrawListener(mOnPreDrawListener);
    }
    if (mLastInsets == null && ViewCompat.getFitsSystemWindows(this)) {
        // We're set to fitSystemWindows but we haven't had any insets yet...
        // We should request a new dispatch of window insets
        ViewCompat.requestApplyInsets(this);
    }
    mIsAttachedToWindow = true;
}

在这个方法中,设置了addOnPreDrawListener监听,此监听在页面发生变化(滑动、旋转、重新获取焦点)会产生回调;

class OnPreDrawListener implements ViewTreeObserver.OnPreDrawListener {
    @Override
    public boolean onPreDraw() {
        onChildViewsChanged(EVENT_PRE_DRAW);
        return true;
    }
}
final void onChildViewsChanged(@DispatchChangeEvent final int type) {
    final int layoutDirection = ViewCompat.getLayoutDirection(this);
    final int childCount = mDependencySortedChildren.size();
    final Rect inset = acquireTempRect();
    final Rect drawRect = acquireTempRect();
    final Rect lastDrawRect = acquireTempRect();
    for (int i = 0; i < childCount; i++) {
        final View child = mDependencySortedChildren.get(i);
        final LayoutParams lp = (LayoutParams) child.getLayoutParams();
        if (type == EVENT_PRE_DRAW && child.getVisibility() == View.GONE) {
            // Do not try to update GONE child views in pre draw updates.
            continue;
        }
        // Update any behavior-dependent views for the change
        for (int j = i + 1; j < childCount; j++) {
            final View checkChild = mDependencySortedChildren.get(j);
            final LayoutParams checkLp = (LayoutParams) checkChild.getLayoutParams();
            final Behavior b = checkLp.getBehavior();
            if (b != null && b.layoutDependsOn(this, checkChild, child)) {
                if (type == EVENT_PRE_DRAW && checkLp.getChangedAfterNestedScroll()) {
                    // If this is from a pre-draw and we have already been changed
                    // from a nested scroll, skip the dispatch and reset the flag
                    checkLp.resetChangedAfterNestedScroll();
                    continue;
                }
                final boolean handled;
                switch (type) {
                    case EVENT_VIEW_REMOVED:
                        // EVENT_VIEW_REMOVED means that we need to dispatch
                        // onDependentViewRemoved() instead
                        b.onDependentViewRemoved(this, checkChild, child);
                        handled = true;
                        break;
                    default:
                        // Otherwise we dispatch onDependentViewChanged()
                        handled = b.onDependentViewChanged(this, checkChild, child);
                        break;
                }
                if (type == EVENT_NESTED_SCROLL) {
                    // If this is from a nested scroll, set the flag so that we may skip
                    // any resulting onPreDraw dispatch (if needed)
                    checkLp.setChangedAfterNestedScroll(handled);
                }
            }
        }
    }
    releaseTempRect(inset);
    releaseTempRect(drawRect);
    releaseTempRect(lastDrawRect);
}

在onChildViewsChanged这个方法中,我们看到有两个for循环,从mDependencySortedChildren中取出元素,首先我们先不需要关心mDependencySortedChildren这个数组,这个双循环的目的就是用来判断View之间是否存在绑定关系

首先我们看下第二个循环,当拿到LayoutParams中的Behavior之后,就会调用Behavior的layoutDependsOn方法,假设此时child为DependentView,checkChild为TextView;

for (int j = i + 1; j < childCount; j++) {
    final View checkChild = mDependencySortedChildren.get(j);
    final LayoutParams checkLp = (LayoutParams) checkChild.getLayoutParams();
    final Behavior b = checkLp.getBehavior();
    if (b != null && b.layoutDependsOn(this, checkChild, child)) {
        if (type == EVENT_PRE_DRAW && checkLp.getChangedAfterNestedScroll()) {
            // If this is from a pre-draw and we have already been changed
            // from a nested scroll, skip the dispatch and reset the flag
            checkLp.resetChangedAfterNestedScroll();
            continue;
        }
        final boolean handled;
        switch (type) {
            case EVENT_VIEW_REMOVED:
                // EVENT_VIEW_REMOVED means that we need to dispatch
                // onDependentViewRemoved() instead
                b.onDependentViewRemoved(this, checkChild, child);
                handled = true;
                break;
            default:
                // Otherwise we dispatch onDependentViewChanged()
                handled = b.onDependentViewChanged(this, checkChild, child);
                break;
        }
        if (type == EVENT_NESTED_SCROLL) {
            // If this is from a nested scroll, set the flag so that we may skip
            // any resulting onPreDraw dispatch (if needed)
            checkLp.setChangedAfterNestedScroll(handled);
        }
    }
}

从上面的布局文件中看,TextView的Behavior中,layoutDependsOn返回的就是true,那么此时可以进入到代码块中,这里会判断type类型:EVENT_VIEW_REMOVED和其他type,因为此时的type不是REMOVE,所以就会调用BeHavior的onDependentViewChanged方法。

因为在onAttachedToWindow中,对View树中所有的元素都设置了OnPreDrawListener的监听,所以只要某个View发生了变化,都会走到onChildViewsChanged方法中,进行相应的Behavior检查并实现联动

所以第2节开头的第一个问题,当DependentView发生位置变化时,是如何通信到child中的,这里就是通过设置了onPreDrawListener来监听。

第二个问题,Behavior是如何被初始化的?如果自定义过XML属性,那么大概就能了解,一般都是在布局初始化的时候,拿到layout_behavior属性初始化,我们看下源码。

if (mBehaviorResolved) {
    mBehavior = parseBehavior(context, attrs, a.getString(
            R.styleable.CoordinatorLayout_Layout_layout_behavior));
}
static Behavior parseBehavior(Context context, AttributeSet attrs, String name) {
    if (TextUtils.isEmpty(name)) {
        return null;
    }
    final String fullName;
    if (name.startsWith(".")) {
        // Relative to the app package. Prepend the app package name.
        fullName = context.getPackageName() + name;
    } else if (name.indexOf('.') >= 0) {
        // Fully qualified package name.
        fullName = name;
    } else {
        // Assume stock behavior in this package (if we have one)
        fullName = !TextUtils.isEmpty(WIDGET_PACKAGE_NAME)
                ? (WIDGET_PACKAGE_NAME + '.' + name)
                : name;
    }
    try {
        Map<String, Constructor<Behavior>> constructors = sConstructors.get();
        if (constructors == null) {
            constructors = new HashMap<>();
            sConstructors.set(constructors);
        }
        Constructor<Behavior> c = constructors.get(fullName);
        if (c == null) {
            final Class<Behavior> clazz =
                    (Class<Behavior>) Class.forName(fullName, false, context.getClassLoader());
            c = clazz.getConstructor(CONSTRUCTOR_PARAMS);
            c.setAccessible(true);
            constructors.put(fullName, c);
        }
        return c.newInstance(context, attrs);
    } catch (Exception e) {
        throw new RuntimeException("Could not inflate Behavior subclass " + fullName, e);
    }
}

通过源码我们可以看到,拿到全类名之后,通过反射的方式来创建Behavior,这里需要注意一点,在自定义Behavior的时候,需要两个构造参数CONSTRUCTOR_PARAMS,否则在创建Behavior的时候会报错,因为在反射创建Behavior的时候需要获取这两个构造参数。

static final Class<?>[] CONSTRUCTOR_PARAMS = new Class<?>[] {
        Context.class,
        AttributeSet.class
};

报错类型就是:

Could not inflate Behavior subclass com.lay.learn.asm.behavior.DependBehavior

2.3 CoordinatorLayout子控件拦截事件源码分析

其实只要了解了其中一个功能的原理之后,其他功能都是类似的。对于CoordinatorLayout中的子View拦截事件,我们可以先看看CoordinatorLayout中的onInterceptTouchEvent方法。

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    final int action = ev.getActionMasked();
    // Make sure we reset in case we had missed a previous important event.
    if (action == MotionEvent.ACTION_DOWN) {
        resetTouchBehaviors(true);
    }
    final boolean intercepted = performIntercept(ev, TYPE_ON_INTERCEPT);
    if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
        resetTouchBehaviors(true);
    }
    return intercepted;
}

其中有一个核心方法performIntercept方法,这个方法中我们可以看到,同样也是拿到了Behavior的onInterceptTouchEvent方法,来优先判断子View是否需要拦截这个事件,如果不拦截,那么交给父容器消费,当前一般Behavior中也不会拦截。

private boolean performIntercept(MotionEvent ev, final int type) {
    boolean intercepted = false;
    boolean newBlock = false;
    MotionEvent cancelEvent = null;
    final int action = ev.getActionMasked();
    final List<View> topmostChildList = mTempList1;
    getTopSortedChildren(topmostChildList);
    // Let topmost child views inspect first
    final int childCount = topmostChildList.size();
    for (int i = 0; i < childCount; i++) {
        final View child = topmostChildList.get(i);
        final LayoutParams lp = (LayoutParams) child.getLayoutParams();
        final Behavior b = lp.getBehavior();
        if ((intercepted || newBlock) && action != MotionEvent.ACTION_DOWN) {
            // Cancel all behaviors beneath the one that intercepted.
            // If the event is "down" then we don't have anything to cancel yet.
            if (b != null) {
                if (cancelEvent == null) {
                    final long now = SystemClock.uptimeMillis();
                    cancelEvent = MotionEvent.obtain(now, now,
                            MotionEvent.ACTION_CANCEL, 0.0f, 0.0f, 0);
                }
                switch (type) {
                    case TYPE_ON_INTERCEPT:
                        b.onInterceptTouchEvent(this, child, cancelEvent);
                        break;
                    case TYPE_ON_TOUCH:
                        b.onTouchEvent(this, child, cancelEvent);
                        break;
                }
            }
            continue;
        }
        if (!intercepted && b != null) {
            switch (type) {
                case TYPE_ON_INTERCEPT:
                    intercepted = b.onInterceptTouchEvent(this, child, ev);
                    break;
                case TYPE_ON_TOUCH:
                    intercepted = b.onTouchEvent(this, child, ev);
                    break;
            }
            if (intercepted) {
                mBehaviorTouchView = child;
            }
        }
        // Don't keep going if we're not allowing interaction below this.
        // Setting newBlock will make sure we cancel the rest of the behaviors.
        final boolean wasBlocking = lp.didBlockInteraction();
        final boolean isBlocking = lp.isBlockingInteractionBelow(this, child);
        newBlock = isBlocking && !wasBlocking;
        if (isBlocking && !newBlock) {
            // Stop here since we don't have anything more to cancel - we already did
            // when the behavior first started blocking things below this point.
            break;
        }
    }
    topmostChildList.clear();
    return intercepted;
}

2.4 CoordinatorLayout嵌套滑动原理分析

对于嵌套滑动,其实在上一篇文章中已经介绍的很清楚了,加上CoordinatorLayout自身的特性,我们知道当子View(指的是实现了nestscrollchild接口的View)嵌套滑动的时候,那么首先会将事件向上分发到CoordinatorLayout中,所以在parent中的onNestedPreScroll的方法中会拿到回调。

public void onNestedPreScroll(View target, int dx, int dy, int[] consumed, int  type) {
    int xConsumed = 0;
    int yConsumed = 0;
    boolean accepted = false;
    final int childCount = getChildCount();
    for (int i = 0; i < childCount; i++) {
        final View view = getChildAt(i);
        if (view.getVisibility() == GONE) {
            // If the child is GONE, skip...
            continue;
        }
        final LayoutParams lp = (LayoutParams) view.getLayoutParams();
        if (!lp.isNestedScrollAccepted(type)) {
            continue;
        }
        final Behavior viewBehavior = lp.getBehavior();
        if (viewBehavior != null) {
            mBehaviorConsumed[0] = 0;
            mBehaviorConsumed[1] = 0;
            viewBehavior.onNestedPreScroll(this, view, target, dx, dy, mBehaviorConsumed, type);
            xConsumed = dx > 0 ? Math.max(xConsumed, mBehaviorConsumed[0])
                    : Math.min(xConsumed, mBehaviorConsumed[0]);
            yConsumed = dy > 0 ? Math.max(yConsumed, mBehaviorConsumed[1])
                    : Math.min(yConsumed, mBehaviorConsumed[1]);
            accepted = true;
        }
    }
    consumed[0] = xConsumed;
    consumed[1] = yConsumed;
    if (accepted) {
        onChildViewsChanged(EVENT_NESTED_SCROLL);
    }
}

我们详细看下这个方法,对于parent的onNestedPreScroll方法,当然也是会获取到Behavior,这里也是拿到了子View的Behavior之后,调用其onNestedPreScroll方法,会把手指滑动的距离传递到子View的Behavior中

<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">
    <TextView
        android:layout_width="match_parent"
        android:layout_height="80dp"
        android:background="#2196F3"
        android:text="这是顶部TextView"
        android:gravity="center"
        android:textColor="#FFFFFF"/>
    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/rv_child"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="80dp"/>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

所以这里我们先定义一个Behavior,这个Behavior是用来接收滑动事件分发的。当手指向上滑动的时候,首先将TextView隐藏,然后才能滑动RecyclerView。

class ScrollBehavior @JvmOverloads constructor(
    val mContext: Context,
    val attributeSet: AttributeSet
) : CoordinatorLayout.Behavior<TextView>(mContext, attributeSet) {
    //相对于y轴滑动的距离
    private var mScrollY = 0
    //总共滑动的距离
    private var totalScroll = 0
    override fun onLayoutChild(
        parent: CoordinatorLayout,
        child: TextView,
        layoutDirection: Int
    ): Boolean {
        Log.e("TAG", "onLayoutChild----")
        //实时测量
        parent.onLayoutChild(child, layoutDirection)
        return true
    }
    override fun onStartNestedScroll(
        coordinatorLayout: CoordinatorLayout,
        child: TextView,
        directTargetChild: View,
        target: View,
        axes: Int,
        type: Int
    ): Boolean {
        //目的为了dispatch成功
        return true
    }
    override fun onNestedPreScroll(
        coordinatorLayout: CoordinatorLayout,
        child: TextView,
        target: View,
        dx: Int,
        dy: Int,
        consumed: IntArray,
        type: Int
    ) {
        //边界处理
        var cosumedy = dy
        Log.e("TAG","onNestedPreScroll $totalScroll dy $dy")
        var scroll = totalScroll + dy
        if (abs(scroll) > getMaxScroll(child)) {
            cosumedy = getMaxScroll(child) - abs(totalScroll)
        } else if (scroll < 0) {
            cosumedy = 0
        }
        //在这里进行事件消费,我们只需要关心竖向滑动
        ViewCompat.offsetTopAndBottom(child, -cosumedy)
        //重新赋值
        totalScroll += cosumedy
        consumed[1] = cosumedy
    }
    private fun getMaxScroll(child: TextView): Int {
        return child.height
    }
}

对应的布局文件,区别在于TextView设置了ScrollBehavior。

<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:orientation="vertical">
    <TextView
        android:layout_width="match_parent"
        android:layout_height="80dp"
        android:background="#2196F3"
        android:text="这是顶部TextView"
        android:gravity="center"
        android:textColor="#FFFFFF"
        app:layout_behavior=".behavior.ScrollBehavior"/>
    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/rv_child"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="80dp"/>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

当滚动RecyclerView的时候,因为RecyclerView属于nestscrollchild,所以事件先被传递到了CoordinatorLayout中,然后通过分发调用了TextView中的Behavior中的onNestedPreScroll,在这个方法中,我们是进行了TextView的上下滑动(边界处理我这边就不说了,其实还蛮简单的),看下效果。

我们发现有个问题,就是在TextView上滑离开的之后,RecyclerView上方有一处空白,这个就是因为在TextView滑动的时候,RecyclerView没有跟随TextView一起滑动。

这个不就是我们在2.1中提到的这个效果吗,所以RecyclerView是需要依赖TextView的,我们需要再次自定义一个Behavior,完成这种联动效果。

class RecyclerViewBehavior @JvmOverloads constructor(
    val context: Context,
    val attributeSet: AttributeSet
) : CoordinatorLayout.Behavior<RecyclerView>(context, attributeSet) {
    override fun layoutDependsOn(
        parent: CoordinatorLayout,
        child: RecyclerView,
        dependency: View
    ): Boolean {
        return dependency is TextView
    }
    override fun onDependentViewChanged(
        parent: CoordinatorLayout,
        child: RecyclerView,
        dependency: View
    ): Boolean {
        Log.e("TAG","onDependentViewChanged ${dependency.bottom} ${child.top}")
        ViewCompat.offsetTopAndBottom(child,(dependency.bottom - child.top))
        return true
    }
}

对应的布局文件,区别在于RecyclerView设置了RecyclerViewBehavior。

<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:orientation="vertical">
    <TextView
        android:layout_width="match_parent"
        android:layout_height="80dp"
        android:background="#2196F3"
        android:text="这是顶部TextView"
        android:gravity="center"
        android:textColor="#FFFFFF"
        app:layout_behavior=".behavior.ScrollBehavior"/>
    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/rv_child"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="80dp"
        app:layout_behavior=".behavior.RecyclerViewBehavior"/>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

这里我设置了RecyclerView依赖于TextView,当TextView的位置发生变化的时候,就会通知RecyclerView的Behavior中的onDependentViewChanged方法,在这个方法中可以设置RecyclerView竖直方向上的偏移量。

具体的偏移量计算,可以根据上图自行推理,因为TextView移动的时候,会跟RecyclerView产生一块位移,RecyclerView需要补上这块,在onDependentViewChanged方法中。

这时候我们会发现,即便最外层没有使用可滑动的布局,依然能够完成吸顶的效果,这就显示了CoordinatorLayout的强大之处,当然除了移动之外,控制View的显示与隐藏、动画效果等等都可以完成,只要熟悉了CoordinatorLayout内部的原理,就不怕UI跟设计老师的任意需求了。

以上就是Android进阶CoordinatorLayout协调者布局实现吸顶效果的详细内容,更多关于Android CoordinatorLayout吸顶的资料请关注我们其它相关文章!

(0)

相关推荐

  • Android进阶从字节码插桩技术了解美团热修复实例详解

    目录 引言 1 插件发布 2 Javassist 2.1 准备工作 2.2 Transform 2.3 transform函数注入代码 2.3.1 Jar包处理 2.3.2 字节码处理 2.4 Javassist织入代码 2.4.1 ClassPool 2.4.2 CtClass 引言 热修复技术如今已经不是一个新颖的技术,很多公司都在用,而且像阿里.腾讯等互联网巨头都有自己的热修复框架,像阿里的AndFix采用的是hook native底层修改代码指令集的方式:腾讯的Tinker采用类加载的方

  • Android进阶NestedScroll嵌套滑动机制实现吸顶效果详解

    目录 引言 1 自定义滑动布局,实现吸顶效果 1.1 滑动容器实现 1.2 嵌套滑动机制完成交互优化 1.2.1 NestedScrollingParent接口和NestedScrollingChild接口 1.2.2 预滚动阶段实现 1.2.3 滚动阶段实现 1.2.4 滚动结束 引言 在上一篇文章Android进阶宝典 -- 事件冲突怎么解决?先从Android事件分发机制开始说起中,我们详细地介绍了Android事件分发机制,其实只要页面结构复杂,联动众多就会产生事件冲突,处理不得当就是b

  • Android Jetpack组件ViewModel基本用法详解

    目录 引言 一.概述与作用 二.基本用法 小结 引言 天道好轮回,终于星期五,但是还是忙碌了一天.在项目中,我遇到了一个问题,起因则是无法实时去获取信息来更新UI界面,因为我需要知道我是否获取到了实时信息,我想到的办法有三,利用Handler收发消息在子线程与主线程切换从而更新信息,其二则是利用在页面重绘的时候(一般是页面变动如跳转下个页面和将应用切至后台),其三就是利用Jetpack中最重要的组件之一ViewModel,最后我还是选择了ViewModel,因为感觉更方便. 其实想到的前面两个方

  • Android进阶之从IO到NIO的模型机制演进

    目录 引言 1 Basic IO模型 1.1 RandomAccessFile的缓冲区和BufferedInputStream缓冲区的区别 1.2 Basic IO模型底层原理 2 NIO模型 3 OKIO 引言 其实IO操作相较于服务端,客户端做的并不多,基本的场景就是读写文件的时候会使用到InputStream或者OutputStream,然而客户端能做的就是发起一个读写的指令,真正的操作是内核层通过ioctl指令执行读写操作,因为每次的IO操作都涉及到了线程的操作,因此会有性能上的损耗,那

  • Android进阶KOOM线上APM监控全面剖析

    目录 正文 1 Leakcanary为什么不能用于线上 1.1 Leakcanary原理简单剖析 1.2 小结 2 KOOM原理分析 2.1 KOOM引入 2.2 KOOM源码分析 2.2.1 trackOOM方法分析 2.2.2 HeapOOMTracker 2.2.3 ThreadOOMTracker 2.2.4 FastHugeMemoryOOMTracker 2.3 dump为何不能放在子线程 2.3.1 ForkJvmHeapDumper分析 2.3.2 C++层分析dumpHprof

  • Android进阶Handler应用线上卡顿监控详解

    目录 引言 1 Handler消息机制 1.1 方案确认 1.2 Looper源码 1.3 Blockcanary原理分析 1.4 Handler监控的缺陷 2 字节码插桩实现方法耗时监控 2.1 字节码插桩流程 2.2 引入ASM实现字节码插桩 2.3 Blockcanary的优化策略 引言 在上一篇文章中# Android进阶宝典 -- KOOM线上APM监控最全剖析,我详细介绍了对于线上App内存监控的方案策略,其实除了内存指标之外,经常有用户反馈卡顿问题,其实这种问题是最难定位的,因为不

  • Android隐私协议提示弹窗的实现流程详解

    android studio版本:2021.2.1 例程名称:pravicydialog 功能: 1.启动app后弹窗隐私协议 2.屏蔽返回键 3.再次启动不再显示隐私协议. 本例程的绝大部分代码来自下面链接,因为本人改了一些,增加了一些功能,所以不有脸的算原创了. 下面这个例子是“正宗”app隐私协议实现方法,而且协议内容使用的是txt格式文件,据说如果使用html格式文件,各大平台在审核的时候大概率无法通过,但协议内容的还应该有更详细协议及说明的链接,我没做,暂时还没学会,会了再修改一下.

  • Android进阶CoordinatorLayout协调者布局实现吸顶效果

    目录 引言 1 CoordinatorLayout功能介绍 1.1 CoordinatorLayout的依赖交互原理 1.2 CoordinatorLayout的嵌套滑动原理 2 CoordinatorLayout源码分析 2.1 CoordinatorLayout的依赖交互实现 2.2 CoordinatorLayout交互依赖的源码分析 2.3 CoordinatorLayout子控件拦截事件源码分析 2.4 CoordinatorLayout嵌套滑动原理分析 引言 在上一节Android进

  • Android 实现当下最流行的吸顶效果

    开始逐渐领略到ItemDecoration的美~ 今天让我 使用 ItemDecoration 来完成 可推动的悬浮导航栏的效果,最终实现的效果如下图: 具体实现步骤如下: 根据我前面的文章所讲的RecyclerView的基本使用,我们先来完成基本的recyclerView: 第一步:布局里写一个RecyclerView 第二步:实例化 recyclerView = (RecyclerView) findViewById(R.id.recyclerView); 第三步:获取所需的数据 (这里我们

  • Android实现上拉吸顶效果

    本文实例为大家分享了Android实现上拉吸顶效果的具体代码,供大家参考,具体内容如下 效果图 1.home_layout.xml 此布局即可实现上拉标题固定在顶部 <?xml version="1.0" encoding="UTF-8"?> <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"     xmlns:app="

  • Android自定义RecyclerView Item头部悬浮吸顶

    本文实例为大家分享了Android自定义RecyclerView Item头部悬浮吸顶的具体代码,供大家参考,具体内容如下 概述 1.自定义了一个FrameLayout,引入条目的头部布局加入到自定义FrameLayout中. 2.将RecyclerView加入FrameLayout 3.条目头部View的Alpha动画以及设置透明和不透明这个时机大多是通过打log来确定的,硬推理还是有些难. 4.当屏幕显示区域的第二条Item距离控件顶端的距离小于条目头部View高度时,就开始移动条目头部Vi

  • Android Jetpack Compose实现列表吸顶效果

    目录 stickyHeader 实体类 加载假数据 吸顶标题 二级条目 完整代码 效果图 安卓传统的 Recyclerview 打造悬浮头部StickyHeader的吸顶效果,十分麻烦,而在Compose中就简单多了 stickyHeader Compose设计的时候考虑得很周到,他们提供了stickyHeader 作用就是添加一个粘性标题项,即使在它后面滚动时也会保持固定.标头将保持固定,直到下一个标头取而代之. 参数key - 表示唯一的密钥键. 它不允许对列表出现使用相同的键.密钥的类型应

  • android中RecyclerView悬浮吸顶效果

    MultiType-Adapter打造悬浮吸顶效果 注:当前版本只适合配合RecyclerView快速打造一款 展示UI 悬浮吸顶效果,如 通讯录效果,由于实现机制的原因,暂时不支持触摸事件. MultiType-Adapter介绍地址:MultiType-Adapter 是一款轻量级支持多数据类型的 RecyclerView 适配器; 使用简单,完全解耦; 悬浮吸顶效果 ```groovy // root build.gradle repositories { jcenter() maven

  • react-native滑动吸顶效果的实现过程

    前言 最近公司开发方向偏向移动端,于是就被调去做RN(react-native),体验还不错,当前有个需求是首页中间吸顶的效果,虽然已经很久没写样式了,不过这种常见样式应该是so-easy,没成想翻车了,网上搜索换了几个方案都不行,最后去github上复制封装好的库来实现,现在把翻车过程记录下来. 需求效果 翻车过程 第一种方案 失败 一开始的思路是这样的,大众思路,我们需要监听页面的滚动状态,当页面滚动到要吸顶元素所处的位置的时候,我们设置它为固定定位,不过很遗憾,RN对于position属性

  • js实现多个标题吸顶效果

    对于导航的吸顶效果,pc端和移动端的需求可能有些差异.在pc端,我们通常只需要一个顶部导航:在移动端,在滑动页面的时候,更需要多个标题的吸顶(例如地区的选择,需要将省份吸顶). 单个标题吸顶和多个标题吸顶的区别在于:多个标题吸顶需要确定一个高度范围,在这个范围中只能有一个标题吸顶,其他都是固定效果. 一.页面布局及样式 此处为了测试效果,用了几个重复的section标签,大家根据实际需求编写布局和样式. <body> <ul id="container"> &l

  • vue中el-table实现自动吸顶效果(支持fixed)

    目录 前言 实现思路 效果: 使用: 主要源码: 前言 看了很多案例,从简单的角度,position:sticky,似乎是比较理想的选择,可是当el-table设置了fixed后,这里的fixed会失效.最后还是采用了js监听滚动的思路实现. 实现思路 表格距离顶部的距离 设置表格距离顶部多少就吸顶-offsetTop1 获取滚动条滚动的距离 当滚动条滚动 offsetTop1 后,表格就自动吸顶 效果: 使用: 在el-table标签中配置:v-sticky="{ top: 0, parent

随机推荐