博主是爱奇艺员工,以上几个都是从爱 奇艺泡泡客户端中截取的。
本文中一共举出了四个栗子:内容由简到难,但是分析方法和基本原理都是相似的。
本文四个控件的代码都是笔者自己手写的。希望可以给自己留下些笔记,也给后来者一些启发。
一. 下拉回弹控件 + 收起
功能点分析
- 下拉手势判定 + View位移
- 松手之后 + View位移
View位移推荐使用translationY, 建议在做位移操作时不要直接调用View.setTranslationY()
而是应该封装一个统一的方法
public float getCurrentOffset(){
return getTranslationY();
}
public void setOffset(float targetScrollX){
//标准坐标轴 右下为正
//进行左右平移时,需要保证平移的scrollX 范围是 0 - mRefreshView.width()
targetScrollX = checkOffsetX(targetScrollX);
// scrollTo(0,(int)targetScrollX);
setTranslationY(targetScrollX);
}
private float checkOffsetX(float target) {
if(target > getMaxOffset() /*|| Math.abs(target - getMaxOffset()) < 10*/){
target = getMaxOffset();
}else if(target < 0){
target = 0;
}
return target;
}
这样的好处是:如果希望修改一种位移方式(例如使用ScrollTo)时,所做的修改量很小。
核心的事件处理部分:
/*相关变量*/
private float mTouchSlop;//最小位移
/*上一次的点击位置*/
private float mXDown;
private float mYDown;
private float mYLastMove;//上一次move事件的Y坐标
private float mYMove;
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
if(mHandler!=null && mHandler.shouldForbidden() || ev.getPointerCount() > 1){
return super.onInterceptTouchEvent(ev);
}
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
mXDown = ev.getRawX();
mYDown = ev.getRawY();
mYLastMove = mYDown;
break;
case MotionEvent.ACTION_MOVE:
mXMove = ev.getRawX();
mYMove = ev.getRawY();
float diffX = (mXMove - mXDown);
float diffY = (mYMove - mYDown);
if(Math.abs(diffY) < mTouchSlop || Math.abs(diffY * 0.5) < Math.abs(diffX)){// 过滤掉水平方向的手势
break;
}
mYLastMove = mYMove;
return true;
}
return super.onInterceptTouchEvent(ev);
}
public float getMaxOffset(){
return mTargetView.getHeight();
}
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_MOVE:
mYMove = event.getRawY();
float deltaY = 1.2f * (mYMove - mYLastMove);//正规坐标轴下的偏移
setOffset(getCurrentOffset() + deltaY);
mYLastMove = mYMove;
break;
case MotionEvent.ACTION_UP:
// 当手指抬起时,根据当前的滚动值来判定应该滚动到哪个子控件的界面
onRelease();
break;
}
return true;
}
public float getCurrentOffset(){
return getTranslationY();
}
整体思路还是按照View的动作拦截机制完成的。
在onInterceptTouchEvent进行动作判别、拦截。
在onTouchEvnet中完成偏移量计算、View的位移、以及回弹动画的播放。
回弹动画
public void onRelease(){
final boolean hasGotPoint = Math.abs(getCurrentOffset()) >= mTriggerPoint;
mAnimator = ValueAnimator.ofFloat(getCurrentOffset(), hasGotPoint? getMaxOffset() : 0).setDuration(ANIMATOR_DURATION);
mAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float animatedValue = (float)animation.getAnimatedValue();
setOffset(animatedValue);
}
});
mAnimator.addListener(new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animation) {
}
@Override
public void onAnimationEnd(Animator animation) {
//todo 进入详情页
if(mListener!=null && hasGotPoint) {
mListener.onTriggered();
}
}
@Override
public void onAnimationCancel(Animator animation) {
}
@Override
public void onAnimationRepeat(Animator animation) {
}
});
mAnimator.start();
}
二. 视频缩放 + View动画
这个效果看起来稍微复杂,但是基本实现思路是类似的
1.找到合适的动作触发时机
2.对View进行操作
除此之外还有几个点需要注意:
1.从上图可以看到视频的主要形态有三种,100%,80%以及隐藏。状态的跳转需要记录。
由于这个view的动画基本上是只要触发就会进行下去的。
- 内部还有个ListView。需要处理好和ListView的冲突。
3.另外,由于动作几乎是立即触发并且不可逆的(施加动作之后就会执行形变)
所以,我们只在onInterceptTouchEvnet中就可以完成主要逻辑了。
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
if (listView == null || videoLayout == null) {//子控件还未初始化
return super.onInterceptTouchEvent(ev);
}
if (!enable) {//禁用开关
return super.onInterceptTouchEvent(ev);
}
//操作区域在listView以上,即视频区域内
int y = (int) ev.getRawY();
int x = (int) ev.getRawX();
int[] location = new int[2];
listView.getLocationOnScreen(location);
if (y < location[1]) {
return super.onInterceptTouchEvent(ev);
}
if (isAnimationPlaying) {
return true;//动画播放期间禁止操作
}
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
// 发生down事件时,记录y坐标
mLastMotionY = y;
mLastMotionX = x;
break;
case MotionEvent.ACTION_MOVE:
deltaY = y - mLastMotionY;
if (Math.abs(deltaY) < 20) {
break;
}
if (!isVideoStop() && isListViewTopping()) {
//非暂停态
if (deltaY < 0 && videoState == VIDEO_LAYOUT_NORMAL_SIZE) {
zoomInVideoLayout(VIDEO_LAYOUT_HALF_SIZE);
return true;
} else if (deltaY > 0 && videoState != VIDEO_LAYOUT_NORMAL_SIZE) {
zoomInVideoLayout(VIDEO_LAYOUT_NORMAL_SIZE);
return true;
}
}
if (isVideoStop()) {
if (deltaY < 0 && videoState != VIDEO_LAYOUT_ZERO_SIZE) {
zoomInVideoLayout(VIDEO_LAYOUT_ZERO_SIZE);
return true;
} else if (deltaY > 0 && isListViewTopping()) {
if (videoState == VIDEO_LAYOUT_ZERO_SIZE) {
zoomInVideoLayout(VIDEO_LAYOUT_NORMAL_SIZE);
return true;
}
}
}
break;
}
return super.onInterceptTouchEvent(ev);
}
三. 左拉刷新
从原理上来讲,这个控件其实和常见的下拉刷新控件是一样的。只是方向变为了向左滑动。
完全从零做起的,实现一个这个小控件也是挺有意思的。
主要思路是,在视觉区域以外的地方添加一个新View(indicate 刷新状态)
主要动作是对整个View做位移动画。
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
mTargetView.layout(l,t,r,b);//在此栗子中是图片
mRefreshView.layout(r,t,r + mRefreshView.getMeasuredWidth(),b);//左拉提示,旋转指示等
}
而动作判别又是我们熟悉的那一套代码啦
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
if(mHandler!=null && mHandler.shouldForbidden()){
return super.onInterceptTouchEvent(ev);
}
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
mXDown = ev.getRawX();
mYDown = ev.getRawY();
mXLastMove = mXDown;
break;
case MotionEvent.ACTION_MOVE:
mXMove = ev.getRawX();
mYMove = ev.getRawY();
float diffX = (mXMove - mXDown);
float diffY = (mYMove - mYDown);
if(Math.abs(diffX * 0.5) < Math.abs(diffY)){
break;
}
mXLastMove = mXMove;
// 当手指拖动值大于TouchSlop值时,认为应该进行滚动,拦截子控件的事件
//向左滑动
if (diffX < 0 && Math.abs(diffX) > mTouchSlop && !canTargetScrollLeft()) {
return true;
}else if(diffX > 0 && Math.abs(diffX) > mTouchSlop && isRefreshViewDisplayed()){
return true;
}
break;
}
return super.onInterceptTouchEvent(ev);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_MOVE:
mXMove = event.getRawX();
float diffX = 1.6f * (mXMove - mXLastMove);//正规坐标轴下的偏移
diffX = diffX * (1.2f - (getCurrentOffset()/getMaxOffset()));//阻尼修正
float target = checkOffsetX(getCurrentOffset()- diffX);
if(getMaxOffset() * mPercentFactor < target){
mRefreshView.setExplodeState(true);//爆炸特效 + 提示转换
}else{
mRefreshView.setExplodeState(false);
}
setOffset(target);
mXLastMove = mXMove;
break;
case MotionEvent.ACTION_UP:
// 当手指抬起时,根据当前的滚动值来判定应该滚动到哪个子控件的界面
//todo 进入详情页
if(mListener!=null && mRefreshView.isHasExploded()) {
mListener.onTriggered();
}
postDelayed(new Runnable() {
@Override
public void run() {
onRelease();
}
}, mRefreshView.isHasExploded() ? 500 :0);
break;
}
return super.onTouchEvent(event);
}
主要动作核心代码:
public void setOffset(float targetScrollX){
//标准坐标轴 右下为正
//进行左右平移时,需要保证平移的scrollX 范围是 0 - mRefreshView.width()
targetScrollX = checkOffsetX(targetScrollX);
float percent = (targetScrollX / getMaxOffset())/ mPercentFactor;
percent = Math.min(percent,1);
mRefreshView.updatePullPercent(percent);
scrollTo((int)targetScrollX,0);
}
private float checkOffsetX(float targetScrollX) {
if(targetScrollX > mRefreshView.getWidth() /*|| Math.abs(targetScrollX - getMaxOffset()) < 10*/){
targetScrollX = mRefreshView.getWidth();
}else if(targetScrollX < 0){
targetScrollX = 0;
}
return targetScrollX;
}
被刷新的View被抽象出来作为mRefreshView,相对比较简单,只要实现了
void updatePullPercent(float percent);
void setExploedState(boolean explored);
这里除了问题提示之外,还有一个
旋转的箭头以及渐变的绿色背景。
箭头是现成的UI图,绿色背景稍微麻烦一些,需要使用颜色渐变来完成。
下面的RotateArrowView 实现了这个功能,顺便将箭头也add了进来。
//只包括了这个类的核心代码
public class RotateArrowView extends FrameLayout {
private ArgbEvaluator argbEvaluator = new ArgbEvaluator();
...
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int x = getMeasuredWidth()/2;
int y = getMeasuredHeight()/2;
int radius = getWidth()/2;
canvas.drawCircle(x,y,radius,mPaint);
}
public void updatePercent(float percent){
int evaluateColor = (int)argbEvaluator.evaluate(percent, startColor, endColor);
mPaint.setColor(evaluateColor);
arrow.setRotation(180* percent);//箭头的角度需要旋转
postInvalidate();
}
}
ArgbEvaluator 是谷歌提供的一个方便的颜色渐变计算器。
之前对ViewGroup在直觉上有个误解,就是复写父view的onDraw要考虑和子View z-index上的层级关系。
实际上ViewGroup的onDraw复写之后,并不会影响到其子View(只是默默地在最后面画了一个背景)。
其实思考一下也是,父View以及子View的z-index层级关系是在layout时就已经确定好的。如果需要在onDraw再去费心考虑,对于api使用者而言是一个灾难。