SwiftUI使用Path和GeometryReader绘制折线图
SwiftUI使用Path和GeometryReader绘制折线图

SwiftUI使用Path和GeometryReader绘制折线图

在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)
    }
}

如果您认为这篇文章给您带来了帮助,您可以在此通过支付宝或者微信打赏网站开放者。

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注