Android 仿 YouTube 拖拽视频效果的实现

4,966 阅读8分钟
原文链接: www.jianshu.com

Android仿YouTube拖拽视频效果的实现

youtube-like-drag-video-view

代码已经开源到GitHub

github.com/Lyzon/youtu…

可以给个star支持一下我!谢谢!

实现的效果图


demo.gif

实现思路

在YouTube APP看到这个效果的时候,就觉得挺有意思的,然后就想着去实现这个效果。想了好久,想到了以下实现方案:

  • 首先播放视频的View我选择了TextureView,关于TextureView可以参考一下这篇文章: TextureView简易教程
  • 自定义我们的YouTubeVideoView继承一个LinearLayout,里面包裹着TextureView与下方的详情页面。
  • 根据手指在屏幕上的滑动距离计算并改变TextureView当前的宽、高。
  • TextureView的滑动效果我选择通过LayoutParams动态地设置marginTop属性达到上下滑动的效果。
  • 在最小化的时候,先判断用户意图,如果是横向滑动的话,改marginRight/Left属性来实现横向的滑动,滑动到一定距离则隐藏整个View。
  • 手指抬起后剩下的滑动效果使用属性动画来实现。
  • 剩下的一些细节比如说透明度的改变,最小化时的悬浮效果,以及距离屏幕边界的距离等等,也是根据手指的滑动距离得到的。
  • 所有的滑动事件的处理都在给TextureView设置的OnTouchListener里完成。

其他的实现思路

  • TextureView的拖动效果也可以使用Android中一个帮助拖动的类ViewDragHelper来完成,在ViewDragHelper的回调中实现与其他View的联动。
  • 可以试试CoordinatorLayout,协调与联动。~

Let's Code!

这里我就不把代码全部贴上了,主要讲一下我这个实现思路中需要注意的一些点。先看一下我们要使用到的全局变量吧:

// 可拖动的videoView 和下方的详情View
private View mVideoView;
private View mDetailView;
// video类的包装类,用于属性动画
private VideoViewWrapper mVideoWrapper;

//滑动区间,取值为是videoView最小化时距离屏幕顶端的高度
private float allScrollY;

//1f为初始状态,0.5f或0.25f(横屏时)为最小状态
private float nowStateScale;
//最小的缩放比例
private float MIN_RATIO = 0.5f;
private static final float VIDEO_RATIO = 16f / 9f;

//是否是第一次Measure,用于获取播放器初始宽高
private boolean isFirstMeasure = true;

//VideoView初始宽高
private int originalWidth;
private int originalHeight;

//最小时距离屏幕右边以及下边的 DP值 初始化时会转化为PX
private static final int MARGIN_DP = 12;
private int marginPx;

//是否可以横滑删除
private boolean canHide;

接下来重写onFinishInflate()方法,获取到两个子View,一个播放视频,一个展示详情。

@Override
protected void onFinishInflate() {
    super.onFinishInflate();
    if (getChildCount() != 2)
        throw new RuntimeException("YouTubeVideoView only need 2 child views");

    mVideoView = getChildAt(0);
    mDetailView = getChildAt(1);

    init();
}

再看一下init()方法,主要做一下初始化:

private void init() {
    //设置触摸监听器
    mVideoView.setOnTouchListener(new VideoTouchListener());
    //初始化包装类
    mVideoWrapper = new VideoViewWrapper();
    //DP To PX
    marginPx = MARGIN_DP * (getContext().getResources().getDisplayMetrics().densityDpi / 160);

    //当前缩放比例
    nowStateScale = 1f;

    //如果是横屏则最小化比例为0.25f
    if (mVideoView.getContext().getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE)
        MIN_RATIO = 0.25f;

    originalWidth = mVideoView.getContext().getResources().getDisplayMetrics().widthPixels;
    originalHeight = (int) (originalWidth / VIDEO_RATIO);

    ViewGroup.LayoutParams lp = mVideoView.getLayoutParams();
    lp.width = originalWidth;
    lp.height = originalHeight;
    mVideoView.setLayoutParams(lp);
}
  • 首先我们先为播放视频的View注册一个监听器,接下来在这个监听器里处理触摸事件。
  • 然后我们初始化了一个包装类,这个包装类用于属性动画,稍后分析。
  • px(像素) = dp * (dpi / 160)。
  • 原始宽度 = 屏幕宽度,高度由比例16 : 9算出。
  • 然后把初始宽高通过LayoutParams设置给播放视频的View。
  • 包装类:

      private class VideoViewWrapper {
      private LinearLayout.LayoutParams params;
      private LinearLayout.LayoutParams detailParams;
    
      VideoViewWrapper() {
          params = (LinearLayout.LayoutParams) mVideoView.getLayoutParams();
          detailParams = (LinearLayout.LayoutParams) mDetailView.getLayoutParams();
          params.gravity = Gravity.END;
      }
    
      int getWidth() {
          return params.width < 0 ? originalWidth : params.width;
      }
    
      int getHeight() {
          return params.height < 0 ? originalHeight : params.height;
      }
    
      void setWidth(float width) {
          if (width == originalWidth) {
              params.width = -1;
              params.setMargins(0, 0, 0, 0);
          } else
              params.width = (int) width;
    
          mVideoView.setLayoutParams(params);
      }
    
      void setHeight(float height) {
          params.height = (int) height;
          mVideoView.setLayoutParams(params);
      }

    分析一下这个包装类的作用,我们知道,要改变一个View的宽高,你可以在View的onMeasure或者onLayout中做文章,也可以通过给View设置LayoutParams来改变宽高。然后我们在使用属性动画的时候,要改变某个对象的某个属性的值,那么这个属性要有相对应的set/get方法,然而View里并没有setWidth/getWidth方法,有些实现类有setWidth/getWidth方法,可是改变的并不是控件的宽高。这个时候,使用包装类可以完美的解决这个问题,我们通过这个类为播放视频的View间接地提供了get/set宽高的方法,方法内的实现是为View设置LayoutParams。这样使用不仅可读性高,而且很安全,拓展性也高。

接下来重写onMeasure()方法,如果是第一次onMeasure的话,初始化竖直方向的滑动区间,也就是视频View从最大到最小整个过程中手指需要滑动的竖直方向上的距离,也就是最小化时视频View的MarginTop的值,通过this.getMeasuredHeight()获取我们整个View的测量高度,不用屏幕高度的原因是因为虚拟按键的影响。

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    if (isFirstMeasure) {
        //滑动区间,取值为是videoView最小化时距离屏幕顶端的高度 也就是最小化时的marginTop
        allScrollY = this.getMeasuredHeight() - MIN_RATIO * originalHeight - marginPx;
        isFirstMeasure = false;
    }
}

接下来就到给视频View设置的OnTouchListener里了,这个类里的代码有点多,在ACTION_DOWN里,做一下初始化,在ACTION_UP和CANCEL里,确定View的状态,最大化或最小化。这里主要看一下ACTION_MOVE里的代码:

case MotionEvent.ACTION_MOVE:
    tracker.addMovement(ev);
    dy = y - mLastY; //和上一次滑动的差值
    int dx = x - mLastX;
    int newMarY = mVideoWrapper.getMargin() + dy; //新的marginTop值
    int newMarX = mVideoWrapper.getMarginRight() - dx;//新的marginRight值
    int dDownY = y - mDownY;
    int dDownX = x - mDownX; // 从点击点开始产生的的差值

    //如果滑动达到一定距离
    if (Math.abs(dDownX) > touchSlop || Math.abs(dDownY) > touchSlop) {
        isClick = false;
        if (Math.abs(dDownX) > Math.abs(dDownY) && canHide) {//如果X>Y 且能滑动关闭
            mVideoWrapper.setMarginRight(newMarX);
            } else
              updateVideoView(newMarY); //否则通过新的marginTop的值更新大小
            }
      break;
  • touchSlop是一个int值,跟随不同的分辨率有变化,一般滑动差值大于这个值,才能认为用户在进行滑动操作。使用 ViewConfiguration.get(getContext()).getScaledTouchSlop() 获取到。
  • 横滑隐藏的时候动态地设置marginRight属性就可以了。
  • 竖直滑动改变大小的时候,调用的 updateVideoView(int marginTop)方法我们看一下:

      private void updateVideoView(int m) {
      //如果当前状态是最小化,先把我们的的布局宽高设置为MATCH_PARENT
      if (nowStateScale == MIN_RATIO) {
          ViewGroup.LayoutParams params = getLayoutParams();
          params.width = -1;
          params.height = -1;
          setLayoutParams(params);
      }
    
      canHide = false;
    
      //marginTop的值最大为allScrollY,最小为0
      if (m > allScrollY)
          m = (int) allScrollY;
      if (m < 0)
          m = 0;
    
      //视频View高度的百分比100% - 0%
      float marginPercent = (allScrollY - m) / allScrollY;
      //视频View对应的大小的百分比 100% - 50%或25%
      float videoPercent = MIN_RATIO + (1f - MIN_RATIO) * marginPercent;
    
      //设置宽高
      mVideoWrapper.setWidth(originalWidth * videoPercent);
      mVideoWrapper.setHeight(originalHeight * videoPercent);
    
      mDetailView.setAlpha(marginPercent);//设置下方详情View的透明度
      this.getBackground().setAlpha((int) (marginPercent * 255));
    
      int mr = (int) ((1f - marginPercent) * marginPx); //VideoView右边和详情View 上方的margin
      mVideoWrapper.setZ(mr / 2);//这个是Z轴的值,悬浮效果
    
      mVideoWrapper.setMarginTop(m);
      mVideoWrapper.setMarginRight(mr);
      mVideoWrapper.setDetailMargin(mr);

    }

顺着注释看,主要就是通过marginTop值算出百分比,通过百分比得到当前宽高,并通过包装类设置给视频View。

看一下UP里的处理吧:

                case MotionEvent.ACTION_UP:

                if (isClick) {
                    if (nowStateScale == 1f && mCallback !=null) {
                            //单击事件回调
                            mCallback.onVideoClick();
                    } else {
                        goMax();
                    }
                    break;
                }

                tracker.computeCurrentVelocity(100);
                float yVelocity = Math.abs(tracker.getYVelocity());
                tracker.clear();
                tracker.recycle();

                if (canHide) {
                    //速度大于一定值或者滑动的距离超过了最小化时的宽度,则进行隐藏,否则保持最小状态。
                    if (yVelocity > touchSlop || Math.abs(mVideoWrapper.getMarginRight()) > MIN_RATIO * originalWidth)
                        dismissView();
                    else
                        goMin();
                } else
                    confirmState(yVelocity, dy);//确定状态。
                break;

首先,如果在MOVE里移动的距离小于touchSlop的话,UP里isClick就为真,这个时候就进行单击事件的处理,并且break,如果不是单击事件,就可以根据移动的速度或者移动的距离来确定状态,看一下用于手指抬起后确定状态的函数:

private void confirmState(float v, int dy) { //dy用于判断是否反方向滑动了

    //如果手指抬起时宽度达到一定值 或者 速度达到一定值 则改变状态
    if (nowStateScale == 1f) {
        if (mVideoView.getWidth() <= originalWidth * 0.75f || (v > 15 && dy > 0)) {
            goMin();
        } else
            goMax();
    } else {
        if (mVideoView.getWidth() >= originalWidth * 0.75f || (v > 15 && dy < 0)) {
            goMax();
        } else
            goMin();
    }
}

非常简单。
最后看一下goMax()函数:

public void goMax() {

    AnimatorSet set = new AnimatorSet();
    set.playTogether(
            ObjectAnimator.ofFloat(mVideoWrapper, "width", mVideoWrapper.getWidth(), originalWidth),
            ObjectAnimator.ofFloat(mVideoWrapper, "height", mVideoWrapper.getHeight(), originalHeight),
            ObjectAnimator.ofInt(mVideoWrapper, "marginTop", mVideoWrapper.getMarginTop(), 0),
            ObjectAnimator.ofInt(mVideoWrapper, "marginRight", mVideoWrapper.getMarginRight(), 0),
            ObjectAnimator.ofInt(mVideoWrapper, "detailMargin", mVideoWrapper.getDetailMargin(), 0),
            ObjectAnimator.ofFloat(mVideoWrapper, "z", mVideoWrapper.getZ(), 0),
            ObjectAnimator.ofFloat(mDetailView, "alpha", mDetailView.getAlpha(), 1f),
            ObjectAnimator.ofInt(this.getBackground(), "alpha", this.getBackground().getAlpha(), 255)
    );
    set.setDuration(200).start();
    nowStateScale = 1.0f;
    canHide = false;
}

使用属性动画把所有要更改的对象的所有值都设置为最大化时候的状态就可以了。goMin()方法差不多,反着设置属性就是了。

最后在MainActivity中做一些常规工作,播放一下视频就可以了!

总结一下

这个自定义ViewGroup的的代码还有许多可以优化的地方,可是本人水平有限,做得不够好。另外,这个效果不能封装成库来使用,因为局限性还是比较多的。写这个效果,从一开始的完全没有思路,到后来一步步慢慢地实现出来。其实是非常有成就感的一件事情。这次是我第一次写博客,有不好的地方请批评指正。非常感谢,代码已经开源到github,希望能够给个star,再次感谢。