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

本篇文章已授权微信公众号 guolin_blog (郭霖)独家发布

序言

时光荏苒,又是一年高考季,不知不觉中距离高考已经七年过去了,在我眼中依稀能够嗅到那个夏天的味道,朋友的道别,得知成绩后的苦涩,独自静听大海的翻涌。。。

岁月安好,在浮躁的年纪里,我撬动键盘记录回忆

大哥们,请放下刀,MD 我只想说,我老的真快。

正文

今天想跟大家分享的是头条小视频拖拽的动画效果,玩过头条的小伙伴肯定感受过。酷酷的效果:

《仿炫酷头条小视频拖拽动画》 demo.gif

由于完整效果图体积太大,只展示了一部分,想体验完整的效果请链接以下地址:

apk下载地址

深有体会,写好一个控件,重点在于观察分析,化繁为简,经常会进入死胡同,换一换思路,将柳暗花明。

大哥快放下你的刀,小弟不BB了。

观察分析头条动画

打开手机 -> 设置 -> 开发者选项 -> 选择窗口动画缩放 程序动画时长(5x) -> 打开头条进入小视频列表操作观察。大概可以分为以下3部分:

  1. 列表页进入到详情页的过渡动画
  2. 详情页拖动动画
  3. 释放后回到列表页的过渡动画

接下来逐一讲解

列表页进入到详情页的过渡动画

过渡动画的实现有两种方式,第一种是共享元素 ActivityOptionsCompat 使用简单,缺点是只兼容 5.0 以上;第二种方式手动实现过渡动画,可控性强,兼容 5.0 以下版本,实现比较复杂。最开始我是通过共享元素去实现的过渡动画,但效果差强人意,只好老老实实的手动去实现过渡动画。手动实现过渡动画感触可以让我引用美国一名大演说家的名言 “细节决定成败” ,那么让我们一起来看看有哪些细节需要处理。

通过分析,可以得出列表页 -> 详情页可以拆分为平移 + 缩放动画,列表页的封面图相对屏幕的位置开始缩放平移铺满屏幕。那么首先就需要考虑的是动画是在列表页实现,监听动画结束跳转到详情页;还是直接跳转到详情页,由详情页来实现动画。这两种方案都可以,我通过观察头条采用的是后一种方案,以下是观察的结果:

《仿炫酷头条小视频拖拽动画》 vdr_3.png

点击 item ,截图如下:

《仿炫酷头条小视频拖拽动画》 vdr_2.png

可以观察到执行动画的同时已经跳转到了详情页,并且我这里选择的是 动画程序时长缩放 ,也再一次证明了头条也是采用手动实现过渡动画的方式,不幸的是让我再一次发现了头条的一个小小 bug ,手指释放后执行过渡动画期间可以再次拖动。言归正传,最终确定在详情页实现过渡动画。

过渡动画流程简介:

点击列表页封面图 -> 启动详情页,在布局最外层铺一张同列表页相同的封面图(全屏),并缩放平移到列表封面图的相同大小相同位置 -> 封面图平移放大至全屏 -> 动画结束隐藏封面图并加载视频资源。(也可以把封面图当做视频的第一帧处理)有点狸猫换太子的感觉,真真假假,分不清楚。

过渡动画最终被拆分成:

  1. 平移动画
  2. 缩放动画

继续拆分

  1. x轴方向平移动画
  2. y轴方向平移动画
  3. x轴方向缩放动画
  4. y轴方向缩放动画

这里以x轴方向平移动画为例,我们需要x轴方向平移的 起始值结束值起始值 为列表封面图左顶点, 结束值 为屏幕原点。可以通过以下方法获取:

View.getGlobalVisibleRect(Rect r)

x轴方向平移动画:

float value = (float) animation.getAnimatedValue();
setTranslationX(r.x- value * r.x);

其他几种动画我这里就不在一 一累述,如有不明白的请查阅源码以及留言咨询。

接着看以下这种情况:

如果列表封面图的 item 未完全展示,有部分滑出屏幕顶部(这里称作上边界越界),同理 item 有部分未滑入屏幕底部(上边界越界),点击 item 会出现一种什么样的情况,以下是头条的截图:

《仿炫酷头条小视频拖拽动画》 vdr_4.png

明显的被压缩变形了,降低了用户体验度,那么怎么优化呢?

//`item` 的实际高度替换可见高度。
startScaleY = (float) item.getHeight()/ screenHeight; 

优化后的效果图如下:

《仿炫酷头条小视频拖拽动画》 vdr_6.png

头条很巧妙的避免了从详情页 -> 列表页引起的变形问题。采用的方案是,移动了列表 item 的位置使之完全可见。

最后的需要实现列表页无缝过渡到详情页,让用户感觉不到有界面的跳动,先去除系统自带的转场效果:

 overridePendingTransition(0, 0);

并把详情页 Activity 设置成透明,主题添加如下代码:

<item name="android:windowIsTranslucent">true</item>

然后设置列表页封面图以及详情页大图缩放类型为:

android:scaleType="centerCrop"

在最初的实现当中我忽略了一个很重要的环节,我使用的测试机分辨率为 1080*1920 ,并把列表页 item 的宽高比设置为 9 : 16 ,碰巧使详情页大图跟列表页封面图缩放比例一致从而实现了无缝的过渡。后来测试的小哥哥跑过来告诉我在他手机上过渡有明显的抖动,不符合预期。尼玛,怎么会?一跑,瞪大双眼,还真会,这 . . . 我的锅。

开启调试模式 . . . 咦 . . 过渡动画并没有问题 . . 屏幕比例 . . 图片缩放比例 . . 哈哈,我知道问题所在了,列表封面图的宽高比必须和详情页大图(全屏)的宽高比一致,不然会导致 centerCrop 的缩放比例不一致,引起的抖动问题。那么动态设置封面图图片宽高比:

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);

约束的布局方式 实战篇ConstraintLayout的崛起之路

第1部分的过渡动画到此差不多结束了

详情页拖动动画

观察头条详情页拖动动画具有以下特点:

  • x轴平移
  • 缩放,缩放中心点为屏幕底部中点
  • 释放动画,y轴偏移量大于临界值(屏幕高度的十分之一)释放手指执行过渡动画,否则执行恢复动画
  • 拖动监听,拖动过程中隐藏非视频控件
  • 消费事件,默认最外层父控件消费事件,如何把事件传递给子控件消费
  • 滑动冲突,左右滑动时与 viewpager 造成的滑动冲突

x轴平移

x 轴的平移动画是相对简单的,根据 x 轴的偏移量来设置平移距离:

setTranslationX(getTranslationX() + dx);

缩放

缩放动画是由 y 轴偏移量决定的,当 y 轴偏移量大于临界值(屏幕高度的二分之一)时,则执行 y 轴方向的平移动画:

//设置缩放中心点
setPivotX(getWidth() / 2F);
setPivotY(getHeight());

float scale = 1.0F - dy / getHeight();
//缩放动画 
setScaleX(scale);
setScaleY(scale);
//平移
if (scale < mStartOffsetRatioY) { // 0.5 
    setTranslationY(getTranslationY() + dy / 2);
}

释放动画

根据 y 轴偏移量大于临界值(屏幕高度的十分之一)分别执行回退的过渡动画和恢复动画

final boolean isEnd = ((dy / getHeight()) > 0.1);
if (isEnd) {
    //执行回退的过渡动画
    startEndAnimation();
} else {
    //执行恢复动画
    startRestorationAnimation();
}

恢复动画的代码如下:

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);
animation = ObjectAnimator.ofPropertyValuesHolder(this, propertyScaleX, propertyScaleY, propertyTranslationX, propertyTranslationY).setDuration(400);
//恢复动画开始
animation.start();

回退的过渡动画部分代码如下(请结合源码理解):

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));

由于拖拽时设置的缩放中心点为屏幕底部中点,所以 x 方向的平移需要减去 (getWidth() - mOriginViewVisibleWidth) / 2.0F) ,同理 y 方向的平移也需要减去 getHeight() - mOriginViewRealHeight

拖动监听

so easy 直接贴代码:

case MotionEvent.ACTION_MOVE:
 if (mListener != null) {
    mListener.onStartDrag();
 }

消费事件

头条详情页既可以拖动又可以响应 OnClickListener 事件,细细琢磨你会发现,最外层的父控件需要拦截并消费 touch 事件,拦截了事件传递,那么子控件又怎么响应 OnClickListener 事件。有以下的三种处理方案:

  1. 父控件不拦截事件,只消费事件。(如果子控件有拦截事件,那么就不会走父控件的 touch 事件,导致无拖动效果,适合子控件无拦截事件)
  2. 父控件根据特定标识判定是否拦截事件。(如 setTag 的方式)
  3. 在控件 dispatchTouchEvent 分发事件里判定是否有滑动的趋势来判定是否拦截事件

第一种方案直接被 pass 掉,第二种方案根据当前触摸的子 View 是否有特定的 tag 标记来决定是否拦截事件。那么怎么来获取触摸的 View 是否有 tag 标记呢?采用的是比较常规的处理方式,遍历整个父控件的子控件(树形结构),触摸点是否包含在子控件内同时获取 tag 信息,相关的代码如下:

    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);
                   if (isConsume) {
                       break;
                   }
               }
           }
       }
       return isConsume;
   }

view 是否包含触摸点:

    private boolean isTouchView(int touchX, int touchY, View view) {
       Rect rect = new Rect();
       view.getGlobalVisibleRect(rect);
       return rect.contains(touchX, touchY);
   }

父控件拦截事件代码如下:

    @Override
   public boolean onInterceptTouchEvent(MotionEvent ev) {
       int action = ev.getAction() & MotionEventCompat.ACTION_MASK;
       switch (action) {
           case MotionEvent.ACTION_DOWN:
               return !childInterceptEvent(this, (int) ev.getRawX(), (int) ev.getRawY());
       }
       return super.onInterceptTouchEvent(ev);
   }

那么只需要在布局文件中新增以下一行代码就可以响应 OnClickListener 事件:

android:tag="dispatch"

聪明如斯的你肯定会发现设置 tag 是一件多么操蛋的事,如果没有事先约定谁知道要设置 tag 以及 tag 的值,还有就是如果子控件是第三方控件,那么还得在代码中手动设定。这样就衍生出了第三方案,根据滑动的趋势来判定是否拦截事件,相关的代码如下:

    @Override
   public boolean dispatchTouchEvent(MotionEvent ev) {
       float y = ev.getRawY();
       float x = ev.getRawX();
       switch (ev.getAction() & ev.getActionMasked()) {
           case MotionEvent.ACTION_DOWN:
               mTouchLastX = x;
               mTouchLastY = y;
               break;
           case MotionEvent.ACTION_MOVE:
               float dy = y - mTouchLastY;
               float dx = x - mTouchLastX;
               //判定是否拦截事件
               mIsInterceptTouchEvent = Math.abs(dy) > mMinScaledTouchSlop | Math.abs(dx) > mMinScaledTouchSlop;
               break;
       }
       return super.dispatchTouchEvent(ev);
   }

最终采用的是 方案二 + 方案三,采用方案二是为了兼容旧版本。

滑动冲突

有关滑动冲突的解决方案可以查阅以下文章:

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

当前需要解决与 viewpager 左右滑动冲突,处理的策略是:y 轴的偏移量是否大于 x 轴偏移量来决定 touch 事件的消费。核心代码如下:

if (Math.abs(dx) >= Math.abs(dy)) {
//消费事件
}else{
//不消费事件
}

到这里拖拽就有一个大体的效果了,有一个小小的细节,头条拖拽到顶部有明显的卡顿,本文的方式并不会卡顿,丝滑如初。剩下第三部分回退的过渡动画。

释放后回到列表页的过渡动画

还是老架势,分析头条的效果:

  • 列表页 item 完全可见,回退的过渡动画将缩放平移至列表 item 相同大小,相同位置
  • 列表页 item 越界(上越界或下越界),回退的过渡动画将缩放平移至列表顶部,如果列表最后两条数据下越界则缩放平移至列表底部。

首先需要判定是否越界:

//+1精度误差
if ((mOriginViewVisibleHeight + 1) < mOriginViewRealHeight) {
    if ((mOriginViewY + mOriginViewRealHeight) > getHeight()) {
        //下边界越界
    } else {
        //上边界越界
    }
}

mOriginViewVisibleHeight 列表 item 可见高度,mOriginViewRealHeight 列表 item 的真实高度,mOriginViewY 列表 item 相对屏幕的 y 坐标,与之对应的 mOriginViewXx 坐标。

上边界越界,回退的过渡动画的起始 xy 坐标与结束的 xy 保持不变,即mOriginViewXmOriginViewY 不变,只有高度发生了变化,mOriginViewVisibleHeight 可见高度变为了 mOriginViewRealHeight 真实高度:

//上边界越界
mOriginViewVisibleHeight = mOriginViewRealHeight;

下边界越界,需要考虑是否最后两条 item 下越界,值为 false ,屏幕的相对 y 坐标变为 statusHeight + (int) mTopNavHeightmTopNavHeight 顶部导航栏的高度。反之屏幕的相对 y 坐标变为 getHeight() - (int) mBottomNavHeight - mOriginViewRealHeightmBottomNavHeight 底部导航栏的高度。同时mOriginViewVisibleHeight 可见高度变为 mOriginViewRealHeight 真实高度。

//是否最后一行数据
if (mIsLastRow) {
    mOriginViewY = getHeight() - (int) mBottomNavHeight - mOriginViewRealHeight;
} else {
    mOriginViewY = statusHeight + (int) mTopNavHeight;
}
mOriginViewVisibleHeight = mOriginViewRealHeight;

回退的过渡动画相关代码如下:

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();
...//省略相关属性动画初始化,以下是动画回调相关代码
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) - (topOutOfBound ? value * (mOriginViewRealHeight - mOriginViewVisibleHeight) : 0));

最后提二点,第一 item 是基于 RecyclerView 实现,重写 addItemDecoration 绘制分割线:

 mRecyclerView.addItemDecoration(new RecyclerView.ItemDecoration() {
     @Override
     public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
         super.getItemOffsets(outRect, view, parent, state);
         int pos = parent.getChildAdapterPosition(view);
        //相关分割线绘制
     }
 });

第二 RecyclerView 移动指定的位置 position 到列表顶部,RecyclerView 自带的几个 API 效果并不理想,最后通过以下方式实现:

private void moveToPosition(LinearLayoutManager layoutManager, int selectedPosition) {
    int firstVisiblePosition = layoutManager.findFirstVisibleItemPosition();
    int top = mRecyclerView.getChildAt(selectedPosition - firstVisiblePosition).getTop();
    mRecyclerView.scrollBy(0, top);
}

三部分的动画组成了头条小视频拖拽效果,整篇实现并没有什么难点,细节处理好就 OVER

完结

最近一段时间我发现自己越发浮躁,懒散,学习又落下了一大截,处于这个年龄段的烦恼,生活中的无奈 . . . 始终坚信今后会越来越好 . . .

求求大家别再学习了,等等我 . . .

源码地址

为自己点个赞。

    原文作者:文淑
    原文地址: https://www.jianshu.com/p/9c7503f4717e
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞