前言
在开发ChatTCP的报表展示功能的时候,我选择使用SwiftUI的原生Chart组件。但是这东西文档太少了,除了官方那不算文档的文档,遇到问题都好难解决。我在做柱形图的时候,想要实现像网站使用ercharts实现的鼠标移动到柱状图上面才弹窗显示对应数值的效果,苦于找不到文档,于是放弃了。
于是就有了后来提交到appstore审核被驳回的事情,驳回原因如下图:
由于多组数据Chart组件使用了堆叠展示,好像也不支持对比的方式展示。因为堆叠展示,一个BarItem显示两个数值是很容易出现文字重叠的情况的,是很影响用户体验。
为了能够审核通过,只能硬着头皮去解决了。
实现方式
通过官方的介绍,我了解到是可以实现鼠标移动(选中)然后弹窗显示数值的,并且怎么显示还是可以自定义的,只是官方给的代码并不完整,并且案例是一个折线图的。
后面参考这篇文章《Building Pie Charts and Donut Charts with SwiftUI in iOS 17》摸索出了解决方案,这篇文章介绍了饼图怎么实现鼠标移动到某个扇区的时候,把某个扇区高亮显示。然后我再基于官方的折线图案例,通过不断试错,摸索出了柱状图的解决方案。
饼图如何实现
先介绍饼图如何实现。
我想实现的效果是,当鼠标移动到某个扇区时,这个扇区高亮显示,并且显示数值,而其它扇区变暗,并且不显示数值。当鼠标未在任意一个扇区时,所有扇区都恢复高亮显示。
代码如下:
@State private var selectedAngle:Int?
@State private var selectedSector: String?
Chart(chartData) { data in
SectorMark(
angle: .value("Count", data.count),
innerRadius: .ratio(0.618),
angularInset: 1.5
)
.cornerRadius(8)
.foregroundStyle(data.color)
.opacity(selectedSector == nil || data.name == selectedSector ? 1.0 : 0.3)
.annotation(position: .overlay) {
Group {
if data.name == selectedSector {
Text("\(data.count)")
.foregroundStyle(.themeContentBase)
}
}
}
}.chartAngleSelection(value: $selectedAngle)
.onChange(of: selectedAngle) { _, newValue in
if let newValue {
selectedSector = findSelectedSector(value: newValue)
} else {
selectedSector = nil
}
}
代码解释:
内层SectorMark的调用:
- opacity:如果当前鼠标停留在某一项上,且不是当前项,那么就把当前项变暗。
- annotation:显示值,如果当前项是选中项才显示数值。
最外层Chart的调用:
- chartAngleSelection:当鼠标移动到饼图上,或者在饼图上移动时,这个方法传递的参数$selectedAngle的值就会改变。用于获取鼠标在扇区的位置,实际就是扇区的角度值(这个角度值最大不是360,而是每个SectorMark的angle值的和)。
- onChange:当selectedAngle值发生变化时,我们在这个方法中计算当前选中的是哪一项。
onChange方法中通过调用findSelectedSector方法来获取当前选中的扇区对应的是哪个数据项(item),实现代码如下。
private func findSelectedSector(value: Int) -> String? {
var accumulatedCount = 0
let selectedItem = chartData.first { item in
accumulatedCount += Int(item.count)
return value <= accumulatedCount
}
return selectedItem?.name // 返回选中项的名称
}
大致意思就是,按顺序遍历数据项,将每一项的值加起来,如果累加到当前项,这个值大于等于鼠标所在位置角度值,那么就停止遍历,这一项就是当前选中项。
效果如下:
柱形图如何实现
有了饼图的案例,我们就知道大致的思路了。
柱形图不能用chartAngleSelection,但是可以用chartXSelection和chartYSelection来获取当前鼠标所在位置的x坐标的值,或者y坐标的值。
代码实现如下:
@State private var selectedSector: String?
Chart {
ForEach(barItem) { data in
BarMark(
x: .value("Name", data.name),
y: .value("Value", data.value)
)
.foregroundStyle(data.color)
.foregroundStyle(by: .value("Category", data.category))
}
if let selectedName = selectedSector {
RuleMark(
x: .value("Selected", selectedName)
)
.foregroundStyle(Color.gray.opacity(0))
.offset(yStart: -8)
.zIndex(999)
.annotation(position: .top, spacing: 0, overflowResolution: AnnotationOverflowResolution(x: .fit(to: .chart), y: .disabled)) {
HStack(spacing: 8) {
ForEach(barItem) { data in
if data.name == selectedName {
Label(
title: {
Text("\(Int(data.value))")
.foregroundStyle(.themeContentBase)
},
icon: {
Circle()
.fill(data.color)
.frame(width: 8, height: 8)
}
)
}
}
}.frame(alignment: .leading)
}
}
}.chartXSelection(value: $selectedSector)
由于我的案例中,柱形图显示的数据,x轴是字符串,y轴是数值,然后我只需要知道当前x轴的值是什么,在对应的BarItem上面显示值就可以了。所以这里字段selectedSector的类型是String。官网的折线图例子x轴坐标是日期,所以官网例子中对应的selectedSector字段的类型是Day。
案例中if let selectedName = selectedSector
就是判断当前鼠标是否在某个BarItem上,如果是就通过RuleMark来实现“弹窗”显示这个对应x轴上多个堆叠的BarItem的y轴值。案例中实现的是在x轴位置柱形图的头顶来显示。
其中这一段就是显示数值的代码,可以自由发挥。ForEach循环是遍历多组数据每组数据对应选中的BarItem的值。
HStack(spacing: 8) {
ForEach(barItem) { data in
if data.name == selectedName {
Label(
title: {
Text("\(Int(data.value))")
.foregroundStyle(.themeContentBase)
},
icon: {
Circle()
.fill(data.color)
.frame(width: 8, height: 8)
}
)
}
}
}.frame(alignment: .leading)
效果如下:
参考文献: