在SwiftUI应用中,如果Xcode项目支持iOS16+,推荐使用SwiftUI原生的Charts图表进行绘制。
在iOS15版本中,可以考虑使用Charts库或SwiftUICharts库绘制图表,它支持iOS13+,提供折线图、柱状图等多种图表类型。但因为学习成本比较高,显示的图表效果达不到我的预期,因此考虑使用Path和GeometryReader绘制折线图。
下图的折线图是预期的效果。

下面将使用Path和GeometryReader绘制折线图。
简单折线图
通过下面的代码,可以实现折线图的绘制。
struct ExchangeRateChart: View {
var datas: [Double] = [0.22,0.24,0.36,0.27,0.33,0.5,0.44]
var body: some View {
GeometryReader { geo in
let width = geo.size.width
let height = geo.size.height
// 计算最大和最小值
let dataMax = datas.max() ?? 0
let dataMin = datas.min() ?? 0
Path { path in
for (index,data) in datas.enumerated() {
// X轴的各点
let xPosition = CGFloat(index) / CGFloat(datas.count - 1) * width
// Y轴的数值 - 从底部开始计算,减去归一化的值
let normalizedValue = (data - dataMin) / (dataMax - dataMin)
let yPosition = height - (normalizedValue * height)
if index == 0 {
path.move(to: CGPoint(x: xPosition, y: yPosition))
} else {
path.addLine(to: CGPoint(x: xPosition, y: yPosition))
}
}
}
.stroke(Color.gray, lineWidth: 5)
}
}
}

代码解析
datas是图表需要显示的数据源,通过GeometryReader获取到视图的高度和宽度,通过Path绘制具体的位置。
1、使用max()和min()获取到datas数组中的最大值和最小值,使用Path对各点的坐标进行绘制,使用enumerated获取索引。
2、Path中计算x坐标:
let xPosition = CGFloat(index) / CGFloat(datas.count - 1) * width
这个代码的含义是如果是第0个元素,x坐标就是0,如果是最后一个元素,index是6,datas.count – 1也是6,最后返回的就是width。
因此,这里实际是将width作等分。

这里可能疑惑的点就是,数组是7个元素,index是0-6,数组长度 – 1 也是6,看起来6和7不一致。这是因为第一个元素是0,所有第二个元素就是1/6,第三个元素是2/6,第7个元素就是6/6,所以各点是一一对应的。
3、Path中计算y轴坐标:
// Y轴的数值 - 从底部开始计算,减去归一化的值
let normalizedValue = (data - dataMin) / (dataMax - dataMin)
let yPosition = height - (normalizedValue * height)
y轴首先计算的是标准化值,data – dataMin表示当前数据 – 最低数值的差。

假设当前数据是0.36,0.36 – 0.22(最小值),就是0.14,这里的0.14表示当前数值距离最小值的差额。
dataMax – dataMin表示最大值和最小值的差额,0.5(最大值)– 0.22(最小值),就是0.28。0.28就是整个图表的范围。
(data – dataMin) / (dataMax – dataMin),实际上就是当前数据的差额 / 最大数据的差额。返回的就是当前数据的占比。
如果不理解占比的话,可以将数据再变化一下,假设当前最大值是100,最小值是0,当前的数值为30,那么 (30 – 0) / (100 – 0) = 0.3,这里的0.3就是当前数值在整个图表中的占比,即高度为30%。
接着需要将占比转换成具体的高度,返回到前面的数值,normalizedValue经过计算得到的结果为0.5。0.5 * height,这里假设height = 100,返回的结果就是 50。
因此,y轴就是 50,但是这里的50指的是从底部向上的50%,因为SwiftUI的图表是从左上角开始计算的,所以当绘制点位时,y轴的高度实际上是从上往下绘制。
所以还需要height – 计算的高度,得到50。

上图的坐标就是y轴从上往下绘制,所以当y轴为50时,height – 50返回50。
如果y的占比为0.3,100 * 0.3 = 30,高度为30,但因为y轴从上往下绘制,所以是height – 30 = 70,y轴的点应该是70才能正确的绘制点位。
4、Path的绘制
当index即第一个点位时,使用path.move创建点位,后面的数值使用path.addLine添加线段。
if index == 0 {
path.move(to: CGPoint(x: xPosition, y: yPosition))
} else {
path.addLine(to: CGPoint(x: xPosition, y: yPosition))
}
5、设置间距
如果想要设置折线图的上下间距,使用添加以下两个变量:
let spacing:CGFloat = 40
let spacingHeight = height - 2 * spacing
let yPosition = height - spacing - (normalizedValue * spacingHeight)

spacing表示间距,这里设置的是40间距。spacingHeight表示高度的范围。

根据这个图可以理解,原来的高度是height,如果想要设置一个上下间距,就需要减去两个上下间距。所以height变成height – 2 * spacing的高度。
在计算y轴坐标时,height – spacing表示从0.5的高度开始绘制,因为顶部有一个spacing间距。
normalizedValue * spacingHeight仍然表示当前点位占比 * spacing缩减后的高度。假设点位占比为30%,原来的高度是300,那么这里在减去2个40的间隔后,高度变成了220。30%在220的高度中计算得出的高度为66。
因为是从顶部开始绘制,所以300 – 40的间隔,得到的是顶部跳过间隔开始绘制的位置 260。260 – 0.3 * 220得到的数值是194,所以y轴的点位就是194。

根据上面的图表也可以看出,40间距 + 154的高度,刚好是194的点位。
图表背景

为了实现这样的图表背景,需要在原来的图表基础上绘制一个闭合的路径。
使用ZStack设置,设置相同的两个Path:
ZStack {
Path { path in
// 闭合的渐变路径
}
Path { path in
// 曲线
}
.stroke(Color.gray, lineWidth: 3)
}
第一个Path为闭合的灰白渐变路径。
Path { path in
var origin:CGPoint = .zero
for (index,data) in dataPoints.enumerated() {
// X轴的各点
let xPosition = CGFloat(index) / CGFloat(dataPoints.count - 1) * width
// Y轴的数值 - 从底部开始计算,减去归一化的值
let normalizedValue = (data.totalValue - dataMin) / (dataMax - dataMin)
let yPosition = height - spacing - (normalizedValue * spacingHeight)
if index == 0 {
origin = CGPoint(x: xPosition, y: yPosition)
path.move(to: origin)
} else {
path.addLine(to: CGPoint(x: xPosition, y: yPosition))
}
}
path.addLine(to: CGPoint(x: width, y: height))
path.addLine(to: CGPoint(x: 0, y: height))
path.addLine(to: origin)
path.closeSubpath() // 完成绘制
}
.fill(
LinearGradient(
gradient: Gradient(colors: [Color(hex: "DDDDDD"), .clear]), // 渐变的颜色
startPoint: .top, // 渐变的起始点
endPoint: .bottom // 渐变的结束点
)
)
主要的内容为,设置了一个origin原点,因为曲线从左往右绘制,具体的绘制方向是从左向右绘制到顶部,然后从顶部到底部,从底部到左侧底部,再从底部返回到原点。

因此,在开始绘制时,将开始的位置保存到原点。在线路绘制完成后,使用GeometryReader获取到的width和height作为右侧底部,左测底部的点。最后path.addLine(to: origin)返回原点,并使用closeSubpath完成绘制。
使用fill填充渐变颜色,完成背景色的绘制。
数值和日期刻度

接下来是比较难的部分,需要完成的整个数值和日期刻度。
因为数值和日期需要两个数据,因此,我在这里将原来的简单数值:
var datas: [Double] = [0.22,0.24,0.36,0.27,0.33,0.5,0.44]
更换成了一个数据结构类型的数组。
var dataPoints: [ExchangeRateChartPoint]
这是一个汇率数据结构,有id、date日期、totalValue总值,还有一个预览的静态变量:
struct ExchangeRateChartPoint: Identifiable {
let id = UUID()
let date: Date
let totalValue: Double // 所有外币的本币总价值
static let previewData: [ExchangeRateChartPoint] = [
ExchangeRateChartPoint(date: Date(timeIntervalSince1970: 1744300800), totalValue: 2000),
ExchangeRateChartPoint(date: Date(timeIntervalSince1970: 1744387200), totalValue: 2050),
// 省略更多的数据
]
}

整个框架结构为,左侧数值在VStack,右侧的图表和底部的日期在一个HStack,最后使用一个大的HStack将数值、图表和日期统一框起来。
我这里的布局是左侧的数值分为5个,最大值、最小值和3个平均值。底部的日期则是3个,最早日期、中间日期和最晚日期。
左侧数值
左侧的5个值的计算,使用map匹配所有值并通过max()和min()找出最大最小值:
let dataMax = dataPoints.map{ $0.totalValue}.max() ?? 0
let dataMin = dataPoints.map{ $0.totalValue}.min() ?? 0
然后,通过stride获取到中间的三个平均值:
let yAxisValues = stride(from: 0, through: 4, by: 1).map { i in
dataMin + (Double(i) / 4.0) * (dataMax - dataMin)
}.reversed() // 从大到小显示
Stride这里,使用stride(from: 0, through: 4, by: 1),表示0,1,2,3,4。
通过map匹配着5个值,返回的是最小值 + stride值 * 最大和最小值的差额。

通过reversed反转排序,从最小值到最大值反转成最大值到最小值,这是因为在下面使用ForEach时,是在VStack中进行竖向排版,所以是数值的顶部到底部,因此是最大值到最小值。
然后使用VStack绘制着5个数值:
// 左侧 Y 轴数值
VStack {
ForEach(yAxisValues,id:\.self) { value in
Text(String(format: "%.0f", value))
.font(.caption2)
.frame(height: 28, alignment: .top)
.offset(y: -6)
.foregroundColor(.gray)
}
}

现在还存在一个问题,那就是我右侧的图表是含有空白间距的。因为定格并不美观,因此在前面的文章中有表示使用spacing设置间距。
let spacing:CGFloat = 40
let spacingHeight = height - 2 * spacing
let yPosition = height - spacing - (normalizedValue * spacingHeight
因此,需要设置一个缩放占比,之所以没有使用更准确的数值调整,主要是因为在固定的frame下,缩放的代码更简单。
// 计算最大和最小值
let paddingRatio = 0.5 // 可以自由调整(比如上下额外 10% 高度)
let dataRange = dataMax - dataMin
let adjustedMax = dataMax + dataRange * paddingRatio
let adjustedMin = dataMin - dataRange * paddingRatio
let yAxisValues = stride(from: 0, through: 4, by: 1).map { i in
adjustedMin + (Double(i) / 4.0) * (adjustedMax - adjustedMin)
}.reversed() // 从大到小显示
新增一个paddingRatio变量,这里的值需要自行调整。dataRange是最大值和最小值的差额。adjustedMax和adjustedMin则用于调整最大和最小值。
adjustedMax和adjustedMin主要用于将最大值缩放或缩小到一定的倍数,这里的倍数跟差额有关。比如paddingRatio为5时,表示最大值从原来的1变成1.5,而最小值也从0变成了-0.5。
调整缩放占比后,最小值从2000变成了1735,而最小值刚好在2000的位置上。最大值则是从2530变成了2795,而最大值则对应在2530的位置。

这样,通过调整缩放占比,可以在固定的frame下,适配大多数数值。
底部日期

因为我们的日期格式希望“2025-4-23”这样的格式,首先创建DateFormatter的日期转换字符串的方法:
// 格式化日期
func formattedDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "YYYY-MM-dd"
return formatter.string(from: date)
}
使用first、last和count / 2分别获取第一个、最后一个和中间的元素日期。
let firstDate = dataPoints.first?.date
let middleDate = dataPoints[dataPoints.count / 2].date
let lastDate = dataPoints.last?.date
因此在传入的数组需要按照日期排序,否则这里的日期获取就会出错。
在SwiftUI中,使用HStack展示每一个日期,其中firstDate和lastDate都是可选值,所以需要解码:
HStack {
if let first = firstDate,
let last = lastDate {
Text(formattedDate(first))
Spacer()
Text(formattedDate(middleDate))
Spacer()
Text(formattedDate(last))
}
}
.font(.caption2)
.frame(height: 20)
.foregroundColor(.gray)
现在已经完成了基本的折线图。

分割线
下面是最简单的分割线,在ZStack的图表最下方添加分割线代码:
ZStack {
Path {}
Path {}
// 分割线
VStack {
ForEach(0..<5, id:\.self) { item in
Divider()
if item != 4 {
Spacer()
}
}
}
}

这样就完成了整体的折线图视图。
辅助线
最后是折线图的优化部分,对于图表来说,添加手势滑动显示对应日期和金额,可以让图表看起来更直观。

实现效果为:拖动手指时,可以看到某个具体点的日期和金额,并通过一个垂直线 + 圆点 + 提示框来高亮显示当前手势位置对应的汇率数据点。
变量
首先需要创建@State变量,用来保存拖动位置和选中的数据点:
@State private var dragLocation: CGFloat? = nil
@State private var selectedIndex: Int? = nil
当手势滑动时,手势的横坐标会保存在dragLocation中,滑动时对应点位则保存在selectedIndex(dataPoints 中的 index)中。
手势监听
给ZStack视图添加DragGesture手势监听:
ZStack {
Path { }.fill() // 背景
Path { } // 视图
VStack {} // 分割线
}
.contentShape(Rectangle())
.gesture(
DragGesture()
.onChanged { value in
let x = value.location.x
dragLocation = x
let index = Int(round(x / width * CGFloat(dataPoints.count - 1)))
selectedIndex = max(0, min(index, dataPoints.count - 1))
}
.onEnded { _ in
dragLocation = nil
selectedIndex = nil
}
)
onChanged闭包会在用户拖动手指时持续调用,当拖动手指时,靠近视图左侧,返回的value.location.x就接近0,向右滑动会增大x。

Index索引则使用 x / width,表示当前拖动点在图表宽度上的百分比,width由GeometryReader的size.width提供。当x为10,width为100,x / width表示10%。
百分比 * (dataPoints.count – 1)可以转换为数组索引。假设dataPoints数据源有6个元素,那么dataPoints.count – 1 = 5,索引为0-5。假如手势返回的占比是30%,30% * 5 = 1.5,那么当前的索引应该在第一个元素和第二个元素之间。

在上图示例中可以看到索引的位置,为什么是在7和8之间?是因为索引从0开始,0、1、2、3、4、5、6中的6实际是第7个元素,而6.76则靠近第 8 个元素。
通过round会返回最接近的整数,round(1.5)返回2.0,如果是rount(-1.5)会返回-2.0。
round返回小数为4、5、6:
rount(0.4) = 0.0
rount(0.5) = 1.0
rount(0.6) = 1.0
rount(-0.4) = -0.0
rount(-0.5) = -1.0
rount(-0.6) = -1.0
从这里可以看到当round(6.76)时,返回的是7.0,通过Int转换为具体的整数索引。
通过max(0, min(index, dataPoints.count – 1))限制返回的索引范围,当index > 数据源的索引时,返回的就是数据源的最大索引。如果index 比最大索引小很多变成 -3 时,max则会限制index为0。
通过max和min可以有效的将数值控制在0到数据源的最大索引之间,保障索引不越界,这样就可以在拖动手势的过程中实时修改拖动的x轴和索引。
在手势结束时,调用onEnded闭包清空X轴和索引。
.onEnded { _ in
dragLocation = nil
selectedIndex = nil
}
辅助线
当用户使用手势滑动屏幕,dragLocation更新x轴的偏移量,selectedIndex变成对应的序号,所以在ZStack上添加辅助线代码:
ZStack {
Path { }.fill() // 背景
Path { } // 视图
VStack {} // 分割线
Path {} // 辅助线
}
.contentShape(Rectangle())
.gesture( )
当滑动手势时,dragLocation和selectedIndex更新值并且不为nil。因此,先解包:
if let dragLocation = dragLocation,let selectedIndex = selectedIndex{
// 辅助线代码
}
在解包的代码中,绘制一个垂直线:
let xPosition = CGFloat(selectedIndex) / CGFloat(dataPoints.count - 1) * width
// 垂直线
Path { path in
path.move(to: CGPoint(x: xPosition, y: 0))
path.addLine(to: CGPoint(x: xPosition, y: height))
}
.stroke(Color.blue, style: StrokeStyle(lineWidth: 1, dash: [4]))

这里的代码是根据当前索引 / 全部索引数量 * width(Geometry获取的宽度),因此,第一个索引点的xPosition就是0,最后一个索引点的xPosition是width,其他的索引点分别对应固定点位。
使用Path绘制,从xPosition,0到xPosition,height。这里的height是通过Geometry获取的整个ZStack的高度。
圆点
接着绘制辅助线和折线交汇处的圆点,这里涉及计算圆点的y轴,x轴在辅助线那里已经计算过了。

在这里圆点的y轴跟折线的点一样,都是通过换算当前数值到底部的差额 / 全部差额,从而计算出当前数值到底部的百分比。然后拿整个图表的height(Geometry获取的高度)减去间隔和差值。
let normalizedValue = (selectedData.totalValue - dataMin) / (dataMax - dataMin)
let yPosition = height - spacing - (normalizedValue * spacingHeight)
// 圆点
Circle()
.fill(Color.blue)
.frame(width: 8, height: 8)
.position(x: xPosition, y: yPosition)
经过计算拿到x轴和y轴坐标后,设置圆点的position定位。
提示框
最后显示包含日期和数值的提示框。

因为提示框涉及日期和数值,日期部分还是使用DateFormatter显示,这里和前面的底部日期的DateFormatter是同一个。
在显示对应数值时,可以根据我们在手势部分获取的selectedIndex:
let selectedData = dataPoints[selectedIndex] // 获取选中的数据点
VStack(alignment: .leading, spacing: 4) {
Text(formattedDate(selectedData.date))
.font(.caption2)
.bold()
Text(String(format: "%.2f", selectedData.totalValue))
.font(.caption2)
}
.padding(6)
.background(Color.white)
.cornerRadius(6)
.shadow(radius: 2)
.position(x: xPosition,y: yPosition)
使用selectedData.date和selectedData.totalValue就可以显示对应点的日期和值。

这里的position代码实际上会遮挡住我们的点位,因此需要设置一个偏移量:
.position(x: xPosition + 60 > width ? xPosition - 60 : xPosition + 60,y: yPosition < 30 ? yPosition + 30 : yPosition - 30)
让提示框的xPosition默认添加60的偏移,yPosition默认添加 -30 的偏移。当xPosition + 60偏移时,提示框可能就到右侧了,所以将xPosition – 60,yPosition同理。这里的30和60数值可以自行配置,只要不遮挡圆点就可以。
最终完成了全部的折线图绘制。

总结
本文涉及的知识偏多,主要还是各点的计算,Path的使用,对于第一次绘制折线图的朋友稍微复杂一些,但基本代码和知识点都可以参考相关文章或从我网站查询了解。
以上就是绘制折线图的全部内容,最后的代码放在附录中。
相关文章
1、SwiftUICharts库:https://github.com/willdale/SwiftUICharts
2、Charts库:https://github.com/ChartsOrg/Charts
3、SwiftUI渐变颜色:https://fangjunyu.com/2025/04/04/swiftui%e6%b8%90%e5%8f%98%e9%a2%9c%e8%89%b2/
4、SwiftUI绘制自定义形状的Path:https://fangjunyu.com/2024/12/16/swiftui%e7%bb%98%e5%88%b6%e8%87%aa%e5%ae%9a%e4%b9%89%e5%bd%a2%e7%8a%b6%e7%9a%84path/
5、SwiftUI容器视图GeometryReader:https://fangjunyu.com/2024/12/15/swiftui%e5%ae%b9%e5%99%a8%e8%a7%86%e5%9b%begeometryreader/
6、SwiftUI可视化图表框架Charts:https://fangjunyu.com/2024/12/28/swiftui%e5%8f%af%e8%a7%86%e5%8c%96%e5%9b%be%e8%a1%a8%e6%a1%86%e6%9e%b6charts/
7、Swift 使用enumerated()方法获取元素索引:https://fangjunyu.com/2024/10/27/swift-%e4%bd%bf%e7%94%a8enumerated%e6%96%b9%e6%b3%95%e8%8e%b7%e5%8f%96%e5%85%83%e7%b4%a0%e7%b4%a2%e5%bc%95/
8、Swift生成数值序列的stride步进器:https://fangjunyu.com/2025/04/22/swift%e7%94%9f%e6%88%90%e6%95%b0%e5%80%bc%e5%ba%8f%e5%88%97%e7%9a%84stride%e6%ad%a5%e8%bf%9b%e5%99%a8/
9、SwiftUI拖动操作DragGesture:https://fangjunyu.com/2024/12/13/swiftui%e6%8b%96%e5%8a%a8%e6%93%8d%e4%bd%9cdraggesture/
10、SwiftUI边框/描边样式StrokeStyle:https://fangjunyu.com/2025/04/23/swiftui%e8%be%b9%e6%a1%86-%e6%8f%8f%e8%be%b9%e6%a0%b7%e5%bc%8fstrokestyle/
附录代码
struct ExchangeRateChart: View {
@State private var dragLocation: CGFloat? = nil
@State private var selectedIndex: Int? = nil
var dataPoints: [ExchangeRateChartPoint]
// 格式化日期
func formattedDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "YYYY-MM-dd"
return formatter.string(from: date)
}
var body: some View {
let dataMax = dataPoints.map{ $0.totalValue}.max() ?? 0
let dataMin = dataPoints.map{ $0.totalValue}.min() ?? 0
// 计算最大和最小值
let paddingRatio = 0.8 // 可以自由调整(比如上下额外 10% 高度)
let dataRange = dataMax - dataMin
let adjustedMax = dataMax + dataRange * paddingRatio
let adjustedMin = dataMin - dataRange * paddingRatio
let yAxisValues = stride(from: 0, through: 4, by: 1).map { i in
adjustedMin + (Double(i) / 4.0) * (adjustedMax - adjustedMin)
}.reversed() // 从大到小显示
let firstDate = dataPoints.first?.date
let middleDate = dataPoints[dataPoints.count / 2].date
let lastDate = dataPoints.last?.date
VStack {
Spacer().frame(height:10)
HStack {
// 左侧 Y 轴数值
VStack {
ForEach(Array(yAxisValues.enumerated()),id:\.1) { index,value in
Text(String(format: "%.0f", value))
.font(.caption2)
.offset(y: -6)
.foregroundColor(.gray)
Spacer()
}
}
VStack {
GeometryReader { geo in
let width = geo.size.width
let height = geo.size.height
let spacing:CGFloat = 40
let spacingHeight = height - 2 * spacing
ZStack {
Path { path in
var origin:CGPoint = .zero
for (index,data) in dataPoints.enumerated() {
// X轴的各点
let xPosition = CGFloat(index) / CGFloat(dataPoints.count - 1) * width
// Y轴的数值 - 从底部开始计算,减去归一化的值
let normalizedValue = (data.totalValue - dataMin) / (dataMax - dataMin)
let yPosition = height - spacing - (normalizedValue * spacingHeight)
if index == 0 {
origin = CGPoint(x: xPosition, y: yPosition)
path.move(to: origin)
} else {
path.addLine(to: CGPoint(x: xPosition, y: yPosition))
}
}
path.addLine(to: CGPoint(x: width, y: height))
path.addLine(to: CGPoint(x: 0, y: height))
path.addLine(to: origin)
path.closeSubpath() // 完成绘制
}
.fill(
LinearGradient(
gradient: Gradient(colors: [Color(hex: "DDDDDD"), .clear]), // 渐变的颜色
startPoint: .top, // 渐变的起始点
endPoint: .bottom // 渐变的结束点
)
)
Path { path in
for (index,data) in dataPoints.enumerated() {
// X轴的各点
let xPosition = CGFloat(index) / CGFloat(dataPoints.count - 1) * width
// Y轴的数值 - 从底部开始计算,减去归一化的值
let normalizedValue = (data.totalValue - dataMin) / (dataMax - dataMin)
let spacing:CGFloat = 40
let spacingHeight = height - 2 * spacing
let yPosition = height - spacing - (normalizedValue * spacingHeight)
if index == 0 {
path.move(to: CGPoint(x: xPosition, y: yPosition))
} else {
path.addLine(to: CGPoint(x: xPosition, y: yPosition))
}
}
}
.stroke(Color.gray, lineWidth: 3)
// 分割线
VStack {
ForEach(0..<5, id:\.self) { item in
Divider()
if item != 4 {
Spacer()
}
}
}
// 垂直辅助线和浮动提示
if let dragLocation = dragLocation,
let selectedIndex = selectedIndex{
let selectedData = dataPoints[selectedIndex]
let xPosition = CGFloat(selectedIndex) / CGFloat(dataPoints.count - 1) * width
let normalizedValue = (selectedData.totalValue - dataMin) / (dataMax - dataMin)
let yPosition = height - spacing - (normalizedValue * spacingHeight)
// 垂直线
Path { path in
path.move(to: CGPoint(x: xPosition, y: 0))
path.addLine(to: CGPoint(x: xPosition, y: height))
}
.stroke(Color.blue, style: StrokeStyle(lineWidth: 1, dash: [4]))
// 圆点
Circle()
.fill(Color.blue)
.frame(width: 8, height: 8)
.position(x: xPosition, y: yPosition)
// 提示框
VStack(alignment: .leading, spacing: 4) {
Text(formattedDate(selectedData.date))
.font(.caption2)
.bold()
Text(String(format: "%.2f", selectedData.totalValue))
.font(.caption2)
}
.padding(6)
.background(Color.white)
.cornerRadius(6)
.shadow(radius: 2)
.position(x: xPosition + 60 > width ? xPosition - 60 : xPosition + 60,
y: yPosition < 30 ? yPosition + 30 : yPosition - 30)
}
}
.contentShape(Rectangle())
.gesture(
DragGesture()
.onChanged { value in
let x = value.location.x
print("x:\(x)")
dragLocation = x
let index = Int(round(x / width * CGFloat(dataPoints.count - 1)))
selectedIndex = max(0, min(index, dataPoints.count - 1))
}
.onEnded { _ in
dragLocation = nil
selectedIndex = nil
}
)
}
.frame(height:120)
// 底部 X 轴时间标签
HStack {
if let first = firstDate,
let last = lastDate {
Text(formattedDate(first))
Spacer()
Text(formattedDate(middleDate))
Spacer()
Text(formattedDate(last))
}
}
.font(.caption2)
.frame(height: 20)
.foregroundColor(.gray)
}
}
}
}
}
#Preview {
ZStack {
Color.gray.opacity(0.5)
ExchangeRateChart(dataPoints: ExchangeRateChartPoint.previewData)
.padding(14)
.frame(width: 300,height: 180)
.background(.white)
.cornerRadius(10)
}
}