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

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

youtube-like-drag-video-view

代码已经开源到GitHub

https://github.com/Lyzon/youtube-like-drag-video-view

可以给个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,再次感谢。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容