自定義 Behavior,實現巢狀滑動、平滑切換周月檢視的日曆

SouthernBox發表於2019-03-04

使用 CoordinateLayout 可以協調它的子佈局,實現滑動效果的聯動,它的滑動效果由 Behavior 實現。以前用過小米日曆,對它滑動平滑切換日月檢視的效果印象深刻。本文嘗試用自定義 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));
    }
});
複製程式碼

效果如下:

自定義 Behavior,實現巢狀滑動、平滑切換周月檢視的日曆

星期標題

上面效果可以看到,顯示星期的標題也一起向上移動了,而且 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>
複製程式碼

效果如下:

自定義 Behavior,實現巢狀滑動、平滑切換周月檢視的日曆

平滑切換檢視

接下來處理周月檢視切換的問題。

當巢狀滑動結束時會回撥 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);
    }
}
複製程式碼

效果如下:

自定義 Behavior,實現巢狀滑動、平滑切換周月檢視的日曆

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 就算完成了。

效果

看一下最終的效果:

自定義 Behavior,實現巢狀滑動、平滑切換周月檢視的日曆

這種實現方式的優點是程式碼量少,用起來方便。使用了 MaterialCalendarView 並且沒有修改它的原始碼,意味著支援它的所有功能。

希望通過本文,大家對 Behavior 有一個大概的瞭解。

專案地址

相關文章