大众点评点餐小程序开发经验 - 菜单联动设计

7,193 阅读14分钟

作者介绍:李超,美团点评前端开发工程师,2年WEB开发经验,现在是美团点评点餐团队的一员。

  在我们团队的小程序开发经验系列多篇文章发布以后,你是否对小程序视图层(大众点评点餐小程序开发经验 - 视图层),逻辑层(大众点评点餐小程序开发经验 - 逻辑层),API(官方API文档)等有更为深入的学习和了解呢?
“纸上谈兵”很容易,“打好胜仗”才是关键。今天由我来为大家分享在实际开发“大众点评点餐小程序”中遇到的问题和解决方案。

效果展示

静态效果展示图

动态效果展示图

页面布局

  如果你看过我们的系列文章, 应该对我们的产品形态有了初步了解。我们是做点餐菜单服务,菜单需要分类,需要购物车模块,那么典型的''型布局是我们的首选。

大体结构为:顶部商家名称,可能会出现黄色横条提示模块;下方左侧为导航菜单栏;下方右侧为每个菜单分类包含的菜品展示列表;底部可能出现购物车模块。
看到这里,再结合上面的图片,你应该对菜单页的结构有比较具象的了解。
下面从产品角度说下具体的交互细节。

产品需求

  • 顶部要求显示商家名称,有分享功能;
  • 下方左侧、右侧可分开滚动,滚动左侧不影响右侧,滚动右侧左侧随之联动高亮显示所在的菜单分类;
  • 点击下方左侧导航菜单栏,高亮显示被点击的菜单分类,下方右侧对应分类详情模块顶部与右侧滚动区的顶部重合(类似于html中#id的锚点功能);
  • 滚动下方右侧菜品分类详情时,当该分类详情模块顶部接触到滚动区域的顶部,左侧对应的导航菜单栏高亮;
  • 若左侧高亮的导航菜单不在可视区域:
    • 当高亮的导航菜单顶部在左侧scroll-view滚动区上方(被遮住了),则将该高亮导航菜单滚动至将高亮导航栏的顶部与左侧可滚动区域顶部重合(高亮菜单为滚动区的第一个分类);
    • 当高亮的导航菜单在左侧scroll-view滚动区可视区下方,将高亮导航菜单滚动到屏幕中央区域(微小偏差可以接受,主要看用户体验。);
  • 顶部下方可能会出现黄条提示文案模块;
  • 底部上方可能会出现购物车模块;
  • 顶部黄条提示文案模块吸顶,底部购物车模块吸底;
  • 需要适配各种不同机型。

关键技术罗列

  这里需要指出:产品在设计成稿之前,我们已经对小程序支持的功能做了细致的调研,在确保可以通过技术手段实现产品需求的前提下才确定UI以及交互设计。

  • 从产品兼容性角度出发,我们考虑使用微信小程序的rpx作为UI设计的尺寸。该尺寸和rem非常类似,不同点在于其对基准尺寸的设定。rem使用文档根元素设定的尺寸作为基准尺寸,而rpx使用iphone6(s)手机屏幕宽度为基准定出1rpx对应的宽度,该动态尺寸对设备的兼容性更加友好;
  • 微信自带scroll-view UI组件,并提供一系列组件状态操作接口;
  • scroll-view组件滚动时触发scroll事件,返回的event对象各项长度属性均使用px作单位;

代码编译

src
├── menu.html
├── menu.js
├── menu.json
└── menu.less

我们在开发中使用工具对文件实时编译:

 `menu.html`->`menu.wxml`
 `menu.less`->`menu.wxss`

为方便代码维护以及日常的开发习惯,我们支持了less语法,引入了Promise

wxml页面布局

### menu.html 
<page>
    <view class="menu-content">
        <view class="yellow-bar">
            // 黄色横条提示模块
        </view>
        <scroll-view class="scroll-view-left" height="{{ windowScrollHeight }}" scroll-into-view="{{ leftToView }}" scroll-top="{{ leftScrollTop }}">
            // 左侧分类导航
        </scroll-view>
        <scroll-view class="scroll-view-right" height="{{ windowScrollHeight }}" scroll-into-view="{{ rightToView }}">
            // 右侧分类详情
        </scroll-view>
        <view class="cart-bar">
            // 购物车模块
        </view>
    </view>
</page>

  这里着重考虑两个scroll-view结构设计,左右的布局结构可以使用Css样式属性float或者是Css3flex;另外黄条提示模块和购物车模块使用fixed属性搞定。
微信官方文档介绍,使用scroll-view组件,必须指定高度。
实践结果:使用scroll-view可以不指定高度,页面有滚动区存在,问题是滚动时无法触发scroll事件,也就无法完成联动设计。

滚动区域高度

  我们知道使用scroll-view需要指定高度,那么这个高度值该怎么算出来,以什么样的方式设定呢?
这里我就不详细的说明其用法了,直接看 scroll-view文档

注意两点:

  • 必须使用px作单位
  • 必须在scroll-view上显式的指定其height属性

在获取滚动区高度windowScrollHeight之前,考虑其影响因素:

  • 设备高度
  • 黄条文案提示模块的存在
  • 购物车模块的存在
  • rpx->px的转换

设备高度可以通过微信官方api getSystemInfo接口API获取。

那么,该什么时候调用接口?
首先这是一个异步API接口,另外其直接受系统权限控制的影响,基于这两点因素,其结果返回的时机就不是确定的。
我们可以在小程序启动时在onLaunch中调起该API,然后将获取的结果放入到全局变量globalData中。而globalData是挂在在全局App上的属性,对所有页面均可见。

getSystemInfo 结果数据结构

sysInfo Object {
    errMsg:"getSystemInfo:ok"
    language:"zh_CN"
    model:"iPhone 6"
    pixelRatio:2
    platform:"devtools"
    system:"iOS 10.0.1"
    version:"6.3.9"
    windowHeight:627
    windowWidth:375
}

这里的windowHeight, windowWidth指的是屏幕高度和宽度,且使用的单位是px

获取sysInfo

// app.js
// 注意这里的wxp为我们对wx的封装,它继承wx的所有属性,特点是若调起wx的异步api函数将返回一个Promise实例。
 getSysInfo: function() {
        let that = this;
        if(that.globalData && that.globalData.sysInfo 
            && that.globalData.sysInfo.windowHeight) {
            // 将结果封装成Promise,后续可统一使用`then`方法
            return Promise.resolve(that.globalData.sysInfo);
        }
        return wxp.getSystemInfo()
            .then(res => {
                that.globalData.sysInfo = res;
                return res;
            })
            .catch(e => {
                // 可以尝试弹出框或toast
                console.error('[getSystemInfo]', e);
            });
   },

// menu.js 
onLoad: function() {
    app.getSysInfo().then((sysInfo)=> {
        // transform rpx -> px and calculate scroll-view height.
    }
}

计算fixed元素高度

  黄条文案提示模块,购物车模块的高度都是已知的。但大家是否注意到我之前提到的设计细节:所有的元素统一使用rpx做单位,而这里需要使用px作单位,必须要做rpx->px的转换。

rpx尺寸对照表

rpx->px装换

    var yellowBarRpxHeight = 50;  // 黄色文案提示模块高度
    var percent = app.data.sysInfo.windowHeight / 375; // 当前设备1rpx对应的px值
    var yellowBarHeight = Number(yellowBarRpxHeight * percent).toFixed(2);

大家对除数375是否有疑问呢, 该比值是否会受到设备实际像素点的影响呢? 答案:不会。
同样的道理可以得到购物车模块的高度cartBarHeight
通过公式:
windowScrollHeight = windowHeight - yellowBarHeight - cartBarHeight
计算得出两个scroll-view的滚动高度。

左->右联动

点击左侧导航菜单栏,右侧定位到对应的分类菜品详情。
通过查看scroll-view文档发现可以使用scroll-into-view属性;该组件自动定位右侧需要滚动到的具体位置。
给左侧导航菜单栏绑定tap事件监听函数,事件触发后获取event对象的currentTarget属性,取出渲染时存放在该节点上的分类id,用此id作为唯一标识定位右侧分类详情,设置右侧scroll-viewscroll-into-view属性,这时其会将右侧scroll-viewid属性值为该值的节点滚动到滚动区域的顶部(类似于html中的#id锚点功能)。

Tap事件监听函数

    // menu.js
   bindLeftTap (e) {
        // 由于事件是冒泡的,所以不确定点击操作是在哪个元素上触发的,但currentTarget表示当前绑定事件对应的节点,便可准确获取该节点上的dataset
        let dataset = e && e.currentTarget && e.currentTarget.dataset;
        var LEFT_TO_RIGHT_SUFFIX = "l2r-";
        if(!dataset || !dataset.id) return;
        // target
        this.setData({
            highlightCategoryId: dataset.id, // 左侧高亮的导航菜单栏
            rightToView: LEFT_TO_RIGHT_SUFFIX + dataset.id, // 更新右侧的scroll-to-view属性。
        });
    }
  • LEFT_TO_RIGHT_SUFFIX"是什么东西?其为全局定义的常量,只是为了方便大家阅读,才将其写入函数内部,用作id拼接,保证唯一性。
  • 在开发阶段曾经尝试直接将获取到的id作为rightToView的值,也就是设定右侧scroll-viewscroll-into-view属性,发现右侧scroll-view不会滚动到指定的高度。猜想可能因为获取到的dataset.id是一个数字类型字符串,其内部使用===方式导致不匹配。
  • 设置scroll-into-view引起的滚动操作同样会触发scroll事件。

右->左联动

   右→左联动是整个页面设计最核心的部分。由于小程序无法获取元素的宽高,位置信息,对滚动右侧实现左侧联动效果带来挑战。

如何准确的获取右侧滚动到的具体分类,并让左侧导航菜单栏相应分类高亮,且在可视的范围内?

在设计阶段,我们和设计同学确认右侧每个菜品详情模块高度固定,分类小灰条高度固定,这样我们就可以根据已有的数据结构计算出每个元素距离文档区顶部的高度。(请参考下图红框圈出内容分别对应分类小灰条,菜品模块详情)

单个菜品分类详情

// PER_BAR_HEIGHT 分类小灰条的高度
// PER_ITEM_HEIGHT 单个菜品详情的高度
var sumScrollHeight = 0;
var assistantCategories = spuMenuSet.map(it => {
    let unitHeight = PER_BAR_HEIGHT + (it.spuMenuItemList && it.spuMenuItemList.length ) * PER_ITEM_HEIGHT;
    it.scrollHeight = sumScrollHeight;
    sumScrollHeight += unitHeight;
    return it;
});

左侧导航菜单栏高亮分类切换的边界条件为右侧分类菜单详情的分类小灰条顶部与右侧滚动区顶部重合。

通过计算出每个分类小灰条距离文档顶部的高度scrollHeight,在每次滚动事件触发时,比较当前滚动的高度与分类小灰条的scrollHeight,就可确定当前在哪个分类菜单详情区域内,从而实现左侧分类导航栏的高亮。

机器误差

  在测试时发现,有些机型滚动下方右侧scroll-view时,在边界条件出现时并不会完成左侧导航菜单栏高亮分类的切换,往往存在10-100px的误差。从产品角度,这种误差是不能容忍的。个人并不确定是什么原因导致误差的出现,但看起来并没有非常好的解决办法。
那么能用什么方案减少误差呢? 我的实现思路是"人工干预自动校正"。

人工干预自动校正

仔细分析滚动事件返回的event对象

Object
    currentTarget:Object
    detail:Object
        deltaX:0
        deltaY:-971
        scrollHeight:24737
        scrollLeft:0
        scrollTop:2409
        scrollWidth:295
        __proto__:Object

    target:Object
        dataset:Object
            __proto__:Object
            id:""
            offsetLeft:0
            offsetTop:38
        __proto__:Object
    timeStamp:13932
    type:"scroll"
    __proto__:Object

特别留意detail中的scrollHeight

滚动事件会给出整个scroll-view文档内容的高度,这个高度值非常关键,我们完全可以通过计算:
scrollHeight = 单个菜品详情高度 * 菜品总数 + 单个分类小灰条高度 * 分类小灰条总数

由于单个菜品详情高度与单个分类小灰条高度的高度比是确定的,所以上面的方程式为一元方程,计算出单个菜品详情高度单个分类小灰条高度,更新每个分类小灰条距离文档顶部的距离scrollTop值。
经测试发现,左侧导航菜单栏高亮分类的切换精度非常高,而且兼容性很好。

左侧高亮分类跳错问题

在实际开发中, 我还发现一个问题: 左侧有分类A、B、C,点击分类B,分类B高亮,右侧定位到分类B的详情区域,随之左侧高亮分类切换到A上。
大家是否想到是什么原因导致的? 在上面讲解scroll-view属性时我提到过一句话:

设置scroll-into-view引起的滚动操作同样会触发scroll事件

这里点击左侧分类,右侧由于scroll-into-view触发了滚动事件,而相应的滚动事件监听函数函数,计算得出当前高亮的导航菜单栏为A,更新页面的data将高亮分类切换到了A上。
解决方案: ① 修改边界条件,但在不同机器上存在细微差别,我们无法准确的设置误差范围;毕竟元素宽高都是我们算出来的;② 限制右侧的scroll事件函数的执行。
推荐使用第二种方式。思路:若点击左侧导航菜单栏,设定全局锁定状态,若锁定则不右→左的联动操作,再解除锁定状态。

分类导航栏的可视问题

  通过上面“右→左”联动,我们已经可以让左侧随着右侧滚动而高亮,问题是: 左侧也是一个scroll-view,如何保证高亮的分类在可视区呢?具体的交互逻辑请看前面的产品需求

高亮分类在可视区下方

高亮分类在可视区上方

监听右侧滚动事件,判断当前在哪一个分类上,确定该分类在左侧scroll-view的文档高度,判断是否需要滚动左侧scroll-view
可以通过scroll-viewscroll-into-view或者scroll-top属性完成滚动。

// 这里是伪代码实现
var index = mapId2index(id); //将id转换为对应分类的index值
var perCateHeight = 40; // 左侧每个分类高度为40
var leftScrollTop = 0; // 左侧scroll-view滚动的高度
var windowScrollHeight = 1440; // 这个值为屏幕高度,可通过getSystemInfo获取到
var cHeight = index * perCateHeight; // 当前分类距离文档顶部的scrollTop值
if( cHeight - leftScrollTop - windowScrollHeight > 0) {
    // 高亮的区域在屏幕底部
    leftScrollTop = cHeight - windowScrollHeight / 2; //左侧scroll-view向上滚动半个屏幕高度
    leftToView = null; // 不使用scroll-into-view 属性, 必须置空, 否则会优先应用该属性而不是leftScrollTop
} else if (cHeight - leftScrollTop < 0) {
    // 高亮的区域在屏幕顶部之上,设置scroll-into-view属性
    leftToView = id;
    leftScrollTop = cHeight; // 需要记录下当前scroll-view滚动高度,以便下次使用
} else {
    leftToView = null;
}

注意点: 若同时设置了scroll-into-viewscroll-top属性,优先使用scroll-into-view属性, 故这里若使用scroll-top属性滚动时需要将scroll-into-viwe属性置空。

优化

联动功能开发完之后,遇到了性能瓶颈。由于复用之前C端的数据接口,接口中存在大量无用的对象属性,而这个数据结构直接作为页面渲染的data数据。
推荐的做法就是简化data数据结构,只存放影响页面渲染的数据,这样做能够大幅度降低UI渲染时间,给用户更加流畅的体验。

总结

微信小程序算是2016年-2017年里非常火的一门新技术了。
如何使用已经支持的功能特性来设计、开发产品是保障项目顺利完成的重要环节。
而在开发过程中,专注细节实现,吃透API文档,让用户感受到我们开发小程序的诚意,而不是在做粗糙的产品复制。

感受

在小程序发布那段时间,总能看到各种对小程序未来的设想,有悲观的,有观望的,也有激进的。我个人认为,“赶鸭子上架”的思路并不可取,必须清楚自己的产品定位。你的产品是否满足“一次性消费”理念,内容是否不足以吸引用户下载你的APP,是否比你的H5更加具有吸引力。这些都是需要我们做细致的思考的。


本文对你有帮助?欢迎扫码加入前端学习小组微信群: