仿虾米播放页面
最近一直在学习Android的事件分发以及自定义View这块内容,正好看到虾米播放页面的交互效果挺炫酷的,就决定仿写一下练练手。
本文参考自:http://blog.cgsdream.org/2016/12/30/android-nesting-scroll/
一、效果预览
二、效果分析
由预览图不难看出,该自定义view继承自ViewGroup,内部摆放了3个子view,顶部控制播放的hoverVeiw;
存放音乐封面和播放控制的headerView;以及底部评论列表targetView。且hoverView随评论列表的滑动而显示或隐藏。
三、自定义View的属性
<declare-styleable name="XiamiPlayLayout">
<attr name="header_view" format="reference"/>
<attr name="target_view" format="reference"/>
<attr name="hover_view" format="reference"/>
<attr name="header_init_offset"/>
<--targetView与headerView重合交接处距ViewGroup顶部偏移量-->
<attr name="target_end_offset"/>
<--targetView初始化时距ViewGroup顶部偏移量-->
<attr name="target_init_offset"/>
</declare-styleable>
四、自定义view套路代码
public class XiamiPlayLayout extends ViewGroup {
public XiamiPlayLayout(Context context) {
this(context, null);
}
public XiamiPlayLayout(Context context, AttributeSet attrs) {
super(context, attrs);
TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.XiamiPlayLayout, 0, 0);
mHeaderViewId = array.getResourceId(R.styleable.XiamiPlayLayout_header_view, 0);
mTargetViewId = array.getResourceId(R.styleable.XiamiPlayLayout_target_view, 0);
mHoverViewId = array.getResourceId(R.styleable.XiamiPlayLayout_hover_view, 0);
mHeaderInitOffset = array.getDimensionPixelSize(R.styleable.XiamiPlayLayout_header_init_offset, Util.dp2px(getContext(), 0));
mTargetInitOffset = array.getDimensionPixelSize(R.styleable.XiamiPlayLayout_target_init_offset, Util.dp2px(getContext(), 200));
mHeaderCurrentOffset = mHeaderInitOffset;
mTargetCurrentOffset = mTargetInitOffset;
//target滑动终止位置
mTargetEndOffset = array.getDimensionPixelSize(R.styleable.XiamiPlayLayout_target_end_offset, Util.dp2px(context, 40));
array.recycle();
ViewCompat.setChildrenDrawingOrderEnabled(this, true);
final ViewConfiguration vc = ViewConfiguration.get(getContext());
mMaxVelocity = vc.getScaledMaximumFlingVelocity();
mMinVelocity = vc.getScaledMinimumFlingVelocity();
mTouchSlop = Util.px2dp(context, vc.getScaledTouchSlop()); //系统的值是8dp,太大了。。。
mScroller = new Scroller(getContext());
mScroller.setFriction(0.98f);
}
onFinishInflate当xml文件解析后调用,在该方法内找控件,初始化子view。
@Override
protected void onFinishInflate() {
super.onFinishInflate();
if (mHeaderViewId != 0) {
mHeaderView = findViewById(mHeaderViewId);
}
if (mTargetViewId != 0) {
mTargetView = findViewById(mTargetViewId);
ensureTarget();
}
if (mHoverViewId != 0) {
mHoverView = findViewById(mHoverViewId);
}
}
//targetView必须实现ITargetView接口,下面会讲
private void ensureTarget() {
if (mTargetView instanceof ITargetView) {
mTarget = (ITargetView) mTargetView;
} else {
throw new RuntimeException("TargetView should implement interface ITargetView");
}
}
五、测量
自身尺寸测量直接调用super.onMeasure(widthMeasureSpec, heightMeasureSpec);交给系统处理
然后,测量它的子view的尺寸,调用系统方法即可。在测量之前,需要确保该ViewGrop设置了子view并初始化完毕。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
ensureHeaderViewAndScrollView();
final int resizeHeightMeasureSpec = MeasureSpec.makeMeasureSpec(
MeasureSpec.getSize(heightMeasureSpec) - mTargetEndOffset,
MeasureSpec.getMode(heightMeasureSpec));
measureChild(mTargetView, widthMeasureSpec, resizeHeightMeasureSpec);
measureChild(mHeaderView, widthMeasureSpec, heightMeasureSpec);
measureChild(mHoverView, widthMeasureSpec, heightMeasureSpec);
}
/**
* 确认子view,如果没有子view,即onFinishInflate中未找到,那么采用getChildAt(int position)方法寻找子view。
*/
private void ensureHeaderViewAndScrollView() {
if (mHeaderView != null && mTargetView != null && mHoverView != null) {
return;
}
if (mHeaderView == null && mTargetView == null && mHoverView == null && getChildCount() >= 3) {
mHoverView = getChildAt(0);
mHeaderView = getChildAt(1);
mTargetView = getChildAt(2);
ensureTarget();
return;
}
throw new RuntimeException("please ensure headerView and scrollView");
}
这里有个需要注意的地方,测量headerView,因为可能设置了target_end_offset,所以XiamiPlayLayout留给headerView的空间并不是自身全部高度,而是要减掉target_end_offset的尺寸。因此 measureChild(mTargetView, widthMeasureSpec, resizeHeightMeasureSpec);
中heightMeasureSpec
改为->resizeHeightMeasureSpec
。
六、修改子view绘制顺序
首先在构造方法中开启允许重绘
ViewCompat.setChildrenDrawingOrderEnabled(this, true);
先绘制headerView,在绘制hoverView,最后绘制targetView。
@Override
protected int getChildDrawingOrder(int childCount, int i) {
ensureHeaderViewAndScrollView();
int hoverIndex = indexOfChild(mHoverView);
int headerIndex = indexOfChild(mHeaderView);
if (hoverIndex == i) {
return 1;
} else if (headerIndex == i) {
return 0;
} else {
return 2;
}
}
七、摆放
计算好各个子view初始化时的坐标,一次调用它们的layout方法摆放,没啥好讲的。
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
final int width = getMeasuredWidth();
final int height = getMeasuredHeight();
if (getChildCount() == 0) {
return;
}
ensureHeaderViewAndScrollView();
final int childLeft = getPaddingLeft();
final int childTop = getPaddingTop();
final int childWidth = width - getPaddingLeft() - getPaddingRight();
final int childHeight = height - getPaddingTop() - getPaddingBottom();
mTargetView.layout(childLeft, childTop + mTargetCurrentOffset,
childLeft + childWidth, childTop + childHeight + mTargetCurrentOffset);
mHeaderViewWidth = mHeaderView.getMeasuredWidth();
mHeaderViewHeight = mHeaderView.getMeasuredHeight();
mHeaderView.layout((width / 2 - mHeaderViewWidth / 2), mHeaderCurrentOffset,
(width / 2 + mHeaderViewWidth / 2), mHeaderCurrentOffset + mHeaderViewHeight);
mHoverViewWidth = mHoverView.getMeasuredWidth();
mHoverViewHeight = mHoverView.getMeasuredHeight();
mHoverView.layout((width - mHoverViewWidth) / 2, -mHoverViewHeight, (width / 2 + mHoverViewWidth /2), 0);
}
八、重头戏,事件拦截分发
虽然所有事件都会走dispatchTouchEvent,但重写处理比较复杂,所以一般套路是重写onInterceptTouchEvent和onTouchEvent。
onInterceptTouchEvent处理思路:
ACTION_DOWN事件中记录手指按下坐标。
ACTION_MOVE事件中,根据手指滑动而变化的实时坐标与按下坐标比对,决定是否拦截。
在这里,先要弄清楚拦截事件意味着什么?当move事件拦截,此次事件及后续的事件就不会再向子view传递,转而都交给自身的onTouchEvent处理!
什么情况下需要拦截呢?当需要滑动headerView的情况下拦截事件,所以只需要判决targetView需要滑动的时候的边界情况。
1、下拉,且targetView中的滑动控件不可再下拉了,那么就拦截事件让XiamiPlayLayout自身处理。
2、上拉,且targetView还没到顶的时候,那么就拦截事件让XiamiPlayLayout自身处理。
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
ensureHeaderViewAndScrollView();
final int action = MotionEvent.getActionMasked(ev);
int pointerIndex;
//如果该控件不可用,不拦截,向下传递事件。
if(!isEnabled()) return false;
switch (action) {
case MotionEvent.ACTION_DOWN:
//记录当前活动的pointerId(处理多指触摸)
mActivePointerId = ev.getPointerId(0);
mIsDragging = false;
pointerIndex = ev.findPointerIndex(mActivePointerId);
if (pointerIndex < 0) {
return false;
}
//记录按下时的坐标
mInitialDownY = ev.getY(pointerIndex);
mInitialDownX = ev.getX(pointerIndex);
break;
case MotionEvent.ACTION_MOVE:
pointerIndex = ev.findPointerIndex(mActivePointerId);
if (pointerIndex < 0) {
Log.e(TAG, "Got ACTION_MOVE event but have an invalid active pointer id.");
return false;
}
//记录触摸移动后的坐标
final float y = ev.getY(pointerIndex);
final float x = ev.getX(pointerIndex);
// mIsDragging置为true,拦截事件,交由自身处理
startDragging(y);
if (mIsDragging) {
//过滤掉左右滑动距离大于上下滑动距离的情况。不然左右滑动viewpager也会导致上下滑动。
if (Math.abs(x - mInitialDownX) > Math.abs(y - mInitialDownY)) {
mIsDragging = false;
}
}
break;
case MotionEvent.ACTION_POINTER_UP:
//多指
onSecondaryPointerUp(ev);
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
//取消拦截事件
mIsDragging = false;
mActivePointerId = INVALID_POINTER;
break;
}
return mIsDragging;
}
private void startDragging(float y) {
//下拉且targetView不可再下拉了 或者 上拉且targetView可以往上拖拽
if ((y > mInitialDownY && !mTarget.canChildScrollUp()) ||
(y < mInitialDownY && mTargetCurrentOffset > mTargetEndOffset)) {
final float yDiff = Math.abs(y - mInitialDownY);
if (yDiff > mTouchSlop && !mIsDragging) {
//初始移动的坐标
mInitialMotionY = y;
Log.e("startDragging: ", mInitialMotionY + " ");
mLastMotionY = mInitialMotionY;
mIsDragging = true;
}
}
}
onTouchEvent处理思路:
mIsDragging为true时,事件交给了XiamiPlayLayout处理,在onTouchEvent中移动targetView和hoverView。
这里有两个坑点需要解决:
- 下拉,直接调用moveTargetView(float dy)方法移动两个子view即可。试想下这种特殊情况:刚开始是targetView内部滑动控件滑动,当内部滑动控件不可再下拉时,事件需要经过XiamiPlayLayout的onInterceptTouchEvent拦截,不再分发给内部滑动控件,转交给onTouchEvent处理,targetView和hoverView移动。
但在这里有个坑点,注意加粗的部分,事件一定都经过onInterceptTouchEvent吗?不是的!当子View处理事件后,
有一种情况是子View主动调用parent.requestDisallowInterceptTouchEvent(true)来告诉系统说:这个事件我要了,父View不要拦截了。这就是所谓的内部拦截法。在ListView的某些时刻它会去调用这个方法。因此一旦事件传递给了ListView,外部容器就拿不到这个事件了。因此我们要打破它的内部拦截:
@Override
public void requestDisallowInterceptTouchEvent(boolean b) {
// 去掉默认行为,使得每个事件都会经过这个Layout
}
- 上拉,上拉也有它特殊的地方,同样试想一下:刚开始是targetView在往上移动,当移至顶部(mTargetEndOffset位置)时,targetView不能继续往上滑了,转而要将事件交给targetView的内部滑动控件上拉滑动。但我们知道,android系统在事件派发时,如果事件被父View处理,即被父View拦截,那么之后的事件都将不会传递给子view了。其解决方案也很简单:在滚动到顶部时主动派发一次Down事件:
if (mTargetCurrentOffset + dy <= mTargetEndOffset) {
moveTargetView(dy);
// 重新dispatch一次down事件,使得列表可以继续滚动
int oldAction = ev.getAction();
ev.setAction(MotionEvent.ACTION_DOWN);
dispatchTouchEvent(ev);
ev.setAction(oldAction);
} else {
moveTargetView(dy);
}
好了,解决了这两个坑点后就能愉快的写代码了:
@Override
public boolean onTouchEvent(MotionEvent ev) {
final int action = MotionEventCompat.getActionMasked(ev);
int pointerIndex;
if(!isEnabled()) return false;
//初始化速度追踪器
acquireVelocityTracker(ev);
switch (action) {
case MotionEvent.ACTION_DOWN:
//获取活动的pointerId
mActivePointerId = ev.getPointerId(0);
break;
case MotionEvent.ACTION_MOVE: {
pointerIndex = ev.findPointerIndex(mActivePointerId);
if (pointerIndex < 0) {
Log.e(TAG, "Got ACTION_MOVE event but have an invalid active pointer id.");
return false;
}
final float y = ev.getY(pointerIndex);
//自身开始targetView的拖动,非targetView中列表
if (mIsDragging) {
float dy = y - mLastMotionY;
if (dy >= 0) {
//下拉,直接移动targetView.
moveTargetView(dy);
} else {
//上拉
if (mTargetCurrentOffset + dy <= mTargetEndOffset) {
//如果偏移量减去移动距离后,偏移量小于等于0,移动targetView
moveTargetView(dy);
// 重新dispatch一次down事件,使得列表可以继续滚动
int oldAction = ev.getAction();
ev.setAction(MotionEvent.ACTION_DOWN);
dispatchTouchEvent(ev);
ev.setAction(oldAction);
} else {
//否则直接移动targetView.
moveTargetView(dy);
}
}
mLastMotionY = y;
}
break;
}
case MotionEvent.ACTION_POINTER_DOWN: {
pointerIndex = ev.getActionIndex();
if (pointerIndex < 0) {
Log.e(TAG, "Got ACTION_POINTER_DOWN event but have an invalid action index.");
return false;
}
mActivePointerId = ev.getPointerId(pointerIndex);
//初始按下坐标以及上次按下坐标都得转移到这根手指所处坐标。
mInitialMotionY = ev.getY(pointerIndex);
mLastMotionY = mInitialMotionY;
break;
}
case MotionEvent.ACTION_POINTER_UP:
onSecondaryPointerUp(ev);
break;
case MotionEvent.ACTION_UP: {
pointerIndex = ev.findPointerIndex(mActivePointerId);
if (pointerIndex < 0) {
Log.e(TAG, "Got ACTION_UP event but don't have an active pointer id.");
return false;
}
if (mIsDragging) {
mIsDragging = false;
//计算瞬时速度
mVelocityTracker.computeCurrentVelocity(1000, mMaxVelocity);
final float vy = mVelocityTracker.getYVelocity(mActivePointerId);
finishDrag((int) vy);
}
mActivePointerId = INVALID_POINTER;
releaseVelocityTracker();
return false;
}
case MotionEvent.ACTION_CANCEL:
releaseVelocityTracker();
return false;
}
return mIsDragging;
}
下面是移动headerView以及hoverView的方法。
private void moveTargetView(float dy) {
int target = (int) (mTargetCurrentOffset + dy);
moveTargetViewTo(target);
}
private void moveTargetViewTo(int target) {
//设定最小偏移量
target = Math.max(target, mTargetEndOffset);
//设定最大偏移量
target = Math.min(target, mTargetInitOffset);
//偏移dy
ViewCompat.offsetTopAndBottom(mTargetView, target - mTargetCurrentOffset);
//记录当前偏移量
mTargetCurrentOffset = target;
//当targetView 偏移量为0(mTargetEndOffset)的时候,hover向下偏移mHoverViewHeight
//当targetView 偏移量为mTargetInitOffset的时候,hover偏移量为0
if (mTargetCurrentOffset <= mTargetInitOffset && mTargetCurrentOffset >= mTargetEndOffset) {
int total = mTargetInitOffset - mTargetEndOffset;
float percent = (mTargetCurrentOffset - mTargetEndOffset) * 1.0f / total;
ViewCompat.setTranslationY(mHoverView, mHoverViewHeight * (1-percent));
}
}
九、接口补充说明
上面提到了一个方法:
private void ensureTarget() {
if (mTargetView instanceof ITargetView) {
mTarget = (ITargetView) mTargetView;
} else {
throw new RuntimeException("TargetView should implement interface ITargetView");
}
}
将mTargetView强转为了ITargetView接口,ITargetView接口定义如下:
public interface ITargetView {
//报告targetView自身是否可以下拉。
boolean canChildScrollUp();
//交给targetView自身处理fling
void fling(float vy);
}
因为我们并不知道targetView中子view是啥,可能是ScrollView、listView? 还是RecyclerView?所以我们索性定义了接口,让targetView实现。这也体现了面向对象的依赖倒置原则。
十、惯性滚动
当手指松开后,view的滑动会立即停止,显得十分生硬。为了让滑动看上去更自然些,需要加入惯性滑动效果。需要用到VelocityTracker
以及Scroller
这两个类来处理。
onTouchEvent方法中,每个事件到来,都加入VelocityTracker中,在ACTION_UP以及ACTION_CANCEL中释放releaseVelocityTracker。
public boolean onTouchEvent(MotionEvent ev) {
...
acquireVelocityTracker(ev);
...
case MotionEvent.ACTION_UP:
...
releaseVelocityTracker();
break;
case MotionEvent.ACTION_CANCEL:
releaseVelocityTracker();
return false;
}
private void acquireVelocityTracker(final MotionEvent event) {
if (null == mVelocityTracker) {
mVelocityTracker = VelocityTracker.obtain();
}
mVelocityTracker.addMovement(event);
}
private void releaseVelocityTracker() {
if (null != mVelocityTracker) {
mVelocityTracker.clear();
mVelocityTracker.recycle();
mVelocityTracker = null;
}
}
ACTION_UP时,获取伪瞬时速度,并调用finishDrag(int vy)处理滚性滑动:
mVelocityTracker.computeCurrentVelocity(1000, mMaxVelocity);
final float vy = mVelocityTracker.getYVelocity(mActivePointerId);
finishDrag((int) vy);
private void finishDrag(int vy) {
Log.i(TAG, "TouchUp: vy = " + vy);
//手指滑动起点坐标 - 手指滑动终点坐标
//有时我们下拉fling,手指抬起瞬间有轻微的反方向滑动,导致vy<0,targetView反向fling,,加上该判断过滤这种情况
final float diffY = mLastMotionY - mInitialMotionY;
if (vy > 0 && diffY > 0) {
//下拉
mNeedScrollToInitPos = true;
mScroller.fling(0, mTargetCurrentOffset, 0, vy, 0, 0, mTargetEndOffset, Integer.MAX_VALUE);
invalidate();
} else if (vy < 0 && diffY < 0) {
//上拉
mNeedScrollToEndPos = true;
mScroller.fling(0, mTargetCurrentOffset, 0, vy, 0, 0, mTargetEndOffset, Integer.MAX_VALUE);
invalidate();
} else {
if (mTargetCurrentOffset <= (mTargetEndOffset + mTargetInitOffset) / 2) {
mNeedScrollToEndPos = true;
} else {
mNeedScrollToInitPos = true;
}
invalidate();
}
}
最后在computeScroll中移动View:
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
//fling阶段
Log.d(TAG, "computeScroll: " + "开始fling" + mScroller.getCurrY());
int offsetY = mScroller.getCurrY();
moveTargetViewTo(offsetY);
invalidate();
} else if (mNeedScrollToInitPos) {
//fling结束后,回滚到初始位置
mNeedScrollToInitPos = false;
if (mTargetCurrentOffset >= mTargetInitOffset) {
return;
}
Log.d(TAG, "computeScroll: " + "fling结束后,回到下面");
mScroller.startScroll(0, mTargetCurrentOffset, 0, mTargetInitOffset - mTargetCurrentOffset);
invalidate();
} else if (mNeedScrollToEndPos) {
//fling结束后,回滚到顶点
mNeedScrollToEndPos = false;
if (mTargetCurrentOffset <= mTargetEndOffset) {
if (mScroller.getCurrVelocity() > 0) {
// 如果还有速度,则传递给子view
mTarget.fling(-mScroller.getCurrVelocity());
Log.d(TAG, "computeScroll: " + "传递速度" + -mScroller.getCurrVelocity());
}
}
mScroller.startScroll(0, mTargetCurrentOffset, 0, mTargetEndOffset - mTargetCurrentOffset);
invalidate();
}
}
十一、项目地址
最后,该demo的github地址:https://github.com/sankemao/XiamiPlayView