微信小程序文本后添加更多功能的实现

1,125 阅读6分钟

碰到的问题

这是我在实际的微信小程序项目中遇到的一个问题,设计稿要求对大段文本进行展开和收缩操作,但是操作文本是添加文本后面,具体样式看下图设计稿

设计稿

设计要求点击“收起”文本时,显示两行文本,并且在文本末尾添加 ... 以及更多,点击“更多”时,恢复全部文本显示。

这个收起还好操作,两个 text 标签连续写就行,但是这个收缩就抓瞎了,作为一个刚入门小程序开发的新手,内心一片 mmp 飘过。实在没办法,祭出搜索大法,找到了几个常用的方法,接下来说说选型的过程。

选型分析

网友常用的方法有三种:

  1. 通过 css 的 float 实现文本环绕
  2. 通过 js 计算文本高度
  3. 通过小程序的 api measureText 测量文本宽度,然后截取文本显示
第一种方法

这个方法网友的帖子分析的非常好,效果也很完美,但是我在小程序上尝试了很久,一直不能实现成功,主要问题是不能实现文本环绕显示,无奈放弃(应该是我对 css 掌握的不够,后续还是要尝试以该方法实现)。

异常的效果

附上参考链接segmentfault.com/a/119000004…

第二种方法

此方法是通过 js 遍历文本,逐字添加显示的文本(也可以用二分法等算法优化),每遍历一次,增加一个文字,然后获取文本框的高度,这样就会使 dom 树持续刷新,影响性能,故放弃

第三种方法

这是我当前采用的方法,这个方法也不是很完美

该方法是使用小程序的 canvas 标签自绘文本,先通过小程序 api measureText 测量原始文本的宽度,如果宽度小于两行文本的宽度,那么直接显示全部文本,不显示“更多”或者“收起”文本,如果宽度大于两行文本的宽度那么使用二分法精确定位第一行断句及第二行断句的位置。

第一行断句逻辑为从起始位置到目标位置截取的文本的 measureText 的返回值刚好小于 canvas 的宽度。第二行断句的逻辑为从第一行断句后到第二行目标位置截取文本的 measureText 的返回值加上“...更多”的宽度刚好小于 canvas 的宽度。

通过以上逻辑就能确定第一行文本,和第二行的文本了,然后将其绘制在 canvas 上就行。

这时候又碰到了一个大坑,canvas 绘制文本的字体无法使用手机系统的默认字体,必须指定一个字体,这就会导致如果手机设置了自定义字体的,那么我们的通过canvas绘制的文本就会与其他文本不一样 (•'╻'•)꒳ᵒ꒳ᵎᵎᵎ,按微信文档 font 设置为符合 CSS font 语法的 DOMString 字符串,然而无效,将 css 中各种通用文字设置了一遍也没用。

小米手机上的效果

这是我的小米手机显示效果,上面二行是用 text 标签显示,下面二行是用 canvas 绘制,字体大小有点不一样,这个是因为 px 与 rpx 转换问题,可以将 text 标签的文字大小设为 px 单位解决,但是字体问题无解

最终只能使用 canvas 做测量,然后截取短文本保存在 data 中,再用 text 加载,最终效果如上图的前两行,"更多"两字是用了绝对定位放在了布局的右下角。

核心逻辑(canvas绘制文本)

wxml 中

<canvas type="2d" id="myCanvas" class="canvas"/>

wxss 中定义 canvas

.canvas {
    width: 678rpx;
    font-size: 28rpx;//实际测试,这个设置对默认文本等都无效
    font-style: normal;//实际测试,这个设置对默认文本等都无效
    font-weight: normal;//实际测试,这个设置对默认文本等都无效
    line-height: 33.6rpx;//实际测试,这个设置对默认文本等都无效
    height: 68rpx;//高度这边先定义了两行文本的高度,实际可以大一点,不然有时候绘制文本到画布外了,不好观察效果
    margin: 0 36rpx;
}

js 中

//全局设置px单位与rpx单位的换算,wx.getSystemInfoSync()中的pixelRatio不准,这里手动计算
const pxToRpx = 750 / wx.getSystemInfoSync().windowWidth;

//在页面加载时,就计算
onLoad: async function (option) {
        // 通过 SelectorQuery 获取 Canvas 节点
        wx.createSelectorQuery()
            .select('#myCanvas')//与wxml中的id一直
            .fields({
                node: true,//返回节点
                size: true,//返回实际大小
            })
            .exec(this.initCanvas)
},
  
initCanvas(result) {
  	//计算行高,将行高设置为字体高度的1.2倍
        const lineHeightRatio = 1.2
        
        //获取canvas
        let canvas = result[0].node;
  	//获取2d的画布上下文,2d与wxml中的type="2d"一致,还有一种是WebGL
        const ctx = canvas.getContext("2d");

        //转换成通用rpx单位,
  	//注意,canvas.width本来有值,但是高度好像不准,参考小程序官方demo,
        //使用wx.createSelectorQuery()查询的返回值,与wxss中设置宽高一致
        canvas.width = result[0].width * pxToRpx
        canvas.height = result[0].height * pxToRpx
  	//将画布进行同比例缩放,后续即可按照rpx单位尺寸进行绘制,定位等
        ctx.scale(pxToRpx, pxToRpx)

  	//设置颜色
        ctx.fillStyle = "#353E58"
  	//换算字体大小,注意字体设置必须使用px单位,将设计稿的28rpx换算成px大小
        let fontsize = 28 / pxToRpx;
  	//字体设置兼容html canvas 2d标准
  	//这是官方的声明https://developers.weixin.qq.com/community/develop/doc/00020a02c2c040114d19a398f5b001?blockType=1
  	//这里有个巨坑,最后一个是指定字体,关键是我找不到不指定字体,使用默认字体的选项,
        //generic-name、generic-family会使用默认字体,但不是手机系统的默认字体,我的小米手机设置了自定义字体,
        //但它不会用这个字体,留空则是font设置失败,字体大小设置无效。不知道谁知道怎么设置,可以在评论区告诉我下。
        //鉴于字体的这个原因,我最终使用的方案是:使用canvas计算文本宽度,但不绘制,保存一个short文本用于收缩
        //状态,最终显示都是放在text标签内显示,更多和收起单独用一个text标签,用于绑定点击事件。
        ctx.font = `normal normal ${fontsize}px generic-family`
        //这个textBaseline关系到绘制基准点问题,我会下面fillText时再展开
        ctx.textBaseline = 'top'
	//获取原始数据
        let text = this.data.bean.abstract;
        let measureText = ctx.measureText(text);

        //简介小于两行时,不显示展开/收起按钮,也不深入处理,直接无脑显示
        if (measureText.width < canvas.width * 2 / pxToRpx) {
            this.setData({
              	//控制是否显示展开/收起按钮
                isAbstractExpanded: true,
              	//收起展开状态标记位
                showAbstractAction: false,
            })
            return
        }
				
        //获取第一行文字长度,传入三个参数,画布上下文,画布宽度,待处理的文本
        let firstLength = this.calcBound(ctx, canvas.width, text);
  	//绘制文本,fillText接收三个参数,都一个内容,第二个绘制的x位置,第三个绘制的y位置
  	//textBaseline = 'top' 时,绘制文本的基准线为文字左上角,即最终占据位置为(0,0)到对角的(文本宽度,文本高度)
  	//textBaseline = 'middle' 时,绘制文本的基准线为文字水平中间,按0,0参数绘制,最终占据位置为(0,-1/2*高度)到对角的(文本宽度,1/2*文本高度),这个适用于需要居中的绘制情况
  	//textBaseline = 'bottom' 时,绘制文本的基准线为文字底部,按(0,0)绘制,会看不到
  	//这是顺直方向的,水平方向的可查看该文:http://m.imxmx.com/show/1/68689.html#
        // ctx.fillText(text.substring(0, firstLength), 0, 0)

        //获取第二行文字长度,后缀添加... 更多
        let moreString = "... 更多";
        let measureMore = ctx.measureText(moreString);
        let secondLength = this.calcBound(ctx, canvas.width - measureMore.width, text.substring(firstLength, text.length - 1));
        let secondLine = text.substring(firstLength, firstLength + secondLength);
  	//文本绘制的我都注释,最后面贴了效果图,可以看一下
        // ctx.fillText(`${secondLine}...`, 0, fontsize * lineHeightRatio)
        let abstract = text.substring(0, firstLength) + secondLine + '...'
        //最终完成两行文本的赋值及显示
        this.setData({
            shortAbstract: abstract
        })
    },
      
//计算文本第一行的长度,返回文本位置下标,这里使用了二分法进行计算,
//判断核心逻辑:某个长度的文本的宽度 < singleLineWidth 并且 某个长度+1的文本的宽度 > singleLineWidth
calcBound(ctx, cWidth, text) {
  	//因为计算文本测量出来的宽度都是px,对画布宽度进行单位换算
        let singleLineWidth = cWidth / pxToRpx;
        let low = 0;
        let high = text.length - 1;
        let mid;
        while (low < high) {
            mid = Math.floor((low + high) / 2)
            //宽度比单行小
            let measureWidth = ctx.measureText(text.substring(0, mid)).width;
            if (measureWidth < singleLineWidth) {
                low = mid + 1
            } else if (measureWidth > singleLineWidth) {
                high = mid - 1
            } else {
                return mid;
            }
        }

        return low - 1;
},