onmyway133 / blog

🍁 What you don't know is what you haven't learned
https://onmyway133.com/
MIT License
679 stars 33 forks source link

How to use hover annotation in Swift Charts #943

Open onmyway133 opened 1 year ago

onmyway133 commented 1 year ago

In this tutorial, we'll learn how to use Swift Charts to visualize ranking data.

Screenshot 2023-08-18 at 11 48 28

We use default AxisMarks and AxisMarks to let Swift Charts interpolate x and y grid lines. For y axis, I want to have finer grain control over the grid, so I specify an arrayLiteral

Note that for rank, the lower value the better ranking, so we make use of chartYScale with reversed automatic domain to flip from 0 -> 100 to 100 -> 0

Finally, to handle hover, we place an invisible view Color.clear with onContinuousHover. Note that the x axis value are in Date values, so we need to cast to Date type

struct ContentView: View {
    @State private var selectedDate: Date?

    var body: some View {
        Chart {
            ForEach(items) { item in
                lineMark(for: item)
            }
        }
        .chartXAxisLabel("Last 30 days")
        .chartXAxis {
            AxisMarks()
        }
        .chartYAxisLabel("Rank")
        .chartYAxis {
            AxisMarks(values: .init(arrayLiteral: 1, 25, 50, 75, 100))
        }
        .chartYScale(domain: .automatic(reversed: true))
        .chartLegend(spacing: 16)
        .chartOverlay { proxy in
            Color.clear
                .onContinuousHover { phase in
                    switch phase {
                    case let .active(location):
                        selectedDate = proxy.value(atX: location.x, as: Date.self)
                    case .ended:
                        selectedDate = nil
                    }
                }
        }
    }
}

For our mark, we can move it to another method marked with @ChartContentBuilder. As we get hovered date value, we can find the item for that and place an annotation

@ChartContentBuilder
private func lineMark(for item: RankTrends.DailyItem) -> some ChartContent {
    LineMark(
        x: .value("Date", item.date),
        y: .value("Rank", item.rank)
    )
    .foregroundStyle(.purple.gradient)

    if let selectedDate,
       let item = items.first(where: { $0.date.isSameDay(date: selectedDate) }) {
        RectangleMark(
            x: .value("Date", selectedDate)
        )
        .foregroundStyle(Color.separator.opacity(0.1))
        .annotation {
            Text("\(item.rank)")
                .font(.callout)
        }
    }
}