NormanZyq
发布于 2020-03-25 / 714 阅读
0
0

SwiftUI 实现交互式条形统计图

我一直很喜欢iPhone自带的健康app的统计图,不仅是简洁优雅,更吸引我的地方在于它是能够交互的,什么意思呢,有的app的统计图是单纯的把图摆在你面前,而不能够点击或者拖拽,但是如果只能点击,又缺了点意思,显然,能够拖拽的条形统计图才更符合人的直观感受。

SwiftUI 是一个强有力的工具,极大程度上的降低了苹果开发的门槛,很多原来很复杂的UI布置变声了一条条的声明语句,让很多新手都能快速上手,我也是这样。

我最近在尝试写时间统计类的小app(为自己所用😂),所以不可避免的要画统计图,于是我才写出了这么个“交互式”统计图。

开始

先看效果:(GIF图可能加载的有点慢)

8vjXUP.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
}

设计布局

接下来我们需要画出:

  1. 纵轴的轴线
  2. 条形统计图本身

轴线是比较容易画的,用个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
    
    ...
}

说明:

  1. 上面的bars就是要显示的条条的数据,max是条条的数据中最大的,因为这里需要计算出每一个轴线的值是多少,count是条条的数量,因为经常要用到,为了减少代码和方便调用,也为了减少潜在的对性能的影响,所以单独用一个变量存储。
  2. draggingPositionnowAtIndex存储在这里是为了显示详细的内容,在稍后还会提到。

下面是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来了。先说明一下:

  1. Group包装是因为里面有if判断,内部使用到的的第一个GeometryReader是要用来计算当前布局的高度的,以便于将轴线从最高点摆到最低点
  2. BarDetailedTextView是拖拽到某一个条条上时,显示详情的布局,这个可以自由发挥,也可以参考,代码会放在最后
  3. 我的轴线分为两部分,一个是时长,一个是线条
  4. DraggableBarsView是条形图的部分,代码在下一部分
  5. 关于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
		}
	}
}

几点说明:

  1. 后续每一根条条的宽度都是动态计算的(布局宽度均分给每个条条和空隙),但是为什么还要有barWidth这个变量?主要是为了避免当数据量较少的时候会印象美观程度(条条巨肥无比)
  2. 前面提到的nowAtIndexdraggingPosition都是为了这里做的准备,因为真正计算手指位置的实在这个View里面,只有用Binding传递数据是最方便的
  3. componentsCount是条条和条条之间的空隙的总数量
  4. 最核心的地方是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,完全是针对我的需求而写,所以有的地方没有检查到位,可能没有删干净_| -_-|_


评论