开源:YinyuetaiPlayer - 高仿音悦台播放页面效果

HannahMoll 8年前
   <p>新版的音悦台 APP 播放页面交互非常有意思,可以把播放器往下拖动,这个页面透明渐变,然后到底部可以左右拖动关闭播放器,然后点击视频列表有个页面弹出来的效果,十分炫酷,于是我自己动手实现了这个交互炫酷的播放器页面。</p>    <h2>1.废话不多说,直接演示实现效果</h2>    <p>1.1.点击某个视频,然后手指上下拖动,播放器做尺寸比例的渐变,视频相关信息做透明度渐变</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/bc4b2db02b0a5911dee59d11a46062de.gif"></p>    <p style="text-align:center">这里写图片描述</p>    <p>1.2.播放器只有在底部的时候,才能左右拖动,此时播放器做透明度渐变,拖动一定范围可以关闭播放器;然后它只有在原始位置的一小段距离内可以往上拖动</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/b753705b7093efc2a83d80b82149c01f.gif"></p>    <p style="text-align:center">这里写图片描述</p>    <p>1.3.点击视频列表的时候,若是上次视频是左右拖动关闭的话,会有个弹起播放页面的效果;若是返回键和返回箭头则无效果</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/adfccaf11ccf1b3aea2387166c4df6dd.gif"></p>    <p style="text-align:center">这里写图片描述</p>    <h2>2.实现的思路讲解</h2>    <ul>     <li>毫无疑问,需要自定义一个容器,然后处理它的触摸事件,对它的子 View 进行不同的处理。触摸事件的处理使用 ViewDragHelper 是再适合不过了,然后你需要实现容器 onMeasure 和 onLayout,由于使用了 ViewDragHelper,有些坑在代码解析的时候就会讲解。</li>     <li>播放页面是用新的 Activity 还仅仅是当前 Activity 的View的问题,由于播放器缩小到底部的时候,用户是可以滑动视频列表的,所以我个人认为就是在当前 Activity 放置一个自定义容器即可,因此为了效率考虑你可以用 ViewStub 来懒加载处理,这里方便演示我就直接 View 的形式了。</li>    </ul>    <h2>3.代码解析</h2>    <p>3.1.需要的变量</p>    <pre>  <code class="language-java">/**   * Created by Oubowu on 201612/26 13:58.<p>   * 实现了布局交互的容器   */  public class YytLayout extends ViewGroup {        private static final int MIN_FLING_VELOCITY = 400;      private ViewDragHelper mDragHelper;        // 拖动的宽度      private int mDragWidth;      // 拖动的高度      private int mDragHeight;        // 响应拖动做缩放的View      private View mFlexView;      // 与mFlexView联动做透明度渐变的View      private View mFollowView;        // 响应拖动做缩放的View保存的位置      private ChildLayoutPosition mFlexLayoutPosition;      // 与mFlexView联动的View保存的位置      private ChildLayoutPosition mFollowLayoutPosition;        // 水平拖动与否的标志位      private boolean mHorizontalDragEnable;        public boolean isHorizontalDragEnable() {          return mHorizontalDragEnable;      }        // 垂直拖动与否的标志位      private boolean mVerticalDragEnable = true;        // 是否正在关闭页面的标志位      private boolean mIsClosing;        // 监听布局是否水平拖动关闭了      private OnLayoutStateListener mOnLayoutStateListener;        // 做拖放缩放的子View的宽度      private int mFlexWidth;      // 做拖放缩放的子View的高度      private int mFlexHeight;        // mFlexView缩放的比率      private float mFlexScaleRatio = 1;        // mFlexView缩放的基准点的偏移值      private int mFlexScaleOffset;        // 触摸事件是否发生在mFlexView的区域      private boolean mInFlexViewTouchRange;</code></pre>    <p>3.2.初始化做 ViewDragHelper 的初始化,然后 post 拿到两个子 View,这里强制规定只能有两个子元素</p>    <pre>  <code class="language-java">public YytLayout(Context context, AttributeSet attrs) {          super(context, attrs);          init(context, attrs);      }        private void init(Context context, AttributeSet attrs) {            final float density = getResources().getDisplayMetrics().density;          final float minVel = MIN_FLING_VELOCITY * density;            ViewGroupCompat.setMotionEventSplittingEnabled(this, false);          FlexCallback flexCallback = new FlexCallback();          mDragHelper = ViewDragHelper.create(this, 1.0f, flexCallback);          // 最小拖动速度          mDragHelper.setMinVelocity(minVel);            post(new Runnable() {              @Override              public void run() {                    // 需要添加的两个子View,其中mFlexView作为拖动的响应View,mLinkView作为跟随View                  mFlexView = getChildAt(0);                  mFollowView = getChildAt(1);                    mDragHeight = getMeasuredHeight() - mFlexView.getMeasuredHeight();                    mFlexWidth = mFlexView.getMeasuredWidth();                  mFlexHeight = mFlexView.getMeasuredHeight();                }          });        }</code></pre>    <p>3.3. ViewDragHelper 的回调需要做的事情比较多,在 mFlexView 拖动的时候需要同时设置 mFlexView 和 mFollowView 的相应变化效果,在 mFlexView 释放的时候需要处理关闭或收起等效果</p>    <pre>  <code class="language-java">private class FlexCallback extends ViewDragHelper.Callback {            @Override          public boolean tryCaptureView(View child, int pointerId) {              // mFlexView来响应触摸事件              return mFlexView == child;          }            @Override          public int clampViewPositionHorizontal(View child, int left, int dx) {              return Math.max(Math.min(mDragWidth, left), -mDragWidth);          }            @Override          public int getViewHorizontalDragRange(View child) {              return mDragWidth * 2;          }            @Override          public int clampViewPositionVertical(View child, int top, int dy) {              if (!mVerticalDragEnable) {                  // 不允许垂直拖动的时候是mFlexView在底部水平拖动一定距离时设置的,返回mDragHeight就不能再垂直做拖动了                  return mDragHeight;              }              return Math.max(Math.min(mDragHeight, top), 0);          }            @Override          public int getViewVerticalDragRange(View child) {              return mDragHeight;          }            @Override          public void onViewReleased(View releasedChild, float xvel, float yvel) {                if (mHorizontalDragEnable) {                  // 如果水平拖动有效,首先根据拖动的速度决定关闭页面,方向根据速度正负决定                  if (xvel > 1500) {                      mDragHelper.settleCapturedViewAt(mDragWidth, mDragHeight);                      mIsClosing = true;                  } else if (xvel < -1500) {                      mDragHelper.settleCapturedViewAt(-mDragWidth, mDragHeight);                      mIsClosing = true;                  } else {                      // 速度没到关闭页面的要求,根据透明度来决定关闭页面,方向根据releasedChild.getLeft()正负决定                      float alpha = releasedChild.getAlpha();                      if (releasedChild.getLeft() < 0 && alpha <= 0.4f) {                          mDragHelper.settleCapturedViewAt(-mDragWidth, mDragHeight);                          mIsClosing = true;                      } else if (releasedChild.getLeft() > 0 && alpha <= 0.4f) {                          mDragHelper.settleCapturedViewAt(mDragWidth, mDragHeight);                          mIsClosing = true;                      } else {                          mDragHelper.settleCapturedViewAt(0, mDragHeight);                      }                  }              } else {                  // 根据垂直方向的速度正负决定布局的展示方式                  if (yvel > 1500) {                      mDragHelper.settleCapturedViewAt(0, mDragHeight);                  } else if (yvel < -1500) {                      mDragHelper.settleCapturedViewAt(0, 0);                  } else {                      // 根据releasedChild.getTop()决定布局的展示方式                      if (releasedChild.getTop() <= mDragHeight / 2) {                          mDragHelper.settleCapturedViewAt(0, 0);                      } else {                          mDragHelper.settleCapturedViewAt(0, mDragHeight);                      }                  }              }              invalidate();          }            @Override          public void onViewPositionChanged(final View changedView, int left, int top, int dx, int dy) {                float fraction = top * 1.0f / mDragHeight;                // mFlexView缩放的比率              mFlexScaleRatio = 1 - 0.5f * fraction;              mFlexScaleOffset = changedView.getWidth() / 20;              // 设置缩放基点              changedView.setPivotX(changedView.getWidth() - mFlexScaleOffset);              changedView.setPivotY(changedView.getHeight() - mFlexScaleOffset);              // 设置比例              changedView.setScaleX(mFlexScaleRatio);              changedView.setScaleY(mFlexScaleRatio);                // mFollowView透明度的比率              float alphaRatio = 1 - fraction;              // 设置透明度              mFollowView.setAlpha(alphaRatio);              // 根据垂直方向的dy设置top,产生跟随mFlexView的效果              mFollowView.setTop(mFollowView.getTop() + dy);                // 到底部的时候,changedView的top刚好等于mDragHeight,以此作为水平拖动的基准              mHorizontalDragEnable = top == mDragHeight;                if (mHorizontalDragEnable) {                  // 如果水平拖动允许的话,由于设置缩放不会影响mFlexView的宽高(比如getWidth),所以水平拖动距离为mFlexView宽度一半                  mDragWidth = (int) (changedView.getMeasuredWidth() * 0.5f);                    // 设置mFlexView的透明度,这里向左右水平拖动透明度都随之变化                  changedView.setAlpha(1 - Math.abs(left) * 1.0f / mDragWidth);                    // 水平拖动一定距离的话,垂直拖动将被禁止                  mVerticalDragEnable = left < 0 && left >= -mDragWidth * 0.05;                } else {                  // 不是水平拖动的处理                  changedView.setAlpha(1);                  mDragWidth = 0;                    mVerticalDragEnable = true;                }                if (mFlexLayoutPosition == null) {                  // 创建子元素位置缓存                  mFlexLayoutPosition = new ChildLayoutPosition();                  mFollowLayoutPosition = new ChildLayoutPosition();              }                // 记录子元素的位置              mFlexLayoutPosition.setPosition(mFlexView.getLeft(), mFlexView.getRight(), mFlexView.getTop(), mFlexView.getBottom());              mFollowLayoutPosition.setPosition(mFollowView.getLeft(), mFollowView.getRight(), mFollowView.getTop(), mFollowView.getBottom());                //            Log.e("FlexCallback", "225行-onViewPositionChanged(): 【" + mFlexView.getLeft() + ":" + mFlexView.getRight() + ":" + mFlexView.getTop() + ":" + mFlexView              //                    .getBottom() + "】 【" + mFollowView.getLeft() + ":" + mFollowView.getRight() + ":" + mFollowView.getTop() + ":" + mFollowView.getBottom() + "】");            }        }</code></pre>    <p>3.4.接下来是处理测量和定位,我们实现的排列效果类似 LinearLayout 垂直排列的效果,这里被 measureChildWithMargins 的 heightUse 摆了一道;onLayout 的时候在位置缓存不为空的时候直接定位是因为 ViewDragHelper 在处理触摸事件子元素在做一些平移之类的,若是有元素更新了 UI 会导致重新 Layout,例如我的播放器在更新时间的 TextView 时就会如此,因此在 FlexCallback 的 onViewPositionChanged 方法记录位置,在重新 Layout 时恢复位置即可,这个也坑了好久</p>    <pre>  <code class="language-java">@Override      protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {            int desireHeight = 0;          int desireWidth = 0;            int tmpHeight = 0;            if (getChildCount() != 2) {              throw new IllegalArgumentException("只允许容器添加两个子View!");          }            if (getChildCount() > 0) {              for (int i = 0; i < getChildCount(); i++) {                  final View child = getChildAt(i);                  // 测量子元素并考虑外边距                  // 参数heightUse:父容器竖直已经被占用的空间,比如被父容器的其他子 view 所占用的空间;这里我们需要的是子View垂直排列,所以需要设置这个值                  measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, tmpHeight);                  // 获取子元素的布局参数                  final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();                  // 计算子元素宽度,取子控件最大宽度                  desireWidth = Math.max(desireWidth, child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin);                  // 计算子元素高度                  tmpHeight = child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;                  desireHeight += tmpHeight;              }              // 考虑父容器内边距              desireWidth += getPaddingLeft() + getPaddingRight();              desireHeight += getPaddingTop() + getPaddingBottom();              // 尝试比较建议最小值和期望值的大小并取大值              desireWidth = Math.max(desireWidth, getSuggestedMinimumWidth());              desireHeight = Math.max(desireHeight, getSuggestedMinimumHeight());          }          // 设置最终测量值          setMeasuredDimension(resolveSize(desireWidth, widthMeasureSpec), resolveSize(desireHeight, heightMeasureSpec));      }        @Override      protected void onLayout(boolean changed, int l, int t, int r, int b) {            if (mFlexLayoutPosition != null) {              // 因为在用到ViewDragHelper处理布局交互的时候,若是有子View的UI更新导致重新Layout的话,需要我们自己处理ViewDragHelper拖动时子View的位置,否则会导致位置错误              // Log.e("YytLayout1", "292行-onLayout(): " + "自己处理布局位置");              mFlexView.layout(mFlexLayoutPosition.getLeft(), mFlexLayoutPosition.getTop(), mFlexLayoutPosition.getRight(), mFlexLayoutPosition.getBottom());              mFollowView.layout(mFollowLayoutPosition.getLeft(), mFollowLayoutPosition.getTop(), mFollowLayoutPosition.getRight(), mFollowLayoutPosition.getBottom());              return;          }            final int paddingLeft = getPaddingLeft();          final int paddingTop = getPaddingTop();            int multiHeight = 0;            int count = getChildCount();            if (count != 2) {              throw new IllegalArgumentException("此容器的子元素个数必须为2!");          }            for (int i = 0; i < count; i++) {              // 遍历子元素并对其进行定位布局              final View child = getChildAt(i);              MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();                int left = paddingLeft + lp.leftMargin;              int right = child.getMeasuredWidth() + left;                int top = (i == 0 ? paddingTop : 0) + lp.topMargin + multiHeight;              int bottom = child.getMeasuredHeight() + top;                child.layout(left, top, right, bottom);                multiHeight += (child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin);          }        }</code></pre>    <p>3.5.触摸事件的处理,由于缩放不会影响 mFlexView 真实宽高,ViewDragHelper 仍然会阻断 mFlexView 的真实宽高的区域,所以这里判断手指是否落在 mFlexView 视觉上的范围内,在才去调 ViewDragHelper 的 shouldInterceptTouchEvent 方法</p>    <pre>  <code class="language-java">@Override      public boolean onInterceptTouchEvent(MotionEvent ev) {            // Log.e("YytLayout", mFlexView.getLeft() + ";" + mFlexView.getTop() + " --- " + ev.getX() + ":" + ev.getY());           // 由于缩放不会影响mFlexView真实宽高,这里手动计算视觉上的范围          float left = mFlexView.getLeft() + mFlexWidth * (1 - mFlexScaleRatio) - mFlexScaleOffset * (1 - mFlexScaleRatio);          float top = mFlexView.getTop() + mFlexHeight * (1 - mFlexScaleRatio) - mFlexScaleOffset * (1 - mFlexScaleRatio);           // 这里所做的是判断手指是否落在mFlexView视觉上的范围内          mInFlexViewTouchRange = ev.getX() >= left && ev.getY() >= top;            if (mInFlexViewTouchRange) {                return mDragHelper.shouldInterceptTouchEvent(ev);            } else {              return super.onInterceptTouchEvent(ev);          }      }        @Override      public boolean onTouchEvent(MotionEvent event) {          if (mInFlexViewTouchRange) {              // 这里还要做判断是因为,即使我不阻断事件,但是此Layout的子View不消费的话,事件还是给回此Layout              mDragHelper.processTouchEvent(event);              return true;          } else {              // 不在mFlexView触摸范围内,并且子View没有消费,返回false,把事件传递回去              return false;          }      }</code></pre>    <p>3.6.在 computeScroll 中,若是 mIsClosing 为 true,即关闭的整个平移执行完毕了,通知回调事件</p>    <pre>  <code class="language-java">@Override      public void computeScroll() {          if (mDragHelper.continueSettling(true)) {              invalidate();          } else if (mIsClosing && mOnLayoutStateListener != null) {              // 正在关闭的情况下,并且拖动结束后,告知将要关闭页面              mOnLayoutStateListener.onClose();              mIsClosing = false;          }      }        /**       * 监听布局是否水平拖动关闭了       */      public interface OnLayoutStateListener {            void onClose();        }        public void setOnLayoutStateListener(OnLayoutStateListener onLayoutStateListener) {          mOnLayoutStateListener = onLayoutStateListener;      }        /**       * 展开布局       */      public void expand() {          mDragHelper.smoothSlideViewTo(mFlexView, 0, 0);          invalidate();      }</code></pre>    <p>3.7.容器实现了,接下来我们继承 YytLayout 实现播放器页面的组合控件即可,再封装一些常用的方法,这里使用的是大名鼎鼎的 Ijkplayer 实现的播放器,屏蔽了 IjkVideoView 的触摸事件自己处理了;顺带一提,为了实现播放器 Controller 跟随拖动缩放的效果,放弃了常用的 PopupWindow 实现的思路,IjkController 直接是添加到 IjkVideoView 中的,要不弹窗实现跟随播放器太麻烦了</p>    <pre>  <code class="language-java">/**   * Created by Oubowu on 2016/12/27 17:32.<p>   * 仿音悦台播放页面的具体实现,组合控件的形式   */  public class YytPlayer extends YytLayout {        private IjkController mIjkController;        private IjkVideoView mIjkVideoView;        private ImageView mIvAvatar;      private TextView mTvName;      private TextView mTvTime;      private TextView mTvTitle;      private TextView mTvDesc;      private RecyclerView mYytRecyclerView;        private VideoListAdapter mVideoListAdapter;        public YytPlayer(Context context, AttributeSet attrs) {          super(context, attrs);          init(context, attrs);      }        private void init(Context context, AttributeSet attrs) {            // 继承YytLayout并且通过merge标签减少层级来实现组合控件          LayoutInflater.from(context).inflate(R.layout.yyt_player, this, true);            setOnLayoutStateListener(new OnLayoutStateListener() {                @Override              public void onClose() {                  setVisibility(View.INVISIBLE);                  mIjkVideoView.release(true);              }          });            mIjkVideoView = (IjkVideoView) findViewById(R.id.ijk_player_view);          final int scaledTouchSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop();          mIjkVideoView.setOnTouchListener(new OnTouchListener() {                float mDownX = 0;              float mDownY = 0;              boolean mClickCancel;                @Override              public boolean onTouch(View v, MotionEvent event) {                  float x = event.getX();                  float y = event.getY();                  switch (event.getAction()) {                      case MotionEvent.ACTION_DOWN:                          mDownX = x;                          mDownY = y;                          break;                      case MotionEvent.ACTION_MOVE:                          if (Math.abs(mDownX - x) > scaledTouchSlop || Math.abs(mDownY - y) > scaledTouchSlop) {                              mClickCancel = true;                          }                          break;                      case MotionEvent.ACTION_UP:                          if (!mClickCancel && Math.abs(mDownX - x) <= scaledTouchSlop && Math.abs(mDownY - y) <= scaledTouchSlop) {                              // 点击事件偶尔失效,只好这里自己解决了                              if (isHorizontalDragEnable()) {                                  expand();                              } else {                                  mIjkVideoView.toggleMediaControlsVisibility();                              }                          }                          mClickCancel = false;                          break;                      case MotionEvent.ACTION_CANCEL:                          mClickCancel = false;                          break;                  }                  return true;              }          });            mIvAvatar = (ImageView) findViewById(R.id.iv_avatar);          mTvName = (TextView) findViewById(R.id.tv_name);          mTvTime = (TextView) findViewById(R.id.tv_time);          mTvTitle = (TextView) findViewById(R.id.tv_title);          mTvDesc = (TextView) findViewById(R.id.tv_desc);            mVideoListAdapter = new VideoListAdapter();          mVideoListAdapter.setOnItemClickCallback(new OnItemClickCallback() {              @Override              public void onClick(View view, int position) {                  int pos = (Integer) view.getTag();                  VideoSummary summary = mVideoListAdapter.getData().get(pos);                  playVideo(mVideoListAdapter.getData(), summary);              }          });            mYytRecyclerView = (RecyclerView) findViewById(R.id.yyt_recycler_view);            GridLayoutManager gridLayoutManager = new GridLayoutManager(context, 2, LinearLayoutManager.VERTICAL, false);          mYytRecyclerView.setLayoutManager(gridLayoutManager);            mYytRecyclerView.setNestedScrollingEnabled(false);            mYytRecyclerView.addItemDecoration(new VideoListItemDecoration(context));            mYytRecyclerView.setAdapter(mVideoListAdapter);        }        // 播放视频      private void playVideo(String path, String name) {            try {              if (mIjkController == null) {                    IjkMediaPlayer.loadLibrariesOnce(null);                  IjkMediaPlayer.native_profileBegin("libijkplayer.so");                    mIjkController = new IjkController(mIjkVideoView, name);                    mIjkController.setOnViewStateListener(new IjkController.OnViewStateListener() {                      @Override                      public void onBackPress() {                          stop();                      }                  });                    mIjkVideoView.setMediaController(mIjkController);                    mIjkVideoView.setOnPreparedListener(new IMediaPlayer.OnPreparedListener() {                      @Override                      public void onPrepared(IMediaPlayer mp) {                          mIjkVideoView.start();                      }                  });                    mIjkVideoView.setOnErrorListener(new IMediaPlayer.OnErrorListener() {                      @Override                      public boolean onError(IMediaPlayer mp, int what, int extra) {                          Toast.makeText(getContext(), "视频播放出错了╮(╯Д╰)╭", Toast.LENGTH_SHORT).show();                          return true;                      }                  });                } else {                  // 重新设置视频名字                  mIjkController.setVideoName(name);              }                // 设置这个TextureView播放器缩放就正常了              mIjkVideoView.setRender(IjkVideoView.RENDER_TEXTURE_VIEW);              // 因为每次setRender都会移除view再添加,为了缩放效果这里控制器是添加到IjkVideoView中的,所以这里也要重新添加才能在IjkVideoView的最上面              mIjkController.updateControlView();                // 显示加载条              mIjkController.showProgress();                // 播放视频              mIjkVideoView.setVideoURI(Uri.parse(path));            } catch (UnsatisfiedLinkError e) {              e.printStackTrace();              Toast.makeText(getContext(), "你的CPU是" + Build.CPU_ABI + ",当前播放器使用的编译版本" + BuildConfig.FLAVOR + "不匹配!", Toast.LENGTH_LONG).show();          }        }        /**       * 显示布局,并且播放视频       *       * @param data    视频列表,用于播放页面下面的列表布局       * @param summary 播放的视频信息       */      public void playVideo(List<VideoSummary> data, VideoSummary summary) {            // 拿到数据,设置到播放的布局的相关信息          Glide.with(getContext()).load(summary.mTopicImg).transform(new GlideCircleTransform(getContext())).into(mIvAvatar);          mTvName.setText(summary.mTopicName);          mTvTime.setText(summary.mPtime);          mTvTitle.setText(Html.fromHtml(summary.mTitle));          if (summary.mDescription.isEmpty()) {              mTvDesc.setText(summary.mTopicDesc);          } else {              mTvDesc.setText(Html.fromHtml(summary.mDescription));          }            // 设置YytLayout可见,并且展开          setVisibility(View.VISIBLE);          expand();            mVideoListAdapter.setData(data);          mVideoListAdapter.setItemWidth(mYytRecyclerView.getWidth() / 2);          mVideoListAdapter.notifyDataSetChanged();            // 播放视频          playVideo(summary.mMp4HdUrl == null ? summary.mMp4Url : summary.mMp4HdUrl, summary.mTitle);      }        // 开始播放      public void start() {          if (mIjkVideoView != null && !mIjkVideoView.isPlaying()) {              mIjkVideoView.start();          }      }        // 暂停播放      public void pause() {          if (mIjkVideoView != null && mIjkVideoView.isPlaying()) {              mIjkVideoView.pause();          }      }        // 停止播放      public void stop() {          setVisibility(View.INVISIBLE);          if (mIjkVideoView != null) {              mIjkVideoView.release(true);          }      }        public boolean isShowing() {          return getVisibility() == VISIBLE;      }  }</code></pre>    <h2>4.总结</h2>    <p>说难也不难,就是各种抠细节需要脑洞,各位不妨看到好玩的交互自己打开脑洞一下,接下来可能要实现下 UC 浏览器播放器的效果,感觉也是非常有意思。</p>    <p> </p>    <p> </p>