跳到主要内容

移动端文字垂直居中方案

· 阅读需 14 分钟
熊滔

有过移动端开发经验的人,想必都对文字垂直居中头痛不已,因为使用常规手段实现文字垂直居中在部分 Android 手机上会出现文字偏上的情况,往往需要各种手段进行微调,苦不堪言。下面是我尝试的几种文字垂直居中方案:

line-height 实现垂直居中:

#line-height {
width: 200px;
text-align: center;

height: 56px;
line-height: 56px;

font-size: 26px;
color: black;

border: 1px solid;
border-radius: 28px;
}

display: flex 实现垂直居中:

#flex {
display: flex;
align-items: center;
justify-content: center;
width: 200px;
height: 56px;
font-size: 26px;
color: black;
border: 1px solid;
border-radius: 28px;
}

padding 实现垂直居中:

#padding {
width: 200px;
text-align: center;
box-sizing: border-box;
height: 56px;
font-size: 26px;
line-height: 1;
padding: 15px 0;
color: black;
border: 1px solid;
border-radius: 28px;
}

Canvas 实现垂直居中:

const canvas = document.querySelector('#canvas');
const ctx = canvas.getContext('2d');

// 解决高清屏模糊的问题
const computedStyle = window.getComputedStyle(canvas);
const dpr = window.devicePixelRatio;
canvas.width = parseFloat(computedStyle.width) * dpr;
canvas.height = parseFloat(computedStyle.height) * dpr;
ctx.scale(dpr, dpr);

const text = '查看更多';
const fontSize = 26;
ctx.font = `${fontSize}px normal`;
const textWidth = ctx.measureText(text).width;
// 这里的 0.75 是因为基线在行框盒子之上,大约是 10%-15%,这里取 12.5%
ctx.fillText(text, (canvas.width / dpr - textWidth) / 2, (canvas.height / dpr + 0.75 * fontSize) / 2);

SVG 实现垂直居中:

<div id="svg-container">
<svg width="200" height="56" viewBox="0 0 200 56">
<text
text-anchor="middle"
fill="currentColor"
x="100"
y="37.75"
font-size="26"
>
查看更多
</text>
</svg>
</div>

为什么移动端的文字这么难以居中,这是因为行高 line-height 导致的,一般而言,当 line-height 大于字体大小时,字体的上下间距应当是平分的,但是在部分机型却不是这样,如 HONOR 20 Pro 中出现上面的距离小于下面的距离。但是当设置 line-height: normal 时,即默认值,会惊讶的发现字体上下间距近乎相等,所以如果使得文字垂直居中,一定不要设置 line-height

上面的前两种方案 line-heightpadding 都设置了 line-height,因此在移动端会偏上。而 display: flex 没有设置 line-height,所以有近乎居中的效果,但是还是有误差,在不同的机型上还是有不同的表现。

通过在不同机型上的对比,发现 Canvas 和 SVG 的方案在各个机型下面均保持一致的居中效果,因此决定将其抽离为一个 React 组件,下面就详细讲解该组件的开发思路。

本文以 SVG 为例,Canvas 开发思路是一样的,在文章的最后给出 Canvas 的实现,供以参考。

使用方法

为实现垂直居中功能,组件需要四个参数

  • height:容器高度,单位为 px
  • fontSize:字体大小,单位为 px
  • text:文字内容
  • color:文字颜色

这四个参数是必须传递的,使用方法如下:

// 使用方式一
<TextMiddle fontSize={28} height={60} color={'deepskyblue'} text={'查看更多'} />

除了上面四个属性,还支持传递 classNamestyle

备注

这里高度和字体大小的单位都是 px,在组件的内部会将其转为 rem

SVG 垂直居中原理

在 SVG 中通过 text 标签来显示文字,可以通过 xy 属性来设置文字的位置,这里我们关注到 y,它设置的是文字基线在 SVG 画布中的位置

<svg width="200" height="60" viewBox="0 0 200 60">
<text font-size="26" fill="currentColor" y="37.75">
查看更多
</text>
</svg>

这里 y 的计算是 y="(height + 0.75 * fontSize) / 2",因为这里 y 指的是文字基线的位置,而基线是比文字的底部要高的,参考下图

字母 x 的底部就是基线的位置,它距离行框盒子底部有一段距离,这个距离与字体大小成比例,与字体大小有关,这个比例不妨设为 r,那么 y 的计算公式为 y = (height + fontSize) / 2 - r * fontSize = (height + (1 - 2r)fontSize) / 2。经调研,比例 r 与字体有关,分布在 10% - 15%,这里我们取 12.5%,所以 y = (height + 0.75 * fontSize) / 2

对于不同的字体,最大的误差为 2.5% 的字体大小,也就是说字体大小 40px 才会有 1px 的误差,移动端的字体大小少有超过 15px 的,最大误差连半个像素都不到,可实现不同平台上的伪居中。

根据上面的原理,可以写出第一版的 TextMiddle 实现

import React, { useEffect, useRef } from "react";

const TextMiddle = props => {
const { height, fontSize, color, text, className = '', style = {} } = props;
const container = useRef();

const actualStyle = {
...style,
height: `${height / 100}rem`,
fontSize: `${fontSize / 100}rem`,
color
};

useEffect(() => {
const computedStyle = window.getComputedStyle(container.current);
const actualHeight = parseFloat(computedStyle.height);
const actualFontSize = parseFloat(computedStyle.fontSize);
const actualWidth = parseFloat(computedStyle.width);

const svg = `
<svg width="${actualWidth}" height="${actualHeight}" viewBox="0 0 ${actualWidth} ${actualHeight}">
<text
font-size="${actualFontSize}"
fill="currentColor"
y="${(actualHeight + 0.75 * actualFontSize) / 2}"
>
${text}
</text>
</svg>
`;
container.current.innerHTML = svg;
}, [text]);

return (
<div ref={container} className={className} style={actualStyle}>
{text}
</div>
)
}

export default TextMiddle;

文字超出

事情到这还没有结束,我们使用 SVG 这种方案是有缺点的,那就是缺失了流动性,文字只能一行展示,不会换行,多余的文字会被隐藏不可见。

考虑在实际开发中,一般都是单行文本实现垂直居中,所以就不对换行进行处理,但是文字超出还是比较常见的,所以有必要对文字超出进行进行处理,这里对超出的文字使用 ... 代替。

怎么判断文字是否超出了呢?我们需要知道文字的长度,如果文字的长度大于容器的长度,就说明超出了,这个时候我们需要进行打点。可以通过 CanvasmeasureText 来获得文本的宽度

const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const getTextLength = (text, fontSize) => {
ctx.font= `${fontSize}px normal`;
const metrics = ctx.measureText(text);
return parseInt('' + metrics.width);
}

这里对文字的长度进行了取整,这是因为在小数部分存在误差,因此我们也需要对容器的长度进行取整,由于取整之后,可能存在 1px 的误差,因此我们需要设置容器的宽度为取整后 +1

const actualWidth = parseInt(computedStyle.width) + 1;
const textLength = getTextLength(text, actualFontSize);
if (textLength > actualWidth) {
// 文字超出处理
}

接下来我们只需要计算超出的文字分界点,然后进行截断

const actualWidth = parseInt(computedStyle.width) + 1;
const textLength = getTextLength(text, actualFontSize);
let actualText = text;
// 计算截断点以及实际文本
if (textLength > actualWidth) {
actualText = text.slice(0, 1) + '...';
for (let i = 1; i < text.length; i++) {
const adjustText = text.slice(0, i) + '...';
const adjustWidth = getTextLength(adjustText, actualFontSize);
if (adjustWidth > actualWidth) {
break;
}
actualText = adjustText;
}
}
const svg = `
<svg width="${actualWidth}" height="${actualHeight}" viewBox="0 0 ${actualWidth} ${actualHeight}">
<text
font-size="${actualFontSize}"
fill="currentColor"
y="${(actualHeight + 0.75 * actualFontSize) / 2}"
>
${actualText}
</text>
</svg>
`;
container.current.innerHTML = svg;

性能优化

当页面上有太多的 SVG 要渲染时,会有较大的白屏时间,经测试,同时有 1000 个 SVG 渲染,将会有接近 1s 的白屏时间,这个是不可接受的,我们可以通过 IntersectionObserver 来进行一个性能优化,只有当该组件即将出现在视口中时,我们才渲染为 SVG。

const svg = `
<svg width="${actualWidth}" height="${actualHeight}" viewBox="0 0 ${actualWidth} ${actualHeight}">
<text
font-size="${actualFontSize}"
fill="currentColor"
y="${(actualHeight + 0.75 * actualFontSize) / 2}"
>
${actualText}
</text>
</svg>
`;
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
// 进入视口时或已经在视口中
if (entry.isIntersecting) {
container.current.innerHTML = svg;
// 取消监听
observer.unobserve(container.current);
}
})
}, {
// 视口范围向下扩大 500px,提前加载
rootMargin: '0px 0px 500px 0px'
});
observer.observe(container.current);

另一个就是对于苹果手机,我们没有必要使用 SVG 的方案,因为苹果手机的兼容性很好,对于苹果手机就使用设置行高等于高度的方案

const isIos = /ipad|iphone|macintosh/gim.test(window.navigator.userAgent);
const actualStyle = {
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
...style,
height: `${height / 100}rem`,
lineHeight: `${height / 100}rem`,
fontSize: `${fontSize / 100}rem`,
color
};

useEffect(() => {
if (isIos) {
return;
}
// ... 其它逻辑
}, []);
危险

不要为 TextMiddle 添加 display: flex;,否则 text-overflow 会失效,除非你确定你的文本一定不会超出容器的宽度。

最终代码

SVG

import React, { useEffect, useRef } from "react";

const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const getTextLength = (text, fontSize) => {
ctx.font= `${fontSize}px normal`;
const metrics = ctx.measureText(text);
return parseInt('' + metrics.width);
}

const TextMiddle = props => {
const { height, fontSize, color, text, className = '', style = {} } = props;
const container = useRef();
const isIos = /ipad|iphone|macintosh/gim.test(window.navigator.userAgent);
const actualStyle = {
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
...style,
height: `${height / 100}rem`,
lineHeight: `${height / 100}rem`,
fontSize: `${fontSize / 100}rem`,
color
};

useEffect(() => {
if (isIos) {
return;
}
const computedStyle = window.getComputedStyle(container.current);
const actualHeight = parseFloat(computedStyle.height);
const actualFontSize = parseFloat(computedStyle.fontSize);
const actualWidth = parseInt(computedStyle.width) + 1;
const textLength = getTextLength(text, actualFontSize);
let actualText = text;
if (textLength > actualWidth) {
actualText = text.slice(0, 1) + '...';
for (let i = 1; i < text.length; i++) {
const adjustText = text.slice(0, i) + '...';
const adjustWidth = getTextLength(adjustText, actualFontSize);
if (adjustWidth > actualWidth) {
break;
}
actualText = adjustText;
}
}

const svg = `
<svg width="${actualWidth}" height="${actualHeight}" viewBox="0 0 ${actualWidth} ${actualHeight}">
<text
font-size="${actualFontSize}"
fill="currentColor"
y="${(actualHeight + 0.75 * actualFontSize) / 2}"
>
${actualText}
</text>
</svg>
`;
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
// 进入视口时或已经在视口中
if (entry.isIntersecting) {
container.current.innerHTML = svg;
observer.unobserve(container.current);
}
})
}, {
// 视口范围向下扩大 500px,提前加载
rootMargin: '0px 0px 500px 0px'
});
observer.observe(container.current);
}, [text]);

return (
<div ref={container} className={className} style={actualStyle}>
{text}
</div>
)
}

export default TextMiddle;

Canvas

import React, { useEffect, useRef } from "react";

const isIos = /ipad|iphone|macintosh/gim.test(window.navigator.userAgent);

const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const getTextLength = (text, fontSize) => {
ctx.font= `${fontSize}px normal`;
const metrics = ctx.measureText(text);
return parseInt('' + metrics.width);
}

const TextMiddle = props => {
const { text, color, height, fontSize, className = '', style = {} } = props;
const container = useRef();

const actualStyle = {
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
...style,
height: `${height / 100}rem`,
lineHeight: `${height / 100}rem`,
fontSize: `${fontSize / 100}rem`,
color
};

useEffect(() => {
if (isIos) {
return;
}

const computedStyle = window.getComputedStyle(container.current);
const actualHeight = parseFloat(computedStyle.height);
const actualFontSize = parseFloat(computedStyle.fontSize);
const actualWidth = parseInt(computedStyle.width) + 1;

// 高分屏模糊处理
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.style.width = actualWidth + 'px';
canvas.style.height = actualHeight + 'px';
const dpr = window.devicePixelRatio;
canvas.width = actualWidth * dpr;
canvas.height = actualHeight * dpr;
ctx.scale(dpr, dpr);

// 文字超出处理
const textLength = getTextLength(text, actualFontSize);
let actualText = text;
if (textLength > actualWidth) {
actualText = text.slice(0, 1) + '...';
for (let i = 1; i < text.length; i++) {
const adjustText = text.slice(0, i) + '...';
const adjustWidth = getTextLength(adjustText, actualFontSize);
if (adjustWidth > actualWidth) {
break;
}
actualText = adjustText;
}
}

ctx.font = `${actualFontSize}px normal`;
ctx.fillStyle = color;
ctx.textBaseline = 'bottom';
ctx.fillText(actualText, 0, (canvas.height / dpr + 0.75 * actualFontSize) / 2);

const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
container.current.removeChild(container.current.firstChild);
container.current.append(canvas);
observer.unobserve(container.current);
}
});
}, {
rootMargin: '0px 0px 500px 0px'
});
observer.observe(container.current);
}, [text, color]);

return (
<div ref={container} className={className} style={actualStyle}>
{text}
</div>
)
}

export default TextMiddle;