美文网首页Android 知识Android开发经验谈半栈工程师
自定义 Behavior,实现嵌套滑动、平滑切换周月视图的日历

自定义 Behavior,实现嵌套滑动、平滑切换周月视图的日历

作者: NanBox | 来源:发表于2018-03-27 12:42 被阅读467次

    使用 CoordinateLayout 可以协调它的子布局,实现滑动效果的联动,它的滑动效果由 Behavior 实现。以前用过小米日历,对它滑动平滑切换日月视图的效果印象深刻。本文尝试用自定义 Behavior 实现一个带有这种效果的日历。

    简介

    先上个小米日历的图,让大家知道要做一个什么效果:

    这是小米日历的效果,在用户操作列表的时候,将日历折叠成周视图,扩大列表的显示区域,同时也不影响日历部分的功能使用,有趣且实用。

    下面利用 CoordinateLayout.Behavior,简单实现一个类似的效果。

    日历控件

    我并不打算自己再写一个日历控件。原本想用原生的 CalendarView,但是 CalendarView 不支持周视图,可自定义程度也不高。

    在 GitHub 搜了一下,决定使用 MaterialCalendarView。这个库比较流行,它支持周月视图的切换,符合 Material Design,也可以自定义显示效果。

    引入该库,在布局文件中使用:

    <com.prolificinteractive.materialcalendarview.MaterialCalendarView
        android:id="@+id/calendar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:mcv_showOtherDates="all" />
    

    切换视图代码如下:

    calendarView.state().edit()
        .setCalendarDisplayMode(CalendarMode.WEEKS)
        .commit();
    

    Behavior

    写代码之前,还有些东西需要先了解一下。

    用 CoordinatorLayout 作为根布局,就可以协调它子控件之间的联动效果,至于如何联动,是由它的内部类 Behavior 实现的。在布局中,对子控件配置 app:layout_behavior 属性,实现对应的联动效果。所以这里我们需要自定义日历和列表的两个 Behavior。

    Behavior 有两种实现联动的方式。一种是通过建立依赖关系,一种是通过 RecyclerView 或 NextedScrollView 的嵌套滑动机制,后面都会讲到。我们要先分析想要实现的效果,确定各个子控件之间的依赖关系,避免循环依赖等错误。

    另外,由于 CoordinatorLayout 的布局类似于 FrameLayout,所以还需要考虑摆放控件位置的问题。

    折叠效果

    大家可能有看过 RecyclerView 和 AppBarLayout 联动的效果,这种效果需要给 RecyclerView 配置 Behavior:

    app:layout_behavior="@string/appbar_scrolling_view_behavior"
    

    但为什么只要给 RecyclerView 配不用给 AppBarLayout 配?看一下 AppBarLayout 的源码就知道了,它默认已经给自己配了:

    @CoordinatorLayout.DefaultBehavior(AppBarLayout.Behavior.class)
    public class AppBarLayout extends LinearLayout {
        // ...
    }
    

    看它 Behavior 源码发现,它继承了 ViewOffsetBehavior。ViewOffsetBehavior 的作用是方便改变控件的位置和获取偏移量。所以这里我再偷个懒,把源码里的 ViewOffsetBehavior 直接拷出来用了。

    我们自定义两个 Behavior,列表控件的 CalendarScrollBehavior 和日历控件的 CalendarBehavior,都继承 ViewOffsetBehavior。

    CalendarScrollBehavior

    在 Behavior 中,通过 layoutDependsOn 方法来建立依赖关系,一个控件可以依赖多个其他控件,但不可循环依赖。当被依赖的控件属性发生变化时,会调用 onDependentViewChanged 方法。

    为了降低复杂程度,我将所有折叠操作都放到 CalendarBehavior 里做,而 CalendarScrollBehavior 里面做一件事,就是把列表置于日历之下。参考了源码 ScrollingViewBehavior,CalendarScrollBehavior 代码如下:

    public class CalendarScrollBehavior extends ViewOffsetBehavior<RecyclerView> {
    
        private int calendarHeight;
    
        public CalendarScrollBehavior(Context context, AttributeSet attrs) {
            super(context, attrs);
        }
    
        @Override
        public boolean layoutDependsOn(CoordinatorLayout parent, RecyclerView child, View dependency) {
            return dependency instanceof MaterialCalendarView;
        }
    
        @Override
        protected void layoutChild(CoordinatorLayout parent, RecyclerView child, int layoutDirection) {
            super.layoutChild(parent, child, layoutDirection);
            if (calendarHeight == 0) {
                final List<View> dependencies = parent.getDependencies(child);
                for (int i = 0, z = dependencies.size(); i < z; i++) {
                    View view = dependencies.get(i);
                    if (view instanceof MaterialCalendarView) {
                        calendarHeight = view.getMeasuredHeight();
                    }
                }
            }
            child.setTop(calendarHeight);
            child.setBottom(child.getBottom() + calendarHeight);
        }
    }
    

    这里没有用到 onDependentViewChanged 方法,所有联动操作都将通过嵌套滑动机制实现。

    CalendarBehavior

    接下来是本文的重点,我们使用的嵌套滑动机制,主要涉及到以下几个方法:

    • onStartNestedScroll
    • onNestedPreScroll
    • onStopNestedScroll
    • onNestedPreFling

    当 RecyclerView 或 NestedScrollView 滑动时,CoordinatorLayout 的子控件 Behavior 可以接收到对应的回调。看方法名应该大概知道它的用途了,下面都会提到。

    onStartNestedScroll 的返回值决定是否接收嵌套滑动事件。我们判断,只要是上下滑动,就接收:

    @Override
    public boolean onStartNestedScroll(CoordinatorLayout coordinatorLayout,
                                       MaterialCalendarView child,
                                       View directTargetChild,
                                       View target,
                                       int axes, int type) {
        return (axes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
    }
    

    onNestedPreScroll 这个方法是在准备滚动之前调用的,它带有滚动偏移量 dy。

    @Override
    public void onNestedPreScroll(CoordinatorLayout coordinatorLayout,
                                  final MaterialCalendarView child,
                                  View target,
                                  int dx, int dy,
                                  int[] consumed,
                                  int type)                            
    

    我们要做的,就是在恰当的时候,消费掉这个偏移量,转化成折叠的效果。

    分析一下这个折叠效果。滚动时,日历也向上滚动,最多到当前选中日期那一行,滚动范围和当前选中日期有关。向上移动是负值,所以日历的滚动范围是从 0 到 -calendarLineHeight * (weekOfMonth - 1),减 1 是因为要多留一行显示星期的标题。列表的滚动范围则是固定的,最多向上移动 5 倍的日历行高,也就是从 0 到 -calendarLineHeight * 5。

    判断偏移量是否在这个范围内,用 ViewOffsetBehavior 的 setTopAndBottomOffset 方法来改变控件位置。所以还要拿到 CalendarScrollBehavior 进行操作。参数 target 是触发嵌套滑动的控件,在这里就是 RecyclerView,通过 target.getLayoutParams()).getBehavior() 就可以拿到 CalendarScrollBehavior 了。

    折叠过程中,要将偏移量消费掉,这就用到了 consumed 这个参数,它是一个长度为 2 的数组,存放的是要消费掉的 x 和 y 轴偏移量。

    最终代码如下:

    @Override
    public void onNestedPreScroll(CoordinatorLayout coordinatorLayout,
                                  final MaterialCalendarView child,
                                  View target,
                                  int dx, int dy,
                                  int[] consumed,
                                  int type) {
        // 列表未滑动到顶部时,不处理
        if (target.canScrollVertically(-1)) {
            return;
        }
        // 切换月视图
        setMonthMode(child);
        if (calendarMode == CalendarMode.MONTHS) {
            if (calendarLineHeight == 0) {
                calendarLineHeight = child.getMeasuredHeight() / 7;
                weekCalendarHeight = calendarLineHeight * 2;
                monthCalendarHeight = calendarLineHeight * 7;
                listMaxOffset = calendarLineHeight * 5;
            }
            // 移动日历
            int calendarMinOffset = -calendarLineHeight * (weekOfMonth - 1);
            int calendarOffset = MathUtils.clamp(
                getTopAndBottomOffset() - dy, calendarMinOffset, 0);
            setTopAndBottomOffset(calendarOffset);
            // 移动列表
            final CoordinatorLayout.Behavior behavior =
                    ((CoordinatorLayout.LayoutParams) target.getLayoutParams()).getBehavior();
            if (behavior instanceof CalendarScrollBehavior) {
                final CalendarScrollBehavior listBehavior = (CalendarScrollBehavior) behavior;
                int listMinOffset = -listMaxOffset;
                int listOffset = MathUtils.clamp(
                    listBehavior.getTopAndBottomOffset() - dy, -listMaxOffset, 0);
                listBehavior.setTopAndBottomOffset(listOffset);
                if (listOffset > -listMaxOffset && listOffset < 0) {
                    consumed[1] = dy;
                }
            }
        }
    }
    

    现在我们可以把布局参数配一下,看一下效果了,布局如下:

    <?xml version="1.0" encoding="utf-8"?>
    <android.support.design.widget.CoordinatorLayout
        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.prolificinteractive.materialcalendarview.MaterialCalendarView
            android:id="@+id/calendar"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:layout_behavior="@string/calendar_behavior"
            app:mcv_showOtherDates="all" />
    
        <android.support.v7.widget.RecyclerView
            android:id="@+id/recycler_view"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_marginBottom="110dp"
            android:background="@color/color_ee"
            app:layout_behavior="@string/calendar_scrolling_behavior" />
    
    </android.support.design.widget.CoordinatorLayout>
    

    在选中其他日期的时候,记得通知 Behvior 选中的是该月的第几个星期:

    calendarView.setOnDateChangedListener(new OnDateSelectedListener() {
        @Override
        public void onDateSelected(MaterialCalendarView widget,
                                   CalendarDay date,
                                   boolean selected) {
            Calendar calendar = date.getCalendar();
            calendarBehavior.setWeekOfMonth(calendar.get(Calendar.WEEK_OF_MONTH));
        }
    });
    

    效果如下:

    星期标题

    上面效果可以看到,显示星期的标题也一起向上移动了,而且 MaterialCalendarView 是没办法隐藏这个标题的。

    没办法,只好自己写一个星期标题的控件盖在上面,简单写了一个 WeekTitleView,代码就不贴了,在布局里加上:

    <?xml version="1.0" encoding="utf-8"?>
    <android.support.design.widget.CoordinatorLayout
        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:layout_height="match_parent">
    
        <com.prolificinteractive.materialcalendarview.MaterialCalendarView
            android:id="@+id/calendar"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:layout_behavior="@string/calendar_behavior"
            app:mcv_showOtherDates="all" />
    
        <android.support.v7.widget.RecyclerView
            android:id="@+id/recycler_view"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_marginBottom="110dp"
            android:background="@color/color_ee"
            app:layout_behavior="@string/calendar_scrolling_behavior"
            tools:listitem="@layout/item_list" />
    
        <com.southernbox.nestedcalendar.view.WeekTitleView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="#fafafa" />
    
    </android.support.design.widget.CoordinatorLayout>
    

    效果如下:

    平滑切换视图

    接下来处理周月视图切换的问题。

    当嵌套滑动结束时会回调 onStopNestedScroll 方法,可以在这里根据当前控件的位置,判断是否要切换视图。当滑动到最上面的时候切换为周视图,其余的情况都是月视图:

    @Override
    public void onStopNestedScroll(final CoordinatorLayout coordinatorLayout,
                                   final MaterialCalendarView child,
                                   final View target,
                                   int type) {
        if (calendarLineHeight == 0) {
            return;
        }
        if (target.getTop() == weekCalendarHeight) {
            setWeekMode(child);
        } else {
            setMonthMode(child);
        }
    }
    

    效果如下:

    MaterialCalendarView 的视图切换会有一点点卡顿,但还是能接受的。

    惯性滑动

    上面效果可以看出一个问题,当滑动到一半的时候松手,应该要恢复到完整视图的位置。这里包含了,快速滑动后惯性滑动到指定位置的效果,和没有快速滑动时,往就近的指定位置滑动这两种效果。

    我们可以从 onNestedPreFling 拿到滑动速度,方法的返回值决定了是否进行惯性嵌套滑动:

    @Override
    public boolean onNestedPreFling(CoordinatorLayout coordinatorLayout,
                                    MaterialCalendarView child,
                                    View target,
                                    float velocityX, float velocityY) {
        this.velocityY = velocityY;
        return !(target.getTop() == weekCalendarHeight ||
                target.getTop() == monthCalendarHeight);
    }
    

    在 onStopNestedScroll 里判断并执行滚动。由于我们的滚动折叠效果是在 onNestedPreScroll 实现的,所以要想办法触发这个方法。通过源码可以知道,onNestedPreScroll 是在 dispatchNestedPreScroll 里调用的,前提是 startNestedScroll 为 true。所以可以这样触发:

    recyclerView.startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, TYPE_TOUCH);
    recyclerView.dispatchNestedPreScroll(0, dy, new int[2], new int[2], TYPE_TOUCH);
    

    最终 onStopNestedScroll 的完整代码如下:

    @Override
    public void onStopNestedScroll(final CoordinatorLayout coordinatorLayout,
                                   final MaterialCalendarView child,
                                   final View target,
                                   int type) {
        if (calendarLineHeight == 0) {
            return;
        }
        if (target.getTop() == weekCalendarHeight) {
            setWeekMode(child);
            return;
        } else if (target.getTop() == monthCalendarHeight) {
            setMonthMode(child);
            return;
        }
        if (!canAutoScroll) {
            return;
        }
        if (calendarMode == CalendarMode.MONTHS) {
            final Scroller scroller = new Scroller(coordinatorLayout.getContext());
            int offset;
            int duration = 800;
            if (Math.abs(velocityY) < 1000) {
                if (target.getTop() > calendarLineHeight * 4) {
                    offset = monthCalendarHeight - target.getTop();
                } else {
                    offset = weekCalendarHeight - target.getTop();
                }
            } else {
                if (velocityY > 0) {
                    offset = weekCalendarHeight - target.getTop();
                } else {
                    offset = monthCalendarHeight - target.getTop();
                }
            }
            velocityY = 0;
            duration = duration * Math.abs(offset) / (listMaxOffset);
            scroller.startScroll(
                    0, target.getTop(),
                    0, offset,
                    duration);
            ViewCompat.postOnAnimation(child, new Runnable() {
                @Override
                public void run() {
                    if (scroller.computeScrollOffset() &&
                            target instanceof RecyclerView) {
                        canAutoScroll = false;
                        RecyclerView recyclerView = (RecyclerView) target;
                        int delta = target.getTop() - scroller.getCurrY();
                        recyclerView.startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, TYPE_TOUCH);
                        recyclerView.dispatchNestedPreScroll(
                                0, delta, new int[2], new int[2], TYPE_TOUCH);
                        ViewCompat.postOnAnimation(child, this);
                    } else {
                        canAutoScroll = true;
                        if (target.getTop() == weekCalendarHeight) {
                            setWeekMode(child);
                        } else if (target.getTop() == monthCalendarHeight) {
                            setMonthMode(child);
                        }
                    }
                }
            });
        }
    }
    

    到这里,自定义 Behavior 就算完成了。

    效果

    看一下最终的效果:

    这种实现方式的优点是代码量少,用起来方便。使用了 MaterialCalendarView 并且没有修改它的源码,意味着支持它的所有功能。

    希望通过本文,大家对 Behavior 有一个大概的了解。

    项目地址

    相关文章

      网友评论

      • 季白zy:小弟刚刚写博客.请问简书怎么弄代码块啊
        NanBox:@季白zy 可以去看一下 Markdown 的语法,上下用 ``` 包裹的起来的就是代码块。
      • 键盘男:大佬大佬🙏
        NanBox:@键盘男 不敢不敢,大佬见笑了

      本文标题:自定义 Behavior,实现嵌套滑动、平滑切换周月视图的日历

      本文链接:https://www.haomeiwen.com/subject/vlbjcftx.html