追忆
习惯使然,喜欢在文章开始之前唠嗑几句,毕业3年了,距离高考已经7个年头过去了,让我想起了高三的时光,那是个阳光明媚的下午,阳光透过窗户,照在她脸上,映红了她的双颊,也照亮了我懵懂的内心,微笑着默默转过头,开心的折着玫瑰 . . .
这么多年过去,不知道她过得怎么样 . . .
仿头条小视频拖拽动画
图文并茂,今天需要实现的效果如下:
mei_video_drag
由于限制了图片大小,这只是效果的很小一部分,想感受完整的效果可以进入头条体验,或者下载 demo 体验更加丝滑流畅的动画效果。首先我想对大家说的是,模仿炫酷的控件效果我们要勇于尝试,我始终坚信一句话:别人能够实现的,为什么我不能呢?一定要动手去写写,化繁为简,再复杂的动画都是一个个简单的动画组合而来的,观察与分析也是写好一个控件的必备前提。接下来然我们一起去实现小视频拖拽的动画效果。
小视频拖拽动画解剖
首先我简单的描述下整个动画流程,点击小视频列表(转场动画)跳转到小视频详情页,拖动界面(平移缩放动画),释放(无缝的转场)跳转到小视频列表页。那么我把整体的动画划分为两部分,1、转场动画,2、拖拽动画。提到转场动画大家肯定会第一时间想到 Android5.0 的 ActivityOptionsCompat ,我也尝试了下,最终效果并不理想,而且无法兼容到 Android5.0 以下,以及越界带来的图片变形等因素,最后手动实现了转场动画(更加灵活,扩展性更强),接下来具体分析。
1、转场动画
转场动画就是一个平移+缩放的效果,那么你有两种方案,是在列表页做呢,还是在详情页做呢?我这里选择的是后者,如果你感兴趣可以在列表页中去实现。为了实现无缝转场你还需要做到以下几点:
-
列表页图片位于屏幕的 x,y 轴坐标以及图片宽高
-
列表图片的宽高比必须与详情页的宽高比(这里是全屏)一致
-
详情页平移+缩放动画
获取坐标,宽高相对简单,可以通过如下函数获取:
Rect globalRect = new Rect();
view.getGlobalVisibleRect(globalRect);
保持宽高比一致,我这里 item 使用的是约束布局,通过 ConstraintSet 实现:
ConstraintSet constraintSet = new ConstraintSet();
constraintSet.clone((ConstraintLayout) helper.itemView);
constraintSet.setDimensionRatio(R.id.iv_bg, "H," + DensityUtil.getScreenSize(mContext).x + ":" + DensityUtil.getScreenSize(mContext).y);
constraintSet.applyTo((ConstraintLayout) helper.itemView);
注意我们在 startActivity 的同时加上 overridePendingTransition(0, 0); 去除默认的转场效果。
平移+缩放的代码如下:
//执行开始动画
private void startStartAnimation(final boolean isExit) {
if (mStartAnimation != null && mStartAnimation.isRunning()) {
return;
}
if (mEndAnimation != null && mEndAnimation.isRunning()) {
return;
}
//判定来源view宽高
if (mOriginViewVisibleHeight != 0 && mOriginViewVisibleWidth != 0 && mStartAnimationEnable) {
setPivotX(0);
setPivotY(0);
//DensityUtil.getStatusBarHeight((Activity) getContext());
final int statusHeight = 0;
//具体场景 可以替换成 getWidth() getHeight() 这里是以屏幕的宽高来计算的
boolean isTopOutOfBound = false;
int screenHeight = DensityUtil.getScreenSize(getContext()).y;
final float startScaleX = (float) mOriginViewVisibleWidth / DensityUtil.getScreenSize(getContext()).x;
final float startScaleY = (float) mOriginViewRealHeight / screenHeight;
if ((mOriginViewVisibleHeight + 1) < mOriginViewRealHeight) {
//上边界越界 或者 下边界越界
if ((mOriginViewY + mOriginViewRealHeight) > screenHeight) {
//下边界越界
isTopOutOfBound = false;
} else {
//上边界越界
isTopOutOfBound = true;
}
}
final boolean topOutOfBound = isTopOutOfBound;
mStartAnimation = ValueAnimator.ofFloat(isExit ? 1.0F : 0F, isExit ? 0F : 1.0F).setDuration(isExit ? mEndAnimDuration : mStartAnimDuration);
mStartAnimation.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float value = (float) animation.getAnimatedValue();
setTranslationX(mOriginViewX - value * mOriginViewX);
setTranslationY((mOriginViewY - statusHeight) - value * (mOriginViewY - statusHeight) - (topOutOfBound ? (1.0F - value) * (mOriginViewRealHeight -
mOriginViewVisibleHeight) : 0));
setScaleX(startScaleX + value * (1.0F - startScaleX));
setScaleY(startScaleY + value * (1.0F - startScaleY));
}
});
mStartAnimation.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
super.onAnimationEnd(animation);
mChildInterceptEventEnable = true;
mRunningAnimationEnable = false;
if (mListener != null) {
mListener.onCompleteAnimation(!isExit);
}
}
@Override
public void onAnimationStart(Animator animation) {
super.onAnimationStart(animation);
mRunningAnimationEnable = true;
mChildInterceptEventEnable = false;
}
});
mStartAnimation.start();
} else {
if (mListener != null) {
mListener.onCompleteAnimation(!isExit);
}
}
}
代码中有相应的注释,若有什么疑问?请结合源码理解。从详情页跳转到列表页就是一个逆向过程。
2、拖拽效果
拖拽效果也可以细分为以下三部分:
-
拖拽规则
-
滑动冲突
-
子view消费事件
拖拽规则
我们逐条分析,拖拽效果又可以细分为以下几个部分:
- x方向拖动只在x轴平移控件
setTranslationX(getTranslationX() + dx);
- y方向拖动是缩放,且达到某个临界值y方向平移控件
setPivotX(getWidth() / 2F);
setPivotY(getHeight());
//设置缩放
float scale = 1.0F - mMoveDy / getHeight();
setScaleX(scale);
setScaleY(scale);
//缩放小于0.5时并平移y
if (scale < mStartOffsetRatioY) {
setTranslationY(getTranslationY() + dy / mOffsetRateY);
}
首先设置缩放中心点为屏幕底部中点,y方向的偏移量来控制缩放比例,并且缩放比例达到一定值时y方向平移控件。
只需要一行代码来实现控防止件滑出屏幕顶部:
mMoveDy = mMoveDy <= 0 ? 0 : mMoveDy;
头条往顶部滑动,会发现界面卡顿,本控件依旧丝滑如初。
- y方向的偏移量达到一定值释放执行退出动画否则执行恢复动画
//判定是否执行恢复动画还是结束动画
final boolean isEnd = ((mMoveDy / getHeight()) > mRestorationRatio);
mRestorationRatio 默认值是 0.1 ,可以通过 xml ,代码动态设置。
恢复动画的代码如下:
//执行恢复动画
private void startRestorationAnimation() {
if (mRestorationAnimation != null && mRestorationAnimation.isRunning()) {
return;
}
PropertyValuesHolder propertyScaleX = PropertyValuesHolder.ofFloat("scaleX", getScaleX(), 1.0F);
PropertyValuesHolder propertyScaleY = PropertyValuesHolder.ofFloat("scaleY", getScaleY(), 1.0F);
PropertyValuesHolder propertyTranslationX = PropertyValuesHolder.ofFloat("translationX", getTranslationX(), 0);
PropertyValuesHolder propertyTranslationY = PropertyValuesHolder.ofFloat("translationY", getTranslationY(), 0);
mRestorationAnimation = ObjectAnimator.ofPropertyValuesHolder(this, propertyScaleX, propertyScaleY, propertyTranslationX, propertyTranslationY)
.setDuration(mStartAnimDuration);
mRestorationAnimation.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
super.onAnimationEnd(animation);
mChildInterceptEventEnable = true;
mRunningAnimationEnable = false;
mDraggingEnable = true;
if (mListener != null) {
mListener.onCompleteAnimation(true);
}
}
@Override
public void onAnimationStart(Animator animation) {
super.onAnimationStart(animation);
mChildInterceptEventEnable = false;
mRunningAnimationEnable = true;
}
});
mRestorationAnimation.start();
}
退出动画代码如下:
//执行结束动画
private void startEndAnimation() {
if (mEndAnimation != null && mEndAnimation.isRunning()) {
return;
}
//判定来源view宽高
if (mOriginViewVisibleHeight != 0 && mOriginViewVisibleWidth != 0) {
final float startTransitionX = getTranslationX();
final float startTransitionY = getTranslationY();
final float startScaleX = getScaleX();
final float startScaleY = getScaleY();
final float endScaleX = (float) mOriginViewVisibleWidth / getWidth();
final float endScaleY = (float) mOriginViewRealHeight / getHeight();
//状态栏高度 若状态栏隐藏则设置此值 DensityUtil.getStatusBarHeight((Activity) getContext());
final int statusHeight = 0;
//是否越界
boolean isTopOutOfBound = false;
//加1是防止精度误差
if ((mOriginViewVisibleHeight + 1) < mOriginViewRealHeight) {
if ((mOriginViewY + mOriginViewRealHeight) > getHeight()) {
//下边界越界
isTopOutOfBound = false;
} else {
//上边界越界
isTopOutOfBound = true;
}
}
final boolean topOutOfBound = isTopOutOfBound;
mEndAnimation = ValueAnimator.ofFloat(0F, 1.0F).setDuration(mEndAnimDuration);
mEndAnimation.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float value = (float) animation.getAnimatedValue();
setScaleX(startScaleX + value * (endScaleX - startScaleX));
setScaleY(startScaleY + value * (endScaleY - startScaleY));
setTranslationX(startTransitionX + value * (mOriginViewX - startTransitionX) - value * (getWidth() - mOriginViewVisibleWidth) / 2.0F);
setTranslationY(startTransitionY - value * (startTransitionY - mOriginViewY) - value * (getHeight() - mOriginViewRealHeight + statusHeight) - (topOutOfBound ?
value * (mOriginViewRealHeight - mOriginViewVisibleHeight) : 0));
}
});
mEndAnimation.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
super.onAnimationEnd(animation);
mChildInterceptEventEnable = true;
mRunningAnimationEnable = false;
if (mListener != null) {
mListener.onCompleteAnimation(false);
}
}
@Override
public void onAnimationStart(Animator animation) {
super.onAnimationStart(animation);
mRunningAnimationEnable = true;
mChildInterceptEventEnable = false;
}
});
mEndAnimation.start();
} else {
if (mListener != null) {
mListener.onCompleteAnimation(false);
}
}
}
这里只有一点需要注意的,拖拽控件设置了缩放中心点为屏幕底部中点,那么x,y方向的平移应该减去相应的值,x方向 (getWidth() - mOriginViewVisibleWidth) / 2.0F,y方向 getHeight() - mOriginViewRealHeight
滑动冲突
由于控件可以左右拖动, viewpager 也可以左右拖动,产生了滑动冲突。有关滑动冲突的解决方案可以参考以下文章:
最后通过了简单的规则处理了滑动冲突,x方向的偏移量不小于y方向的偏移量则不消费事件,反之则消费。
//第一步 解决与viewpager的左右冲突 若手指拖动的x轴偏移量大于等于y轴偏移量则不消费事件
if (Math.abs(mMoveDx) >= Math.abs(mMoveDy)) {
return super.onTouchEvent(event);
}
子view消费事件
父控件默认消费了touch事件,至今并没有找到一个优雅的方式去处理子view的消费事件,采用的是在view设置tag来实现的,导致控件显得笨重了一些。实现原理,是通过遍历父控件的子控件,若当前触摸点在子控件区域内,并且子控件设置了 tag 为 dispatch ,则父控件不拦截事件 onInterceptTouchEvent 返回 false ,相关代码如下:
private boolean childInterceptEvent(ViewGroup parentView, int touchX, int touchY) {
boolean isConsume = false;
for (int i = parentView.getChildCount() - 1; i >= 0; i--) {
View childView = parentView.getChildAt(i);
if (!childView.isShown()) {
continue;
}
boolean isTouchView = isTouchView(touchX, touchY, childView);
if (isTouchView && childView.getTag() != null && TAG_DISPATCH.equals(childView.getTag().toString())) {
isConsume = true;
break;
}
if (childView instanceof ViewGroup) {
ViewGroup itemView = (ViewGroup) childView;
if (!isTouchView) {
continue;
} else {
isConsume |= childInterceptEvent(itemView, touchX, touchY);
break;
}
}
}
return isConsume;
}
由于 xml 是树形结构,所有这里采用了递归遍历。
总结
一句话,勇于尝试,善于分析,最后才是创新。
强力推荐 Mei控件集 , 若有你喜欢的控件,请别忘 star