Published on

小程序中类抖音的交互实现方案

Authors
  • avatar
    Name
    Hansuku
    Twitter

首先思考一个问题,如果想要在小程序中实现抖音上下切换、拥有漂浮物按钮等怎么办?

这个问题从 18 年 9 月开始一直让我头痛到睡不着觉,历时八个月,中间有过微信的更新,有过我们自己的努力,终于把这个坑爬出来了。下面会逐一做解决问题的分析,如果想看结果可以直接跳到结尾。

作为稍微有一点经验的前端,看到这道题的时候可能会代入传统网页的思想。

pageScrollTo

准备 N 个视频容器,在容器上做滑动、点击等事件监听,touchStart 时记录手指 XY 轴,结束时 diff XY 轴判断滑动方向然后调用 pageScrollTo 页面的滚动,滚动到上一个或下一个容器的位置。当然了,这是最基础的想法,且先不说touchStarttouchEnd 件单靠 XY 轴改变值来判断是否滚动这样的触发条件可靠,视频一多起来实例简直要卡上天,并且如果想要实现手指拖多少视频容器滚动多少,就需要频繁调用 setData,这在小程序中是一个相当高危的操作,即使做了节流性能依旧好不到哪里去,IOS 上可能通过 transition 来让他看起来不卡顿但是有段落感,安卓上直接会卡顿的异常明显。

pageScrollTo升级版

第一个方案理应不动手直接 PASS,但是在我看过微视的小程序之后我还是决定一试。

微视的小程序通过创造一个 Video 容器和 N 个图片容器来做载体,在视频上滑动时,判断方向然后pageScrollTo给 300ms 的过渡滚动到图片的位置并且给一定延迟,在确定滚动完成以后再次 pageScrollTo并且过渡为 0ms 滚动回视频的位置。

这样的逻辑看起来可能有点扯淡,但微视他们确实这样做到了,只是效果不尽人意,第一,必须等用户手指离开以后一次性滚动到目标位置,虽然pageScrollTo给延迟以后的流畅性还算不错,但用户在滑动的时候多少显得生硬。第二,微视把所有的事件监听挂载在了视频上方的一个容器当中,这极度容易导致误触,直到现在他们也没有解决在安卓上,点击漂浮于视频上方的按钮会导致视频切换,这也变相印证了第一个方案里依靠touchStarttouchEnd判断滑动与否是行不通的,他太容易误触了。

偷梁换柱式

在打算用内嵌网页来完成这个效果的时候,我们突然发现了一款名叫“趣看看短视频的”小程序,他给了我一些灵感。 由于在 19 年 2 月前,小程序还没有同层渲染,所以 Video 组件不能被放到 Swiper当中,所以本身依靠Swiper来实现这样的东西似乎不大可能,但是这个小程序确实依靠Swiper来做到了手指拖动 十分舒适的切换。 他在Swiper中放的不过是一些视频的封面图,也就是很多个Image标签,视频被放置在Swiper的上方,依靠绝对定位来漂浮。在视频播放时,Video 标签的 left0,视频暂停或在滑动状态时,Video 标签的 left 被切换到了9999,然后在滑动的时候,Swiper有一个滑动动画结束的钩子animationFinsh,这个钩子被执行以后再次把Video 标签的 left 变成0,这样就完成了一整套滑动效果。

这样看起来很美好,而且后继我仿照这样的思路做出来一个在线上跑了 4 个月的版本,但它却有个一个致命的交互问题,如果要切换视频,就必须暂停视频,因为滑动事件是Swiper的,我们把视频盖再了他上方会导致滑动事件完全自闭,所以只能在第一次滑动的时候把视频暂停并且推到left:9999,再次滑动来完成切换。


再后来,是今年二月,春节回来以后我们迎来了喜大普奔的消息(以下是官方原话):

小程序原生组件因脱离 WebView 渲染而存在一些使用上的限制,为了方便开发者更好地使用原生组件进行开发,我们对小程序原生组件引入了 同层渲染 模式。通过同层渲染,小程序原生组件可与其他内置组件处于相同层级,不再有特殊的使用限制。

现阶段,小程序 video 组件 已切换至同层渲染模式。在该模式下,video 组件可以做到:

1、直接通过 z-index 属性对 video 组件进行层级控制;

2、无需使用 cover-view、cover-image 组件来覆盖 video 组件;

3、可在例如 scroll-view、swiper、movable-view 等内置组件中使用 video 组件;

4、可通过 CSS 对 video 组件进行控制;

5、video 组件不会遮挡 vConsole。

基础库 v2.4.0 及以上版本已默认开启 video 同层渲染,其他原生组件如 input、map、canvas、live-player、live-pusher 等也将逐步切换至同层渲染模式。


划重点,敲黑板,可以在Swiper中使用video组件了

于是,在 4 月初的时候我们终于打算依靠这个特性来修改原来的代码 首先我们设计思路是,永远保证只有一个视频容器,因为我们的数据不是列表制的,每一个视频都有对应的上一个或者下一个视频,所以需要在请求了视频详情以后拿到上一个下一个的封面图 ID 等信息动态插入到Swiper的列表当中,图片的消耗相对是十分低的,这样用户在这个页面滑动了 500 次,即使不考虑回收也依旧可以让小程序流畅运行。

先上 wxml 部分的代码

<swiper class="swiper-container" vertical="{{true}}" duration="300" current="{{current}}" bindanimationfinish="swiperSuccess" bindtouchstart="swiperStart" bindtouchend="swiperTouchEnd" id="swiperInstant" data-e-animationfinish-so="this" data-e-animationfinish-a-a="{{current}}">
    <swiper-item wx:key="index" wx:for="{{loopArray0}}" wx:for-item="item" wx:for-index="index">
        <block>
            <block wx:if="{{current == index}}">
                <video src="{{cdn_name + data.video_path}}" controls="{{false}}" autoplay="{{true}}" poster="{{cdn_name + data.face_img}}" loop="{{true}}" id="{{'video' + index}}" class="video-container" bindplay="startPlay" bindtouchend="handlePlayClick" enable-progress-gesture="{{false}}" bindtimeupdate="handleTimeUpdate" data-e-touchend-so="null" data-e-touchend-a-a="{{index}}"></video>
            </block>
            <block wx:else>
                <image src="{{cdn_name + item.$original.face_img}}" style="{{item.$loopState__temp2}}"></image>
            </block>
        </block>
        <block wx:if="{{!isPlay}}">
            <view class="pause-container" bindtap="handlePlayClick" data-e-tap-so="null" data-e-tap-a-a="{{current}}">
                <image class="pause-img" src="{{cdn_name + '/rush_rabbit/img/video_play_v4.png'}}"></image>
            </view>
        </block>
    </swiper-item>
</swiper>

可以看到 核心部分的代码其实就是wx:if的那个判断,我们记录了Swipercurrent,并且对 swiper-item的下标做比对来判断当前用户看到的是哪个 swiper-item,一致则展示Video组件,否则展示Image组件。讲道理本身wx:key的值不应该设置下标,但是考虑到我们并不会删除列表里的数据造成大量的 diff,也好取值,就用了 index。 需要注意一点的是,视频上我绑定的点击事件会和Swiperbindanimationfinish冲突,这里我也很莫名其妙,但是他确实执行了,为了避免导致bindanimationfinish的误触,我们需要记录手指点击位置和松开位置,然后在bindanimationfinish判断他是点击还是滑动,做响应的处理

// 点击事件
{
    key: "swiperStart",
    value: function swiperStart(e) {
        this.setState({
            startTime: e.timeStamp,
            showGoods: false,
            showShare: false,
            pointer: {
                startX: e.changedTouches[0].clientX,
                startY: e.changedTouches[0].clientY,
                endX: e.changedTouches[0].clientX,
                endY: e.changedTouches[0].clientY
            }
        });
    }
},
// 点击结束
{
    key: "swiperTouchEnd",
    value: function swiperTouchEnd(e) {
        var startX = this.state.pointer.startX;
        var startY = this.state.pointer.startY;
        this.setState({
            pointer: {
                startX: startX,
                startY: startY,
                endX: e.changedTouches[0].clientX,
                endY: e.changedTouches[0].clientY
            }
        });
    }
},
// 滑动动画结束
{
    key: "swiperSuccess",
    value: function swiperSuccess(current, e) {
        var _state$pointer = this.state.pointer,
            startX = _state$pointer.startX,
            startY = _state$pointer.startY,
            endX = _state$pointer.endX,
            endY = _state$pointer.endY;
        // 如果点击屏幕的位置和松开屏幕的位置完全一致,则return
        if (startX == endX || startY == endY) {
            return;
        }
        var id = this.props.videoReducer.listData[e.detail.current].id;
        // 切换视频
        this.props.changeID(id, 'switch');
        // 暂停视频
        var video = _index2.default.createVideoContext("video" + current,this.$scope);
        video.pause();
    }
}

这是第一个版本的代码,其中还有一些可优化的点,甚至我们在思考是否可以加入 3 个 Video 容器来做预加载,但起码现在的效果,已经甩开市面上的小程序一大截了,至少目前快手、微视的小程序体验都十分糟糕,而我认为依靠我们的方案至少是能够实现 60 分的产品。

*/}