仿炫酷头条小视频拖拽动画

1,451 阅读7分钟

追忆

习惯使然,喜欢在文章开始之前唠嗑几句,毕业3年了,距离高考已经7个年头过去了,让我想起了高三的时光,那是个阳光明媚的下午,阳光透过窗户,照在她脸上,映红了她的双颊,也照亮了我懵懂的内心,微笑着默默转过头,开心的折着玫瑰 . . .

这么多年过去,不知道她过得怎么样 . . .

仿头条小视频拖拽动画

图文并茂,今天需要实现的效果如下:

mei_video_drag

由于限制了图片大小,这只是效果的很小一部分,想感受完整的效果可以进入头条体验,或者下载 demo 体验更加丝滑流畅的动画效果。首先我想对大家说的是,模仿炫酷的控件效果我们要勇于尝试,我始终坚信一句话:别人能够实现的,为什么我不能呢?一定要动手去写写,化繁为简,再复杂的动画都是一个个简单的动画组合而来的,观察与分析也是写好一个控件的必备前提。接下来然我们一起去实现小视频拖拽的动画效果。

小视频拖拽动画解剖

首先我简单的描述下整个动画流程,点击小视频列表(转场动画)跳转到小视频详情页,拖动界面(平移缩放动画),释放(无缝的转场)跳转到小视频列表页。那么我把整体的动画划分为两部分,1、转场动画,2、拖拽动画。提到转场动画大家肯定会第一时间想到 Android5.0ActivityOptionsCompat ,我也尝试了下,最终效果并不理想,而且无法兼容到 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 也可以左右拖动,产生了滑动冲突。有关滑动冲突的解决方案可以参考以下文章:

自定义View系列教程08--滑动冲突的产生及其处理

最后通过了简单的规则处理了滑动冲突,x方向的偏移量不小于y方向的偏移量则不消费事件,反之则消费。

//第一步 解决与viewpager的左右冲突 若手指拖动的x轴偏移量大于等于y轴偏移量则不消费事件
if (Math.abs(mMoveDx) >= Math.abs(mMoveDy)) {
    return super.onTouchEvent(event);
}

子view消费事件

父控件默认消费了touch事件,至今并没有找到一个优雅的方式去处理子view的消费事件,采用的是在view设置tag来实现的,导致控件显得笨重了一些。实现原理,是通过遍历父控件的子控件,若当前触摸点在子控件区域内,并且子控件设置了 tagdispatch ,则父控件不拦截事件 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