使用 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 有一個大概的了解。