可惜了, 现在燃兔倒了, 看不到它的界面, 发个自己做的效果图吧:
哈哈, 大概的效果就是这样子.
我们先来解剖一下它:
- 首先它有一个TopBar, 一个BottomBar;
- 上面可以滑动的view,我们就叫他棺材盖吧,哈哈是不是很形象;
- 棺材盖打开之后,下面的棺材底还可以滑动, 还有一个关闭按钮,停留在底部;
好像没有了吧, 我们再来仔细看一遍:
原来还有一个HeaderView呢, 他跟棺材底第一个view一样的宽高跟内容, 所以不仔细看是看不出有两个的. 如果他们是分离的话, 那关闭的动画就比较容易做了. 对了,还有一个白色的渐变效果.
我们先大概说一下它的逻辑:
- TopBar和BottomBar在最上面, TopBar根据手指滑动的速度和方向来确定显示或收起;
- 棺材盖跟HeaderView有一个滚动视差, 并且棺材盖向下滑动到了一定距离后, 触发打开棺材盖: TopBar,BottomBar随之隐藏, 打开后, 棺材底可以接受触摸事件.
- 点击关闭按钮, 先是一块白色的东西遮住棺材底, HeaderView跟棺材盖出现,并且下次打开的时候,棺材底看起来总是未滚动过的状态, 即使上次滑动到了底部.
好了, 我们再来想想代码应该怎么写:
- 首先这个肯定是ViewGroup而不是View了.
- 其次, 因为我们的棺材盖是可以打开,关闭,上下滑动的, 所以我们还要自己做滑动跟惯性效果, 那这样的话,我们在onMeasure的时候, 就不应该限制棺材盖跟棺材底的高度,他有多高就给他多高. onLayout的时候就用getMeasuredHeight,这样我们的滑动就可以做了.
- 还有我们的这个ViewGroup打算限制布局中的子View数量,这样一来,也比较符合棺材这个设定,二来,我们也方便管理,哈哈。
- 限制外部添加子View的数量为2: 一个棺材盖,一个棺材底就够了,至于TopBar, BottomBar, HeaderView那些用@layout的方法来添加,这样做的话,xml布局就比较清晰明了。
先来定义一下属性:
<declare-styleable name="CoffinLayout">
<attr name="lid_offset" format="dimension" />
<attr name="lid_elevation" format="dimension" />
<attr name="trigger_open_offset" format="dimension" />
<attr name="residual_view" format="reference" />
<attr name="header_view" format="reference" />
<attr name="transition_color" format="color" />
<attr name="top_bar" format="reference"/>
<attr name="bottom_bar" format="reference"/>
</declare-styleable>
- lid_offset: 棺材盖的偏移量,这样就灵活好多;
- lid_elevation: 棺材盖的阴影;
- trigger_open_offset: 触发打开棺材盖的距离;
- residual_view: 棺材盖打开后,显示在屏幕底部的View;
- tr-ansition_color: 过渡View的颜色,就是棺材盖下面渐变的View,打开前用来遮挡棺材底;
好了,属性我们准备好了,再来限制外部添加View的数量:
我们重写全部addView方法,并在最后一个addView方法里面限制:
@Override
public void addView(View child) {
addView(child, -1);
}
@Override
public void addView(View child, int index) {
if (child == null) {
throw new IllegalArgumentException("Cannot add a null child view to a ViewGroup");
}
LayoutParams params = child.getLayoutParams();
if (params == null) {
params = generateDefaultLayoutParams();
if (params == null) {
throw new IllegalArgumentException("generateDefaultLayoutParams() cannot return null");
}
}
addView(child, index, params);
}
@Override
public void addView(View child, int width, int height) {
final LayoutParams params = generateDefaultLayoutParams();
params.width = width;
params.height = height;
addView(child, -1, params);
}
@Override
public void addView(View child, ViewGroup.LayoutParams params) {
addView(child, -1, params);
}
@Override
public void addView(View child, int index, LayoutParams params) {
switch (getChildCount()) {
case 0:
mBottomView = child = packingBottomView(child);
break;
case 1:
addResidualView(index);
mLidView = child = packingLidView(child);
addHeaderView(index);
if (child != null) {
super.addView(child, index, params);
}
addTopBar(index);
addBottomBar(index);
return;
case 6:
throw new IllegalStateException("CoffinLayout child can't > 2");
default:
break;
}
if (child != null) {
super.addView(child, index, params);
}
}
主要是看最后一个方法,当前子View为0时,则认定本次添加的View是棺材底,为1时,则棺材盖。
我们在添加棺材底之前,还调用了一个packingBottomView方法,这个方法就是在棺材底上面,添加过渡View:
/**
* 给他包装一下, 加上一个过渡的view
*
* @param view 棺材底
* @return 包装后的棺材底
*/
private View packingBottomView(View view) {
if (mTransitionView != null && view != null) {
FrameLayout frameLayout = new FrameLayout(getContext());
frameLayout.addView(view);
frameLayout.addView(mTransitionView);
return frameLayout;
}
return null;
}
我们在添加棺材盖之前, 还先后添加了ResidualView(打开后显示在底部的View), HeaderView, 在棺材盖添加了之后,调用了addTopBar和addBottomBar方法,分别添加了它们两个。我们现在来看一下子View的顺序:
TopBar
HeaderView
BottomView
ResidualView
LidView
BottomBar
哈哈,这样是不是就清晰了好多。我们现在来看一下packingLidView方法做了什么:
/**
* 给他包装一下, 加上阴影
*
* @param view 棺材盖
* @return 包装后的棺材盖
*/
private View packingLidView(View view) {
if (mElevationView != null && view != null) {
LinearLayout linearLayout = new LinearLayout(getContext());
linearLayout.setOrientation(LinearLayout.VERTICAL);
linearLayout.addView(mElevationView);
linearLayout.addView(view);
return linearLayout;
}
return null;
}
原来就是在外面套了一层LinearLayout,加上了阴影, 至于阴影是怎么做的呢, 很简单,就一个View把GradientDrawable作为Background.
我们现在来看一下onMeasure方法是怎么不限制棺材盖和棺材底的高度的:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
for (int i = 0; i < getChildCount(); i++) {
View view = getChildAt(i);
//子view想要多高,就给它多高
view.measure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED));
}
//测量棺材底
View view = ((ViewGroup) mBottomView).getChildAt(0);
if (view != null) {
view.measure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED));
}
mTransitionView.measure(widthMeasureSpec, heightMeasureSpec);
setMeasuredDimension(widthMeasureSpec, heightMeasureSpec);
}
哈哈,我们是用了 MeasureSpec.UNSPECIFIED 这个不常用的模式来测量它们,这样我们在onLayout中分别调用它们的getMeasuredHeight方法就能拿到它们需要的高度了:
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
switch (getChildCount()) {
case 6:
//BottomBar当然是放在底部了
mBottomBar.layout(0, b - mBottomBar.getLayoutParams().height, r, b);
case 5:
//TopBar当然是顶部了
mTopBar.layout(0, 0, r, mTopBar.getLayoutParams().height);
case 4:
//顶部 + 偏移量
mHeaderView.layout(0, mHeaderViewOffset, r, mHeaderViewOffset + mHeaderView.getLayoutParams().height);
mHeaderView.setTranslationY(0);
case 3:
case 2:
//棺材盖: 棺材盖固定的偏移量 + 当前的偏移量
mLidView.layout(0, mLidOffset + mLidViewOffset, r, mLidOffset + mLidViewOffset + mLidView.getMeasuredHeight());
if (mResidualView != null) {
//棺材盖上面用来切换开关的view: 放在底部
mResidualView.layout(0, b, r, b + mResidualView.getLayoutParams().height);
}
case 1:
//棺材底: 顶部 + 偏移量
mBottomView.layout(0, mBottomViewOffset, r, mBottomViewOffset + mBottomView.getMeasuredHeight());
//过渡view: 与棺材底偏移量相反 (因为它要始终显示在屏幕内)
mTransitionView.layout(0, -mBottomViewOffset, r, -mBottomViewOffset + mTransitionView.getHeight());
break;
default:
break;
}
}
好了,现在我们可以先看看效果了:
布局的话, 我们的TopBar, BottomBar, HeaderView, ResidualView都是通过@layout的方式引用的, 要注意的是我们的lid_offset(棺材盖偏移量)刚好跟棺材底的第一个View的高度是一样的, 因为这样就可以把那个View完全显示出来, 还有HeaderView我们要做成跟棺材底的第一个View一样的高度以及显示内容.
<?xml version="1.0" encoding="utf-8"?>
<com.test.viewtest.views.CoffinLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/coffin_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="false"
app:bottom_bar="@layout/bottom_bar"
app:header_view="@layout/header_view"
app:lid_elevation="8dp"
app:lid_offset="240dp"
app:residual_view="@layout/residual_view"
app:top_bar="@layout/top_bar"
app:trigger_open_offset="100dp">
<!--棺材底-->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">
<ImageView
android:layout_width="match_parent"
android:layout_height="240dp"
android:adjustViewBounds="true"
android:scaleType="fitXY"
android:src="@drawable/ic_0" />
<android.support.v7.widget.RecyclerView
android:id="@+id/bottom_recycler_view"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
<!--棺材盖-->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">
<android.support.v7.widget.RecyclerView
android:id="@+id/top_recycler_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@android:color/white" />
<android.support.v7.widget.RecyclerView
android:id="@+id/horizontal_recycler_view"
android:layout_width="match_parent"
android:layout_height="250dp"
android:background="@android:color/white" />
</LinearLayout>
</com.test.viewtest.views.CoffinLayout>
看看效果:
好了,现在子View们都已就位了,还差个滚动的效果,说到滚动,我们就要用到Scroller了,但是只用Scroller是做不了像ScrollView那样的滑动效果的,因此我们还要用到一个VelocityTracker, 这个可以获取到手指移动的速率, 我们就用这个配合Scroller来完成惯性滚动效果。
既然是要监听触摸事件,身为爸爸身为ViewGroup,就要重写onInterceptTouchEvent方法了:
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
//正在播放开关动画: 拦截
if (isLidOpeningOrClosing()) {
return true;
}
//已经开始拖动: 拦截
final int action = ev.getAction();
if ((action == MotionEvent.ACTION_MOVE) && (isBeingDragged)) {
return true;
}
//爸爸需要拦截: 拦截
if (super.onInterceptTouchEvent(ev)) {
return true;
}
//不能拖动: 放行
if (!canScroll()) {
return false;
}
int y = (int) ev.getY();
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
//停止惯性滚动并刷新y坐标
if (!isLidOpeningOrClosing()) {
abortScrollerAnimation();
}
mLastY = y;
break;
case MotionEvent.ACTION_MOVE:
int offset = y - mLastY;
//判断是否触发拖动事件
if (Math.abs(offset) > mTouchSlop) {
mLastY = y;
isBeingDragged = true;
}
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_OUTSIDE:
isBeingDragged = false;
break;
}
return isBeingDragged;
}
那我们拦截了之后,就要怎么处理呢:
- 棺材盖我们设定有5种状态: 半开,全开,合上,正在打开,正在关闭;
- move事件我们就判断当前棺材盖的状态,如果棺材盖未打开,那就滚动棺材盖,反之,则滚动棺材底.
- up事件: 如果棺材盖是半开的状态,则判断是否向下滑动,如果滑动的距离达到我们给定的距离,就触发打开棺材盖,如果距离不够,就回弹; 如果棺材盖是全开或合上状态,则根据手指速率,开始惯性滚动:
@Override
public boolean onTouchEvent(MotionEvent event) {
mVelocityTracker.addMovement(event);
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
if (!isLidOpeningOrClosing()) {
abortScrollerAnimation();
} else {
return false;
}
mLastY = y;
break;
case MotionEvent.ACTION_MOVE:
if (mCurrentStatus == STATE_NAKED) {
offsetBottomView(y);
} else {
offsetLidView(y);
}
if (mCurrentStatus != STATE_NAKED) {
mVelocityTracker.computeCurrentVelocity(1000);
float velocityY = mVelocityTracker.getYVelocity();
//根据手指滑动的速率和方向来判断是否要隐藏或显示TopBar
if (Math.abs(velocityY) > 4000) {
if (velocityY > 0) {
if (mTopBar != null && mTopBar.getTranslationY() == -mTopBar.getLayoutParams().height) {
showTopBar();
}
} else {
if (mTopBar != null && mTopBar.getTranslationY() == 0) {
hideTopBar();
}
}
}
}
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_OUTSIDE:
case MotionEvent.ACTION_CANCEL:
boolean isHandle = false;
if (mCurrentStatus == STATE_HALF) {
//大于触发距离, 则打开棺材盖, 反之
if (mLidView.getTop() >= mTriggerOffset) {
openCoffin();
isHandle = true;
} else if (mLidView.getTop() > mLidOffset) {
closeCoffin();
isHandle = true;
}
}
//没有触发打开或关闭棺材盖的动画, 则开始惯性滚动
if (!isHandle) {
mVelocityTracker.computeCurrentVelocity(1000);
mScroller.fling(0, 0, 0, (int) mVelocityTracker.getYVelocity(),
0, 0, Integer.MIN_VALUE, Integer.MAX_VALUE);
invalidate();
}
//标记状态
isBeingDragged = false;
break;
default:
break;
}
return true;
}
那现在我们再来看一下computeScroll方法:
- 棺材盖未打开: 滚动棺材盖,并做越界处理;
- 棺材盖已打开: 滚动棺材底,并做越界处理;
- 本次滚动结束: 更新状态;
/**
* 计算平滑滚动
*/
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
int y = mScroller.getCurrY();
//是新的一轮则刷新offset
if (isNewScroll) {
isNewScroll = false;
mScrollOffset = y;
}
//未开盖: 滚动棺材盖
if (mCurrentStatus != STATE_NAKED) {
//判断是否还可以滚动
if (mLidView != null && mLidView.getBottom() >= getBottom()) {
int offset = y - mScrollOffset;
//判断是否越界: 如果越界,则本次偏移量为可以滑动的最大值
if (mLidView.getBottom() + offset < getBottom()) {
offset = getBottom() - mLidView.getBottom();
} else if (mScroller.getCurrVelocity() > 0 && offset > 0) {//手指滑动, 并且是向下滑
if (mLidView.getTop() + offset >= mLidOffset && !isLidOpeningOrClosing()) {
offset = mLidOffset - mLidView.getTop();
}
}
offsetChildView(offset);
}
} else {//已开盖: 滚动棺材底
//判断是否还可以滚动
if (mBottomView != null && mBottomView.getBottom() >= getBottom()
&& mBottomView.getTop() <= getTop()) {
int offset = y - mScrollOffset;
//判断是否越界: 如果越界,则本次偏移量为可以滑动的最大值
if (mBottomView.getBottom() + offset < getBottom()) {
offset = getBottom() - mBottomView.getBottom();
} else if (mBottomView.getTop() + offset > getTop()) {
offset = getTop() - mBottomView.getTop();
}
mBottomViewOffset += offset;
mBottomView.offsetTopAndBottom(offset);
mTransitionView.offsetTopAndBottom(-offset);
}
}
mScrollOffset = y;
invalidate();
}
if (mScroller.isFinished()) {
isNewScroll = true;
//滚动结束, 更新状态
if (mCurrentStatus == STATE_OPENING) {
mTransitionView.setVisibility(INVISIBLE);
mHeaderView.setVisibility(INVISIBLE);
if (mResidualView != null) {
showResidualView();
}
mCurrentStatus = STATE_NAKED;
notifyListener();
} else if (mCurrentStatus == STATE_CLOSING) {
int offset = getTop() - mBottomView.getTop();
mBottomViewOffset += offset;
mBottomView.offsetTopAndBottom(offset);
mTransitionView.offsetTopAndBottom(-offset);
if (mResidualView != null) {
mResidualView.setTranslationY(0);
}
mCurrentStatus = STATE_HALF;
notifyListener();
}
}
}
跟一般的平滑滚动情况不同, 因为我们要做的滚动是可以分离的, 所以肯定不能用scrollTo.
做过跟随手指移动的小伙伴就会对那3个方法很熟悉了:
- layout(int l, int t, int r, int b)
- offsetLeftAndRight(int offset)
- offsetTopAndBottom(int offset)
我们只需要上下滑动, 所以用offsetTopAndBottom方法就可以了.
现在滑动是准备好了, 还有棺材盖跟HeaderView的视差效果, 这个其实也很简单, 我们在棺材盖offset的时候, 再根据当前的top值, 来决定HeaderView的偏移情况, 如果top值大于HeaderView的高度, 那就不需要做处理了, 否则将棺材盖本次需要offset的值除以2, 得到HeaderView的值, 那么棺材盖在向上滚动的时候, HeaderView就在他下面慢慢地也向上滚动了. 我们看看代码吧:
/**
* 更新棺材盖的位置
*
* @param y 偏移量
*/
private void offsetLidView(int y) {
if (mLidView != null && mLidView.getBottom() >= getBottom()) {
int offset = y - mLastY;
//判断是否越界
if (mLidView.getBottom() + offset < getBottom()) {
offset = getBottom() - mLidView.getBottom();
}
//如果棺材盖未打开, 并且是向下滑动, 则加一个阻尼效果
if (offset > 0 && mLidView.getTop() > mLidOffset) {
offset /= 2;
}
//更新需要联动的view
offsetChildView(offset);
//更新状态
int newState = mLidView.getTop() <= getTop() ? STATE_COVER : STATE_HALF;
if (mCurrentStatus != newState) {
mCurrentStatus = newState;
notifyListener();
}
}
mLastY = y;
}
/**
* 更新棺材盖和其他需要联动的View的位置
*
* @param offset 偏移量
*/
private void offsetChildView(int offset) {
//不是正在打开或关闭状态,并且棺材盖当前位置高于默认的偏移量
if (!isLidOpeningOrClosing() && mLidView.getTop() < mLidOffset) {
int bottomViewOffset = offset / 2;//损失一半
//判断越界
if (mBottomView.getTop() > getTop() || mBottomView.getTop() + bottomViewOffset > getTop()) {
bottomViewOffset = getTop() - mBottomView.getTop();
}
//更新BottomView和HeaderView的位置
mBottomViewOffset += bottomViewOffset;
mBottomView.offsetTopAndBottom(bottomViewOffset);
mHeaderViewOffset += bottomViewOffset;
mHeaderView.offsetTopAndBottom(bottomViewOffset);
mTransitionView.offsetTopAndBottom(-bottomViewOffset);
}
//更新棺材盖的位置
mLidViewOffset += offset;
mLidView.offsetTopAndBottom(offset);
//更新TopBar的透明度
float percent = (float) mLidViewOffset / (getBottom() - mLidOffset);
mTransitionView.setAlpha(1F - percent);
percent = (float) (mLidView.getTop() - mTopBar.getHeight()) / (mLidOffset - mTopBar.getHeight());
if (percent > 1F) {
percent = 1F;
}
if (percent < 0) {
percent = 0;
}
setTopBarBackgroundAlpha(percent);
}
棺材底因为没有其他View的联动, 所以offsetBottomView方法就offset自己就行了.
哈哈, 现在还差棺材盖, TopBar, BottomBar, ResidualView, HeaderView他们的打开, 关闭动画, 就基本完成我们的CoffinLayout了, 其实那些动画也很简单, 都是用的ValueAnimator, 不过我们再想想: 这么多View的动画, 无非就是起点和终点不同, 最后都是调用ValueAnimator的start方法来开始的, 为了代码质量, 肯定要先封装一个方法了:
/**
* 执行动画
*
* @param target 要执行动画的view
* @param startY 开始值
* @param endY 结束值
*/
private void startValueAnimation(View target, int startY, int endY) {
ValueAnimator animator = ValueAnimator.ofInt(startY, endY).setDuration(ANIMATION_DURATION);
animator.addUpdateListener(animation -> target.setTranslationY((int) animation.getAnimatedValue()));
animator.start();
}
哈哈, 这样一来, 我们需要播放动画的时候, 就舒服多了:
public void showBottomBar() {
startValueAnimation(mBottomBar, mBottomBar.getLayoutParams().height, 0);
}
public void hideBottomBar() {
startValueAnimation(mBottomBar, 0, mBottomBar.getLayoutParams().height);
}
private void showResidualView() {
startValueAnimation(mResidualView, 0, -mResidualView.getLayoutParams().height);
}
private void showHeaderView() {
startValueAnimation(mHeaderView, Math.abs(mBottomView.getTop()) >
mHeaderView.getHeight() ? -mHeaderView.getHeight() : mBottomView.getTop(), 0);
}
private void showTopBar() {
startValueAnimation(mTopBar, -mTopBar.getLayoutParams().height, 0);
}
private void hideTopBar() {
startValueAnimation(mTopBar, 0, -mTopBar.getLayoutParams().height);
}
对了, 我们应该向外公开两个方法: 打开棺材盖, 合上棺材盖.
先看看怎么打开:
/**
* 打开棺材盖
*/
public void openCoffin() {
if (mCurrentStatus == STATE_OPENING) {
return;
}
abortScrollerAnimation();
isNewScroll = true;
int offset = getBottom() - mLidView.getTop();
mScroller.startScroll(0, 0, 0, offset, ANIMATION_DURATION);
mCurrentStatus = STATE_OPENING;
notifyListener();
invalidate();
if (mBottomBar != null) {
hideBottomBar();
}
if (mTopBar != null) {
if (mTopBar.getTranslationY() == 0) {
hideTopBar();
} else{
postDelayed(() -> {
if (mTopBar.getTranslationY() == 0) {
hideTopBar();
}
}, ANIMATION_DURATION);
}
}
}
我们先是判断了当前状态, 如果正在打开就直接return. 接着我们还打断了当前未完成的惯性滚动, 开始了新的一轮滚动, 因为我们上面的computeScroll方法已经做了平滑滚动的处理, 所以这里调用了Scroller的startScroll方法之后直接invalidate就行了. 之后我们就隐藏了BottomBar和TopBar, 如果TopBar正在执行打开动画,那么我们等他打开完,再来隐藏他.
再看看怎么关闭:
/**
* 关闭棺材盖
*/
public void closeCoffin() {
if (mCurrentStatus == STATE_CLOSING) {
return;
}
abortScrollerAnimation();
isNewScroll = true;
int offset = mLidOffset - mLidView.getTop();
mScroller.startScroll(0, 0, 0, offset, ANIMATION_DURATION);
mTransitionView.setVisibility(VISIBLE);
mHeaderView.setVisibility(VISIBLE);
if (mCurrentStatus == STATE_NAKED) {
showHeaderView();
}
mCurrentStatus = STATE_CLOSING;
notifyListener();
invalidate();
if (mBottomBar != null && mBottomBar.getTranslationY() > 0) {
showBottomBar();
}
if (mTopBar != null && mTopBar.getTranslationY() < 0) {
showTopBar();
}
}
emmm, 跟打开的逻辑差不多, 不过这里多了一个showHeaderView方法, 如果是已经打开了的棺材盖, 就要播放显示HeaderView的动画, 如果是没达到触发的距离, 回弹到原位的情况下, 就不用播放了.
哈哈, 现在基本上是完成了, 再来看看我们的劳动效果: