优雅实现文本展开收起功能(全平台兼容) uniapp版本 v

实现文本溢出的展开收起功能,纯 CSS 方案在网页中可行,但在小程序中存在兼容性问题。

最优的解决方案就是使用 JavaScript 的二分截断法。

看了下 vant 的 TextEllipsis 组件源码。

理解了算法的实现原理后就写了一个uniapp版本和vue3版本的展开收起组件。

算法步骤:

  1. 创建隐藏容器并渲染内容。
  2. 计算最大行高(行数 × 单行行高)。
  3. 使用递归算法,类似于 tail(left, content.length)。
  4. 取中间值,并将其写入隐藏容器。
  5. 等待渲染完成后获取最新高度。
  6. 如果隐藏容器的高度超过最大行高,则继续调用 tail,使用 left = left,right = middle。
  7. 否则,可能是内容太少了(或者无法再继续截断,那就返回截取的内容)。使用 left = middle,right = right 继续调用 tail。

这个算法通过不断地二分截断,寻找到最合适的截取内容。

就算是1000多字,限定2行展示,截断次数也只在10次左右。

扩展:canvas海报的文字溢出功能也可以用这个算法。

uniapp版本

下面是从源码抽离出来单独封装的uniapp和vue3版本(网页,小程序,app都测试过)

先上效果图 300多ms:
图片

图片
uniapp版本有一些需要注意的点,如果兼容运行在小程序和app的话。

  1. 在小程序中,样式计算是在渲染过程中异步进行的,必须nextTick后才能获取容器最新高度(因为小程序样式计算是异步的。所以性能比不上网页的2ms,实测是300+ms)。
  2. 获取元素节点信息的方法也不一样。
  3. 行高如果是继承的获取的就是inherit。所以需要传行高进去。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
js复制代码<template>
<view
:class="{root:true,visible:!show}"
:style="{ lineHeight: props.lineHeight }"
>
{{ expanded ? props.content : text }}
<text class="action" v-if="hasAction" @click="onClickAction"
>{{ actionText }}</text
>
</view>
<view :class="{hiddenText:true}" :style="{ lineHeight: props.lineHeight }"
>{{ text }}</view
>
</template>

<script lang="ts" setup>
import { defineProps, ref, getCurrentInstance, nextTick, computed, onMounted } from 'vue';
const instance = getCurrentInstance(); // 获取组件实例

const props = defineProps({
content: {
type: String,
default: ''
},
rows: {
type: Number,
default: 2
},
lineHeight: {
type: Number,
default: '30rpx'
}
});

const expanded = ref(false);
const text = ref(props.content);
const hasAction = ref(false);
const show= ref(false);

const actionText = computed(() => {
return expanded.value ? '收起' : '展开';
});
const onClickAction = () => {
expanded.value = !expanded.value;
};
// 查询元素形状信息
const qeuryRect = queryText => {
let query = uni.createSelectorQuery().in(instance);
return new Promise((resolve, reject) => {
query
.select(queryText)
.boundingClientRect(rect => {
resolve(rect);
})
.exec();
});
};
// 查询元素样式属性等信息
const qeuryRectProp = queryText => {
let query = uni.createSelectorQuery().in(instance);
return new Promise((resolve, reject) => {
query
.select(queryText)
.fields({ computedStyle: ['lineHeight', 'height'], dataset: true, size: true }, rect => {
resolve(rect);
})
.exec();
});
};
let dots = '...';
let content = props.content;
let end = content.length;
const setHiddenText = val => {
return new Promise((_, reject) => {
text.value = val;
console.error(val);
nextTick(() => {
_(val);
});
});
};
// 计算截断
const calcEllipsisText = maxHeight => {
const tail = async (left, right) => {
// 递归终止条件
if (right - left <= 1) {
return content.slice(0, left) + dots;
}
const middle = Math.round((left + right) / 2);
// 设置拦截位置(注意slice 0,middle,虽然left ,right不断变,但是0是不变的)
await setHiddenText(content.slice(0, middle) + dots + actionText.value);
let result = await qeuryRectProp('.hiddenText');
if (parseInt(result.height) > maxHeight) {
return tail(left, middle);
}
// 太往左了,内容不够,需要往右边移动
return tail(middle, right);
};
tail(0, end).then(res => {
text.value = res;
show.value=true
console.timeEnd("完成计算")
});
};
// 开始计算
onMounted(() => {
console.time("完成计算")
nextTick(async () => {
let result = await qeuryRectProp('.hiddenText');
let maxHeight = parseInt(result.lineHeight) * props.rows;
// 隐藏的行高大于限定行数高度
if (maxHeight < parseInt(result.height)) {
hasAction.value = true;
calcEllipsisText(maxHeight);
} else {
hasAction.value = false;
text.value = props.content;
show.value=true
}
});
});
</script>

<style lang="scss" scoped>
.visible {
visibility: hidden;
}
.hiddenText {
position: fixed;
z-index: -999;
top: -9999px;
}
.action{
color:#1989fa;
}
</style>

vue3版本

先上效果图:2ms

image.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
js复制代码<template>
<div ref="root">
{{ expanded ? props.content : text }}
<span v-if="hasAction" class="action" @click="onClickAction">
{{ actionText }}
</span>
</div>
</template>

<script setup>
import { ref, watch, computed, onMounted, onUnmounted, onActivated, defineProps, defineEmits } from 'vue'

const emit = defineEmits(['clickAction'])
const props = defineProps({
rows: {
type: Number,
default: 2,
},
dots: {
type: String,
default: '...',
},
content: {
type: String,
default: '',
},
expandText: {
type: String,
default: '展开',
},
collapseText: {
type: String,
default: '收起',
},
})

const useWindowResize = () => {
const window_width = ref(window.innerWidth)
onMounted(() => {
window.addEventListener('resize', () => {
windowWidth.value = window.innerWidth
})
})
onUnmounted(() => {
window.removeEventListener('resize', () => {
windowWidth.value = window.innerWidth
})
})
return window_width
}
const windowWidth = useWindowResize()

const text = ref('')
const expanded = ref(false)
const hasAction = ref(false)
const root = ref(null)
let needRecalculate = false
const actionText = computed(() => (expanded.value ? props.collapseText : props.expandText))

const pxToNum = (value) => {
if (!value) return 0
const match = value.match(/^\d*(\.\d*)?/)
return match ? Number(match[0]) : 0
}

const cloneContainer = () => {
if (!root.value || !root.value.isConnected) return
const originStyle = window.getComputedStyle(root.value)
const container = document.createElement('div')
const styleNames = Array.from(originStyle)
styleNames.forEach((name) => {
container.style.setProperty(name, originStyle.getPropertyValue(name))
})
container.style.position = 'fixed'
container.style.zIndex = '-9999'
container.style.top = '-9999px'
container.style.height = 'auto'
container.style.minHeight = 'auto'
container.style.maxHeight = 'auto'
container.innerText = props.content
document.body.appendChild(container)
return container
}
const calcEllipsised = () => {
console.time('完成计算')
const calcEllipsisText = (container, maxHeight) => {
const { content, dots } = props
const end = content.length
const calcEllipse = () => {
const tail = (left, right) => {
// 递归终止条件
if (right - left <= 1) {
return content.slice(0, left) + dots
}
const middle = Math.round((left + right) / 2)
// 设置拦截位置
container.innerText = content.slice(0, middle) + dots + actionText.value
if (container.offsetHeight > maxHeight) {
return tail(left, middle)
}
// 太往左了,内容不够,需要往右边移动
return tail(middle, right)
}
container.innerText = tail(0, end)
console.timeEnd('完成计算')
}
calcEllipse()
return container.innerText
}

// 计算截断文本
const container = cloneContainer()

if (!container) {
needRecalculate = true
return
}

const { paddingBottom, paddingTop, lineHeight } = container.style
const maxHeight = Math.ceil(
(Number(props.rows) + 0.5) * pxToNum(lineHeight) + pxToNum(paddingTop) + pxToNum(paddingBottom)
)

if (maxHeight < container.offsetHeight) {
hasAction.value = true
text.value = calcEllipsisText(container, maxHeight)
} else {
hasAction.value = false
text.value = props.content
}

document.body.removeChild(container)
}

const toggle = (isExpanded = !expanded.value) => {
expanded.value = isExpanded
}

const onClickAction = (event) => {
toggle()
emit('clickAction', event)
}

onMounted(calcEllipsised)

onActivated(() => {
if (needRecalculate) {
needRecalculate = false
calcEllipsised()
}
})

watch([windowWidth, () => [props.content, props.rows]], calcEllipsised)

defineExpose({ toggle })
</script>

<style scoped>
.action {
color: #1989fa;
}
</style>

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

0%