做一个运动数据 App,首页要画圆环进度(今日步数完成度)、折线图(一周心率趋势)、SVG 路径(跑步轨迹)。用 PNG 画图表放大模糊,用三方图表库太重——
@react-native-ohos/react-native-svg直接用 Path/Circle/渐变画矢量图形,任意缩不失真。
📦 仓库地址:gitcode.com/CPF-RN/rntp… | 安装:
npm install @react-native-ohos/react-native-svg
写在前面
"步道"是一个运动数据可视化 App,首页 Dashboard 要展示:
- 步数进度环:今日 8234/10000 步,圆环填充 82%
- 心率折线图:最近 7 天静息心率趋势
- 跑步轨迹:GPS 路径用 SVG Path 绘制在地图叠加层上
- 卡路里仪表盘:半圆仪表盘 + 渐变填充
这些图形的共同特点:随数据动态变化、需要矢量精度、要用渐变和动画。PNG 图方案直接出局——放大模糊、不能动态变化。用 react-native-chart 库又太重,定制困难。
react-native-svg 让你像写 HTML SVG 一样在 RN 里画矢量图形——Path/Circle/Rect/Line/LinearGradient 全套元素,原生层渲染,任意缩放不失真,还能和 Reanimated 联动做动画。
这篇文章聊什么
- 步数进度环——Circle + strokeDashoffset 动画
- 心率折线图——动态生成 Path d 属性
- 卡路里仪表盘——渐变半圆 + 弧线
flowchart TD
A[运动数据 API] --> B{图表类型}
B -->|进度环| C[Circle + strokeDashoffset]
B -->|折线图| D[Path d 属性动态生成]
B -->|仪表盘| E[Arc Path + LinearGradient]
B -->|轨迹| F[GPS 坐标 → SVG Path]
C --> G[Reanimated 驱动动画]
D --> G
E --> G
F --> H[原生渲染矢量图]
G --> H
第一步:安装
npm install @react-native-ohos/react-native-svg
第二步:步数进度环
用 Circle 的 strokeDasharray + strokeDashoffset 实现圆环进度:
<span>import</span> <span>Svg</span>, { <span>Circle</span>, <span>Defs</span>, <span>LinearGradient</span>, <span>Stop</span> } <span>from</span> <span>'react-native-svg'</span>
<span>import</span> { useEffect } <span>from</span> <span>'react'</span>
<span>const</span> <span>RADIUS</span> = <span>70</span>
<span>const</span> <span>CIRCUMFERENCE</span> = <span>2</span> * <span>Math</span>.<span>PI</span> * <span>RADIUS</span> <span>// 周长</span>
<span>const</span> <span>TARGET</span> = <span>10000</span>
<span>const</span> <span>CURRENT</span> = <span>8234</span>
<span>const</span> <span>PERCENT</span> = <span>CURRENT</span> / <span>TARGET</span>
<span>function</span> <span>StepProgressRing</span>(<span></span>) {
<span>const</span> offset = <span>CIRCUMFERENCE</span> * (<span>1</span> - <span>PERCENT</span>) <span>// 未完成部分的长度</span>
<span>return</span> (
<span><span><<span>Svg</span> <span>width</span>=<span>{180}</span> <span>height</span>=<span>{180}</span>></span>
<span><<span>Defs</span>></span>
<span><<span>LinearGradient</span> <span>id</span>=<span>"ringGrad"</span> <span>x1</span>=<span>"0%"</span> <span>y1</span>=<span>"0%"</span> <span>x2</span>=<span>"100%"</span> <span>y2</span>=<span>"100%"</span>></span>
<span><<span>Stop</span> <span>offset</span>=<span>"0%"</span> <span>stopColor</span>=<span>"#4ECDC4"</span> /></span>
<span><<span>Stop</span> <span>offset</span>=<span>"100%"</span> <span>stopColor</span>=<span>"#45B7D1"</span> /></span>
<span></<span>LinearGradient</span>></span>
<span></<span>Defs</span>></span>
{/* 背景环 */}
<span><<span>Circle</span>
<span>cx</span>=<span>{90}</span> <span>cy</span>=<span>{90}</span> <span>r</span>=<span>{RADIUS}</span>
<span>stroke</span>=<span>"#F0F0F0"</span> <span>strokeWidth</span>=<span>{12}</span>
<span>fill</span>=<span>"none"</span>
/></span>
{/* 进度环(渐变描边) */}
<span><<span>Circle</span>
<span>cx</span>=<span>{90}</span> <span>cy</span>=<span>{90}</span> <span>r</span>=<span>{RADIUS}</span>
<span>stroke</span>=<span>"url(#ringGrad)"</span> <span>strokeWidth</span>=<span>{12}</span>
<span>fill</span>=<span>"none"</span>
<span>strokeLinecap</span>=<span>"round"</span>
// <span>旋转</span> <span>-90</span> <span>度让进度从顶部开始</span>
<span>transform</span>=<span>"rotate(-90, 90, 90)"</span>
<span>strokeDasharray</span>=<span>{CIRCUMFERENCE}</span> // <span>虚线长度</span>=<span>周长</span>
<span>strokeDashoffset</span>=<span>{offset}</span> // <span>偏移</span>=<span>未完成部分</span>
/></span>
{/* 中心文字 */}
<span><<span>SvgText</span> <span>x</span>=<span>{90}</span> <span>y</span>=<span>{85}</span> <span>textAnchor</span>=<span>"middle"</span> <span>fontSize</span>=<span>{32}</span> <span>fontWeight</span>=<span>"bold"</span> <span>fill</span>=<span>"#333"</span>></span>
{CURRENT.toLocaleString()}
<span></<span>SvgText</span>></span>
<span><<span>SvgText</span> <span>x</span>=<span>{90}</span> <span>y</span>=<span>{105}</span> <span>textAnchor</span>=<span>"middle"</span> <span>fontSize</span>=<span>{14}</span> <span>fill</span>=<span>"#999"</span>></span>
/ {TARGET.toLocaleString()} 步
<span></<span>SvgText</span>></span>
<span></<span>Svg</span>></span></span>
)
}
strokeDasharray={CIRCUMFERENCE} + strokeDashoffset={offset} 是 SVG 进度环的经典技巧——虚线长度等于圆周长,偏移量等于未完成部分,调整 offset 就能控制进度。
第三步:心率折线图
根据心率数据动态生成 Path 的 d 属性:
<span>import</span> <span>Svg</span>, { <span>Path</span>, <span>Line</span>, <span>Text</span> <span>as</span> <span>SvgText</span> } <span>from</span> <span>'react-native-svg'</span>
<span>const</span> <span>HEART_RATES</span> = [<span>62</span>, <span>65</span>, <span>61</span>, <span>58</span>, <span>60</span>, <span>63</span>, <span>59</span>] <span>// 最近 7 天静息心率</span>
<span>const</span> <span>WIDTH</span> = <span>300</span>
<span>const</span> <span>HEIGHT</span> = <span>120</span>
<span>const</span> <span>PADDING</span> = <span>20</span>
<span>function</span> <span>HeartRateChart</span>(<span></span>) {
<span>const</span> min = <span>Math</span>.<span>min</span>(...<span>HEART_RATES</span>) - <span>5</span>
<span>const</span> max = <span>Math</span>.<span>max</span>(...<span>HEART_RATES</span>) + <span>5</span>
<span>const</span> range = max - min
<span>// 生成 Path d 属性:M x0 y0 L x1 y1 L x2 y2 ...</span>
<span>const</span> points = <span>HEART_RATES</span>.<span>map</span>(<span>(<span>hr, i</span>) =></span> {
<span>const</span> x = <span>PADDING</span> + (i / (<span>HEART_RATES</span>.<span>length</span> - <span>1</span>)) * (<span>WIDTH</span> - <span>2</span> * <span>PADDING</span>)
<span>const</span> y = <span>HEIGHT</span> - <span>PADDING</span> - ((hr - min) / range) * (<span>HEIGHT</span> - <span>2</span> * <span>PADDING</span>)
<span>return</span> <span>`<span>${i === <span>0</span> ? <span>'M'</span> : <span>'L'</span>}</span> <span>${x}</span> <span>${y}</span>`</span>
})
<span>const</span> linePath = points.<span>join</span>(<span>' '</span>)
<span>// 填充区域路径(折线下方填充)</span>
<span>const</span> fillPath = <span>`<span>${linePath}</span> L <span>${WIDTH - PADDING}</span> <span>${HEIGHT - PADDING}</span> L <span>${PADDING}</span> <span>${HEIGHT - PADDING}</span> Z`</span>
<span>return</span> (
<span><span><<span>Svg</span> <span>width</span>=<span>{WIDTH}</span> <span>height</span>=<span>{HEIGHT}</span>></span>
{/* 填充区域 */}
<span><<span>Path</span> <span>d</span>=<span>{fillPath}</span> <span>fill</span>=<span>"#4ECDC4"</span> <span>opacity</span>=<span>{0.1}</span> /></span>
{/* 折线 */}
<span><<span>Path</span> <span>d</span>=<span>{linePath}</span> <span>stroke</span>=<span>"#4ECDC4"</span> <span>strokeWidth</span>=<span>{2}</span> <span>fill</span>=<span>"none"</span> /></span>
{/* 数据点 */}
{HEART_RATES.map((hr, i) => {
const x = PADDING + (i / (HEART_RATES.length - 1)) * (WIDTH - 2 * PADDING)
const y = HEIGHT - PADDING - ((hr - min) / range) * (HEIGHT - 2 * PADDING)
return <span><<span>Circle</span> <span>key</span>=<span>{i}</span> <span>cx</span>=<span>{x}</span> <span>cy</span>=<span>{y}</span> <span>r</span>=<span>{3}</span> <span>fill</span>=<span>"#4ECDC4"</span> /></span>
})}
<span></<span>Svg</span>></span></span>
)
}
全程用 Path/Circle 原生元素——矢量渲染,在高 DPI 鸿蒙屏幕上线条清晰锐利,不存在 PNG 放大模糊的问题。
第四步:卡路里仪表盘(渐变弧线)
半圆仪表盘,用渐变弧线填充:
<span>function</span> <span>CalorieGauge</span>(<span>{ value, max }: { value: <span>number</span>; max: <span>number</span> }</span>) {
<span>const</span> percent = <span>Math</span>.<span>min</span>(value / max, <span>1</span>)
<span>const</span> startAngle = <span>180</span> <span>// 左侧</span>
<span>const</span> endAngle = <span>180</span> + percent * <span>180</span> <span>// 右侧</span>
<span>// 计算弧线路径</span>
<span>const</span> cx = <span>100</span>, cy = <span>100</span>, r = <span>70</span>
<span>const</span> startX = cx + r * <span>Math</span>.<span>cos</span>((startAngle * <span>Math</span>.<span>PI</span>) / <span>180</span>)
<span>const</span> startY = cy + r * <span>Math</span>.<span>sin</span>((startAngle * <span>Math</span>.<span>PI</span>) / <span>180</span>)
<span>const</span> endX = cx + r * <span>Math</span>.<span>cos</span>((endAngle * <span>Math</span>.<span>PI</span>) / <span>180</span>)
<span>const</span> endY = cy + r * <span>Math</span>.<span>sin</span>((endAngle * <span>Math</span>.<span>PI</span>) / <span>180</span>)
<span>const</span> arcPath = <span>`M <span>${startX}</span> <span>${startY}</span> A <span>${r}</span> <span>${r}</span> 0 0 1 <span>${endX}</span> <span>${endY}</span>`</span>
<span>return</span> (
<span><span><<span>Svg</span> <span>width</span>=<span>{200}</span> <span>height</span>=<span>{120}</span>></span>
<span><<span>Defs</span>></span>
<span><<span>LinearGradient</span> <span>id</span>=<span>"gaugeGrad"</span> <span>x1</span>=<span>"0%"</span> <span>y1</span>=<span>"0%"</span> <span>x2</span>=<span>"100%"</span> <span>y2</span>=<span>"0%"</span>></span>
<span><<span>Stop</span> <span>offset</span>=<span>"0%"</span> <span>stopColor</span>=<span>"#FFB344"</span> /></span>
<span><<span>Stop</span> <span>offset</span>=<span>"100%"</span> <span>stopColor</span>=<span>"#FF6B6B"</span> /></span>
<span></<span>LinearGradient</span>></span>
<span></<span>Defs</span>></span>
{/* 背景弧 */}
<span><<span>Path</span> <span>d</span>=<span>{</span>`<span>M</span> ${<span>cx</span> <span>-</span> <span>r</span>} ${<span>cy</span>} <span>A</span> ${<span>r</span>} ${<span>r</span>} <span>0</span> <span>0</span> <span>1</span> ${<span>cx</span> + <span>r</span>} ${<span>cy</span>}`} <span>stroke</span>=<span>"#F0F0F0"</span> <span>strokeWidth</span>=<span>{14}</span> <span>fill</span>=<span>"none"</span> <span>strokeLinecap</span>=<span>"round"</span> /></span>
{/* 进度弧(渐变) */}
<span><<span>Path</span> <span>d</span>=<span>{arcPath}</span> <span>stroke</span>=<span>"url(#gaugeGrad)"</span> <span>strokeWidth</span>=<span>{14}</span> <span>fill</span>=<span>"none"</span> <span>strokeLinecap</span>=<span>"round"</span> /></span>
<span><<span>SvgText</span> <span>x</span>=<span>{cx}</span> <span>y</span>=<span>{cy</span> <span>-</span> <span>10</span>} <span>textAnchor</span>=<span>"middle"</span> <span>fontSize</span>=<span>{28}</span> <span>fontWeight</span>=<span>"bold"</span> <span>fill</span>=<span>"#333"</span>></span>
{value}
<span></<span>SvgText</span>></span>
<span><<span>SvgText</span> <span>x</span>=<span>{cx}</span> <span>y</span>=<span>{cy</span> + <span>10</span>} <span>textAnchor</span>=<span>"middle"</span> <span>fontSize</span>=<span>{12}</span> <span>fill</span>=<span>"#999"</span>></span>kcal<span></<span>SvgText</span>></span>
<span></<span>Svg</span>></span></span>
)
}
为什么"步道"选了 SVG?
| 需求 | PNG 图片 | 三方图表库 | react-native-svg |
|---|---|---|---|
| 动态进度环 | ❌ 不能变 | ✅ | ✅ |
| 折线图定制 | ❌ | ⚠️ 样式有限 | ✅ 全自定义 |
| 渐变填充 | ❌ | ⚠️ | ✅ |
| 矢量不失真 | ❌ 放大模糊 | ✅ | ✅ |
| 轻量 | ✅ | ❌ 重 | ✅ |
| 动画联动 | ❌ | ⚠️ | ✅ Reanimated |
总结
"步道"这个场景里,@react-native-ohos/react-native-svg 解决了三件事:
- 进度环——Circle + strokeDashoffset,渐变描边 + 动画驱动
- 折线图——动态生成 Path d 属性,矢量渲染不模糊
- 仪表盘——Arc Path + LinearGradient,渐变弧线精确到角度
如果你也需要在 RN 鸿蒙应用里画矢量图形——进度环、自定义图表、GPS 轨迹、仪表盘——react-native-svg 是最轻量又最灵活的选择。
选型对比清晰,把进度环、折线图、仪表盘三类高频图表讲透,适合做鸿蒙运动健康App、需要自定义矢量图表的开发者参考。