做移动端开发的时候,经常遇到一个问题:界面上有个图标或者按钮,用户不知道是干嘛的。
PC 端好办,鼠标悬停显示个提示就行。移动端没有鼠标,怎么办?
答案是长按。用户长按某个元素,弹出一个小气泡告诉他这是什么。松手气泡消失,不影响正常操作。
今天我们来实现这个 Tooltip 组件。
完整源码一览
先把代码完整贴出来,文件位置 src/components/ui/Tooltip.tsx:
tsx
import React, { useState } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, ViewStyle } from 'react-native';
import { UITheme } from './theme';
interface TooltipProps {
content: string;
children: React.ReactNode;
position?: 'top' | 'bottom' | 'left' | 'right';
variant?: 'dark' | 'light';
style?: ViewStyle;
}
export const Tooltip: React.FC<TooltipProps> = ({
content,
children,
position = 'top',
variant = 'dark',
style,
}) => {
const [visible, setVisible] = useState(false);
const getPositionStyle = (): ViewStyle => {
switch (position) {
case 'top':
return { bottom: '100%', left: '50%', marginBottom: 8 };
case 'bottom':
return { top: '100%', left: '50%', marginTop: 8 };
case 'left':
return { right: '100%', top: '50%', marginRight: 8 };
case 'right':
return { left: '100%', top: '50%', marginLeft: 8 };
default:
return {};
}
};
const bgColor = variant === 'dark' ? UITheme.colors.gray[800] : UITheme.colors.white;
const textColor = variant === 'dark' ? UITheme.colors.white : UITheme.colors.gray[800];
return (
<View style={[styles.container, style]}>
<TouchableOpacity
onPressIn={() => setVisible(true)}
onPressOut={() => setVisible(false)}
activeOpacity={1}
>
{children}
</TouchableOpacity>
{visible && (
<View
style={[
styles.tooltip,
getPositionStyle(),
{ backgroundColor: bgColor },
variant === 'light' && styles.lightShadow,
]}
>
<Text style={[styles.text, { color: textColor }]}>{content}</Text>
</View>
)}
</View>
);
};
const styles = StyleSheet.create({
container: { position: 'relative' },
tooltip: {
position: 'absolute',
paddingHorizontal: UITheme.spacing.md,
paddingVertical: UITheme.spacing.sm,
borderRadius: UITheme.borderRadius.sm,
zIndex: 1000,
},
lightShadow: { ...UITheme.shadow.md, borderWidth: 1, borderColor: UITheme.colors.gray[200] },
text: { fontSize: UITheme.fontSize.sm, whiteSpace: 'nowrap' },
});
60 行代码,一个完整的 Tooltip。下面我们一步步拆解。
Step 1:引入依赖
tsx
import React, { useState } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, ViewStyle } from 'react-native';
import { UITheme } from './theme';
这里有个关键点:useState。
Tooltip 需要记住"当前是否显示"这个状态。用户按下去,状态变成 true,气泡出现;用户松手,状态变成 false,气泡消失。
TouchableOpacity 是 React Native 提供的可触摸组件,它能监听按下和松开事件,正好满足我们的需求。
Step 2:定义属性接口
tsx
interface TooltipProps {
content: string;
children: React.ReactNode;
position?: 'top' | 'bottom' | 'left' | 'right';
variant?: 'dark' | 'light';
style?: ViewStyle;
}
五个属性,各有用途:
content 是提示文字,必填。没有提示内容,Tooltip 就没有存在的意义。
children 是被包裹的元素,也是必填。Tooltip 是个"包装器",它需要知道包装谁。
position 控制气泡出现的位置。上下左右四个方向,默认在上方。为什么默认上方?因为用户的手指按住元素时,通常会遮住下方,气泡显示在上方不容易被挡住。
variant 是视觉风格。dark 是深色背景浅色文字,light 是浅色背景深色文字。深色更醒目,浅色更柔和。
style 用于外部样式覆盖,标准操作。
Step 3:组件函数签名和状态
tsx
export const Tooltip: React.FC<TooltipProps> = ({
content,
children,
position = 'top',
variant = 'dark',
style,
}) => {
const [visible, setVisible] = useState(false);
解构赋值的同时设置默认值,这是 React 组件的常见写法。
visible 状态控制气泡的显示隐藏。初始值 false,气泡默认不显示。
为什么用 useState 而不是 useRef?因为 visible 的变化需要触发重新渲染。useRef 的变化不会触发渲染,气泡就不会出现。
Step 4:位置计算函数
tsx
const getPositionStyle = (): ViewStyle => {
switch (position) {
case 'top':
return { bottom: '100%', left: '50%', marginBottom: 8 };
case 'bottom':
return { top: '100%', left: '50%', marginTop: 8 };
case 'left':
return { right: '100%', top: '50%', marginRight: 8 };
case 'right':
return { left: '100%', top: '50%', marginLeft: 8 };
default:
return {};
}
};
这个函数根据 position 返回不同的定位样式。
position: 'top' 时,气泡在触发元素上方:
bottom: '100%'让气泡的底边对齐触发元素的顶边left: '50%'让气泡水平居中(大致居中,精确居中需要额外处理)marginBottom: 8让气泡和触发元素之间有 8px 间距
position: 'bottom' 时,逻辑相反:
top: '100%'让气泡的顶边对齐触发元素的底边marginTop: 8提供间距
position: 'left' 和 position: 'right' 是水平方向的定位:
- left 用
right: '100%',气泡在左边 - right 用
left: '100%',气泡在右边 top: '50%'让气泡垂直居中
这种百分比定位是相对于父元素的,所以父元素需要设置 position: 'relative'。
Step 5:颜色计算
tsx
const bgColor = variant === 'dark' ? UITheme.colors.gray[800] : UITheme.colors.white;
const textColor = variant === 'dark' ? UITheme.colors.white : UITheme.colors.gray[800];
两行代码,两个三元表达式。
dark 模式:深灰背景 + 白色文字
light 模式:白色背景 + 深灰文字
为什么不直接写在 style 里?因为这两个值在 JSX 里要用两次(背景色和文字色),提取成变量更清晰。
Step 6:渲染结构
tsx
return (
<View style={[styles.container, style]}>
<TouchableOpacity
onPressIn={() => setVisible(true)}
onPressOut={() => setVisible(false)}
activeOpacity={1}
>
{children}
</TouchableOpacity>
{visible && (
<View
style={[
styles.tooltip,
getPositionStyle(),
{ backgroundColor: bgColor },
variant === 'light' && styles.lightShadow,
]}
>
<Text style={[styles.text, { color: textColor }]}>{content}</Text>
</View>
)}
</View>
);
结构分三层来看:
最外层 View
styles.container 设置了 position: 'relative',这是绝对定位的基础。气泡用绝对定位,它需要一个相对定位的父元素作为参照。
TouchableOpacity 触摸层
包裹 children,监听触摸事件。
onPressIn 在手指按下时触发,设置 visible 为 true。
onPressOut 在手指抬起时触发,设置 visible 为 false。
activeOpacity={1} 禁用了按下时的透明度变化。默认情况下 TouchableOpacity 按下会变半透明,但 Tooltip 的触发元素不需要这个反馈,所以设为 1(完全不透明)。
气泡层
{visible && ...} 是条件渲染,只有 visible 为 true 时才渲染气泡。
样式数组里有四项:
styles.tooltip基础样式getPositionStyle()位置样式{ backgroundColor: bgColor }背景色variant === 'light' && styles.lightShadow浅色模式的阴影
第四项用了短路求值,只有 light 模式才会加上阴影样式。
Step 7:样式定义
tsx
const styles = StyleSheet.create({
container: { position: 'relative' },
tooltip: {
position: 'absolute',
paddingHorizontal: UITheme.spacing.md,
paddingVertical: UITheme.spacing.sm,
borderRadius: UITheme.borderRadius.sm,
zIndex: 1000,
},
lightShadow: { ...UITheme.shadow.md, borderWidth: 1, borderColor: UITheme.colors.gray[200] },
text: { fontSize: UITheme.fontSize.sm, whiteSpace: 'nowrap' },
});
container
只有一个属性 position: 'relative',为气泡的绝对定位提供参照。
tooltip
position: 'absolute' 让气泡脱离文档流,可以定位到任意位置。
paddingHorizontal 和 paddingVertical 给文字留出呼吸空间。
borderRadius 让气泡有圆角,看起来更柔和。
zIndex: 1000 确保气泡显示在其他元素上方。这个值要足够大,不然可能被其他元素遮住。
lightShadow
浅色模式需要阴影来和背景区分开。...UITheme.shadow.md 展开主题里的阴影配置,再加上细边框增强边界感。
text
whiteSpace: 'nowrap' 防止文字换行。Tooltip 的内容通常很短,换行会让气泡变得很奇怪。
Demo 实战
看完实现,来看看怎么用。文件位置 src/screens/demos/TooltipDemo.tsx。
基础用法
tsx
<Tooltip content="这是一条提示信息">
<Button title="长按我" size="sm" />
</Tooltip>
最简单的用法:用 Tooltip 包裹一个按钮,设置 content。用户长按按钮,提示信息就会出现。
Tooltip 是个"高阶组件"的思路------它不改变被包裹元素的外观和行为,只是给它增加了"长按显示提示"的能力。
四个方向
tsx
<Tooltip content="顶部提示" position="top">
<Button title="上" size="sm" variant="outline" />
</Tooltip>
<Tooltip content="底部提示" position="bottom">
<Button title="下" size="sm" variant="outline" />
</Tooltip>
<Tooltip content="左侧提示" position="left">
<Button title="左" size="sm" variant="outline" />
</Tooltip>
<Tooltip content="右侧提示" position="right">
<Button title="右" size="sm" variant="outline" />
</Tooltip>
四个按钮,四个方向。实际使用时要根据元素在页面中的位置选择合适的方向:
- 元素靠近顶部,用 bottom
- 元素靠近底部,用 top
- 元素靠近左边,用 right
- 元素靠近右边,用 left
原则是让气泡有足够的空间显示,不要超出屏幕。
深色和浅色
tsx
<Tooltip content="深色提示" variant="dark">
<Button title="深色" size="sm" />
</Tooltip>
<Tooltip content="浅色提示" variant="light" style={styles.ml}>
<Button title="浅色" size="sm" variant="outline" />
</Tooltip>
深色气泡在浅色背景上更醒目,浅色气泡在深色背景上更合适。
大多数情况下用深色就行,它的对比度更高,更容易被注意到。
包裹文字
tsx
<Text style={styles.text}>
这是一段文字,其中
<Tooltip content="这是一个专业术语的解释">
<Text style={styles.highlight}>专业术语</Text>
</Tooltip>
需要额外说明。
</Text>
Tooltip 不只能包裹按钮,也能包裹文字。
这个例子里,"专业术语"四个字被高亮显示(蓝色+下划线),用户长按就能看到解释。这种用法在文档类应用里很常见。
样式定义:
tsx
highlight: { color: UITheme.colors.primary, textDecorationLine: 'underline' },
蓝色文字加下划线,暗示"这里有额外信息"。
包裹图标
tsx
<Tooltip content="设置">
<Text style={styles.icon}>⚙️</Text>
</Tooltip>
<Tooltip content="帮助" style={styles.ml}>
<Text style={styles.icon}>❓</Text>
</Tooltip>
<Tooltip content="通知" style={styles.ml}>
<Text style={styles.icon}>🔔</Text>
</Tooltip>
图标按钮是 Tooltip 最典型的使用场景。
图标的含义不是所有人都能一眼看懂,加上 Tooltip 可以降低用户的认知负担。特别是一些不太常见的图标,Tooltip 几乎是必须的。
几个可以改进的地方
这个 Tooltip 实现了核心功能,但还有优化空间:
气泡没有箭头
很多 Tooltip 设计会在气泡上加一个小三角形,指向触发元素。这需要额外的 View 和一些三角形的 CSS 技巧。
居中不够精确
left: '50%' 只是让气泡的左边缘在中点,气泡本身并没有居中。精确居中需要知道气泡的宽度,然后用 transform: translateX(-50%),但 React Native 对 transform 的百分比支持有限。
没有边界检测
如果触发元素在屏幕边缘,气泡可能会超出屏幕。完善的实现需要检测边界,自动调整位置。
动画缺失
气泡是瞬间出现和消失的,加上淡入淡出动画会更优雅。
这些都是可以后续迭代的方向。
使用建议
Tooltip 虽然好用,但不要滥用:
-
内容要简短。Tooltip 不是用来放大段文字的,一句话说清楚最好。
-
不要放关键信息。用户可能不会长按,所以 Tooltip 里的内容应该是"锦上添花"而不是"必须知道"。
-
触发元素要有暗示。用户怎么知道这个元素可以长按?通过视觉暗示,比如下划线、问号图标、或者和其他元素不同的样式。
-
位置要合理。别让气泡被手指挡住,也别让它超出屏幕。
实际项目中的应用
来看几个真实场景的代码:
表单字段说明
tsx
const FormField = () => (
<View style={styles.field}>
<View style={styles.labelRow}>
<Text style={styles.label}>手机号</Text>
<Tooltip content="用于接收验证码和找回密码">
<Text style={styles.helpIcon}>ⓘ</Text>
</Tooltip>
</View>
<Input placeholder="请输入手机号" />
</View>
);
表单标签旁边放一个小图标,用户不确定为什么要填这个字段时,长按图标就能看到说明。
功能按钮提示
tsx
const Toolbar = () => (
<View style={styles.toolbar}>
<Tooltip content="撤销上一步操作" position="bottom">
<TouchableOpacity style={styles.toolBtn}>
<Text>↩️</Text>
</TouchableOpacity>
</Tooltip>
<Tooltip content="重做已撤销的操作" position="bottom">
<TouchableOpacity style={styles.toolBtn}>
<Text>↪️</Text>
</TouchableOpacity>
</Tooltip>
<Tooltip content="保存当前内容" position="bottom">
<TouchableOpacity style={styles.toolBtn}>
<Text>💾</Text>
</TouchableOpacity>
</Tooltip>
</View>
);
工具栏的图标按钮,每个都加上 Tooltip。因为工具栏通常在顶部,所以 position 设为 bottom。
数据指标解释
tsx
const StatsCard = ({ value, label, explanation }) => (
<View style={styles.statsCard}>
<Text style={styles.statsValue}>{value}</Text>
<View style={styles.statsLabelRow}>
<Text style={styles.statsLabel}>{label}</Text>
{explanation && (
<Tooltip content={explanation}>
<Text style={styles.questionMark}>?</Text>
</Tooltip>
)}
</View>
</View>
);
// 使用
<StatsCard
value="85%"
label="转化率"
explanation="访问用户中完成购买的比例"
/>
数据看板里的指标,有些用户可能不理解是什么意思。加个问号图标,长按显示解释。
禁用按钮说明
tsx
const SubmitButton = ({ disabled, disabledReason }) => {
if (disabled) {
return (
<Tooltip content={disabledReason}>
<View style={styles.disabledButton}>
<Text style={styles.disabledText}>提交</Text>
</View>
</Tooltip>
);
}
return (
<Button title="提交" onPress={handleSubmit} />
);
};
// 使用
<SubmitButton
disabled={!isFormValid}
disabledReason="请先填写所有必填项"
/>
按钮被禁用时,用户可能不知道为什么不能点。用 Tooltip 告诉他原因,比单纯的灰色按钮友好得多。
回顾
Tooltip 组件的核心是三件事:
- 用
useState管理显示状态 - 用
TouchableOpacity的onPressIn和onPressOut触发状态变化 - 用绝对定位把气泡放到正确的位置
代码不多,但把移动端"长按提示"这个交互模式封装得很好。下次遇到"用户可能不知道这是什么"的场景,试试 Tooltip。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
