我一直很喜欢iPhone自带的健康app的统计图,不仅是简洁优雅,更吸引我的地方在于它是能够交互的,什么意思呢,有的app的统计图是单纯的把图摆在你面前,而不能够点击或者拖拽,但是如果只能点击,又缺了点意思,显然,能够拖拽的条形统计图才更符合人的直观感受。
SwiftUI 是一个强有力的工具,极大程度上的降低了苹果开发的门槛,很多原来很复杂的UI布置变声了一条条的声明语句,让很多新手都能快速上手,我也是这样。
我最近在尝试写时间统计类的小app(为自己所用😂),所以不可避免的要画统计图,于是我才写出了这么个“交互式”统计图。
开始
先看效果:(GIF图可能加载的有点慢)
“交互式”条形统计图看似简单,实则不然,因为据我所知,SwiftUI并没有直接提供手指的位置在哪个UI控件上的API,也正是因为此,我们希望通过获得手指在哪个柱子上的思路是基本行不通了,其次,SwiftUI提供的几个手势中最常用的三个里只有DragGesture
提供了手指拖拽的距离,或者位置,因此我们转换思路,通过获得手指位置然后计算手指正在哪一个“条条”上。
前期准备
为了实现条形统计图,先定义好“条条”:
struct Bar: Identifiable {
let id: UUID = UUID()
let value: Double
let label: String
let legend: Legend
}
另外,为了未来扩展的可能,再定义好“图例”:
struct Legend: Hashable {
let color: Color
let label: String
}
设计布局
接下来我们需要画出:
- 纵轴的轴线
- 条形统计图本身
轴线是比较容易画的,用个ZStack
就能够轻易的进行堆叠,创建BarsViewContainer
,作用是堆叠轴线和条条,那么它需要管理哪些状态和变量呢?见下面代码:
// file: BarsViewContainer.swift
struct BarsViewContainer: View {
let bars: [Bar]
let max: Double
private let count: Int
@State private var showDetail = false
@State private var nowAtIndex = -1
@State private var draggingPosition: CGPoint = .zero
...
}
说明:
- 上面的
bars
就是要显示的条条的数据,max
是条条的数据中最大的,因为这里需要计算出每一个轴线的值是多少,count是条条的数量,因为经常要用到,为了减少代码和方便调用,也为了减少潜在的对性能的影响,所以单独用一个变量存储。 draggingPosition
和nowAtIndex
存储在这里是为了显示详细的内容,在稍后还会提到。
下面是init
函数
// file: BarsViewContainer.swift
/// 初始化
init(bars: [Bar], chartKind: BarChartKind) {
self.bars = bars
self.max = bars.map { $0.value }.max() ?? 0
self.count = bars.count
}
重要的body
来了。先说明一下:
- 用
Group
包装是因为里面有if
判断,内部使用到的的第一个GeometryReader
是要用来计算当前布局的高度的,以便于将轴线从最高点摆到最低点 BarDetailedTextView
是拖拽到某一个条条上时,显示详情的布局,这个可以自由发挥,也可以参考,代码会放在最后- 我的轴线分为两部分,一个是时长,一个是线条
DraggableBarsView
是条形图的部分,代码在下一部分- 关于
getDetailTextOffset
的内容需要根据你的实际情况而变更,因为我们设计的BarDetailedTextView
肯定不尽相同,偏移量会有些许区别,需要多次运行找出最舒服的偏移距离。
// file: BarsViewContainer.swift
private func getDetailTextOffset(gWidth: CGFloat) -> CGFloat {
let x = self.draggingPosition.x
if x <= 25 {
return 0
} else if x + 125 >= gWidth {
return gWidth - 150
} else {
return x - 25
}
}
var body: some View {
return Group {
if self.max != 0 {
GeometryReader { g in
// MARK: - 详细信息
// 这里是详细信息显示的地方
if self.nowAtIndex >= 0 {
BarDetailedTextView(arrowOffset: self.calculateArrowOffset(),
title: self.getDetailTitle(),
subtitle: self.bars[self.nowAtIndex].value)
.offset(y: -50)
.offset(x: self.getDetailTextOffset(gWidth: g.size.width))
.animation(nil)
.zIndex(999)
}
HStack {
// MARK: - 时长纵坐标
VStack(spacing: g.size.height / 3) {
ForEach(0...3, id: \.self) { i in
Text(self.max - (Double(i) * self.max / 3.0))
.font(.system(size: 10))
.frame(height: 1)
.opacity(0.6)
.lineLimit(1)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(.trailing, 4)
ZStack {
// MARK: - 时长轴线
VStack(spacing: g.size.height / 3) {
ForEach(1...4, id: \.self) { i in
Rectangle()
.frame(height: 1)
.opacity(0.6)
}
}
GeometryReader { gLines in
VStack {
Spacer()
// 这里可以加入横坐标,但是我尚未实现较理想的横坐标显示方式
}
.offset(y: 20)
}
DraggableBarsView(bars: self.bars, index: self.$nowAtIndex, draggingPosition: self.$draggingPosition)
}
}
.edgesIgnoringSafeArea([.leading])
.offset(y: 30)
}
} else {
HStack {
Spacer()
VStack {
Spacer()
Text("666")
Spacer()
}
Spacer()
}
}
}
}
.....
}
DraggableBarsView
这个其实才是最核心的部分,计算量也是最大的,毕竟得一直计算手指正在哪根条条上,所以比较复杂
直接上管理状态和init
函数的代码:
// file: DraggableBarsView.swift
struct DraggableBarsView: View {
let bars: [Bar]
private var barsCount: Int
private let max: Double
private let componentsCount: Int
private let useFixedWidth: Bool
@State var barWidth: CGFloat = 16.0
@State var spacing: CGFloat = 16.0
@Binding var nowAtIndex: Int {
didSet {
if oldValue != self.nowAtIndex {
HapticFeedback.playSelection()
}
}
}
@Binding var draggingPosition: CGPoint
init(bars: [Bar], index: Binding<Int>, draggingPosition: Binding<CGPoint>) {
self.bars = bars
self.max = bars.map { $0.value }.max() ?? 0
let count = bars.count
self.barsCount = count
self._nowAtIndex = index
self.componentsCount = count * 2 - 1
self._draggingPosition = draggingPosition
self.useFixedWidth = (count <= 10)
}
// 利用手指位置计算在哪根柱形图上
private func calculateIndexWithPosition(x: CGFloat, gWidth: CGFloat) {
var result = Int((x / (gWidth / CGFloat(componentsCount))).rounded())
if result % 2 == 0 {
result /= 2
self.nowAtIndex = ((result < self.barsCount && result >= 0) ? result : -1)
}
}
// 设置宽度
private func setWidth(gInnerWidth: CGFloat) {
if useFixedWidth {
self.barWidth = 16
self.spacing = (gInnerWidth - 112) / CGFloat(self.barsCount)
} else {
let width = gInnerWidth / CGFloat(self.componentsCount)
self.barWidth = width
self.spacing = width
}
}
}
几点说明:
- 后续每一根条条的宽度都是动态计算的(布局宽度均分给每个条条和空隙),但是为什么还要有
barWidth
这个变量?主要是为了避免当数据量较少的时候会印象美观程度(条条巨肥无比) - 前面提到的
nowAtIndex
和draggingPosition
都是为了这里做的准备,因为真正计算手指位置的实在这个View里面,只有用Binding
传递数据是最方便的 componentsCount
是条条和条条之间的空隙的总数量- 最核心的地方是
calculateIndexWithPosition
函数
下面是body
// file: DraggableBarsView.swift
var body: some View {
GeometryReader { gInner in
VStack {
HStack(alignment: .bottom, spacing: self.spacing) {
// MARK: - 条形图的ForEach
ForEach(self.bars.indices, id: \.self) { i in
ZStack(alignment: .bottom) {
VStack {
Capsule()
.fill(self.bars[i].legend.color)
.frame(width: self.barWidth)
.frame(height: CGFloat(self.bars[i].value < self.max * 0.05 ?
(self.max * 0.02 + self.bars[i].value) : self.bars[i].value) / CGFloat(self.max) * gInner.size.height) // 如果连最高的一根的5%都没有,就显示最高的一根的10%的长度并加上自身
.scaleEffect(self.nowAtIndex == i ? CGSize(width: 1.4, height: 1.05) : CGSize(width: 1, height: 1), anchor: .bottom)
.animation(.spring())
.accessibility(label: Text(self.bars[i].label))
.accessibility(value: Text(self.bars[i].legend.label))
}
Rectangle()
.frame(width: 3,
height: gInner.size.height)
.opacity(self.nowAtIndex == i ? 1 : 0)
.animation(nil)
}
}
}
.gesture(DragGesture().onChanged { value in
self.draggingPosition = value.location
self.calculateIndexWithPosition(x: value.location.x, gWidth: gInner.size.width)
}
.onEnded { value in
self.nowAtIndex = -1
})
}
.onAppear {
self.setWidth(gInnerWidth: gInner.size.width)
}
}
}
有了前面的思路,这里就很好理解了,每次滑动/拖拽的时候不断调用calculateIndexWithPosition
函数计算手指的位置,然后将该位置保存到nowAtIndex
中。
最后的最后
这时,在你的View中直接使用BarsViewContainer
已经OK了,但是我另外加了一个BarChartView
以便以后扩展:
// file: BarChartView.swift
struct BarChartViewNew: View {
let bars: [Bar]
let chartKind: BarChartKind
private func noData() -> String {
// todo
...
}
private func mainChart() -> BarsViewContainer {
return BarsViewContainer(bars: self.bars)
}
var body: some View {
Group {
if bars.isEmpty {
HStack(alignment: .center) {
Spacer()
Text(self.noData())
Spacer()
}
.padding()
} else {
VStack(alignment: .center, spacing: 16) {
self.mainChart()
.padding()
}
}
}
}
}
struct BarChartViewNew_Previews: PreviewProvider {
static var previews: some View {
let bars: [Bar] = [
Bar(value: 1498, label: "Bar 1", legend: Legend(color: .secondary, label: "Legend 1")),
Bar(value: 1608, label: "Bar 2", legend: Legend(color: .red, label: "Legend 2")),
Bar(value: 2900, label: "Bar 3", legend: Legend(color: .orange, label: "Legend 3")),
Bar(value: 3601, label: "Bar 4", legend: Legend(color: .blue, label: "Legend 4")),
Bar(value: 3210, label: "Bar 5", legend: Legend(color: .pink, label: "Legend 5")),
Bar(value: 3210, label: "Bar 5", legend: Legend(color: .pink, label: "Legend 5"))
]
return BarChartViewNew(bars: bars)
}
}
上述代码从我尝试中的app里截取,而我没有编写完全通用的统计图View,完全是针对我的需求而写,所以有的地方没有检查到位,可能没有删干净_| -_-|_
评论区