侧边栏壁纸
  • 累计撰写 59 篇文章
  • 累计创建 34 个标签
  • 累计收到 7 条评论

目 录CONTENT

文章目录

SwiftUI 实现交互式条形统计图

NormanZyq
2020-03-25 / 0 评论 / 0 点赞 / 665 阅读 / 10094 字
温馨提示:
本文最后更新于 ,若内容或图片失效,请留言反馈。部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

我一直很喜欢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]
<pre><code>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
    }
}
<p>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)
}</p>
<pre><code>				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(&quot;666&quot;)
					Spacer()
				}
				Spacer()
			}
		}
	}
}
.....

}

DraggableBarsView

这个其实才是最核心的部分,计算量也是最大的,毕竟得一直计算手指正在哪根条条上,所以比较复杂

直接上管理状态和init函数的代码:

// file: DraggableBarsView.swift
struct DraggableBarsView: View {
	let bars: [Bar]
<pre><code>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&lt;Int&gt;, draggingPosition: Binding&lt;CGPoint&gt;) {
	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 &lt;= 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 &lt; self.barsCount &amp;&amp; result &gt;= 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 {
<pre><code>let bars: [Bar]

let chartKind: BarChartKind

private func noData() -&gt; String {
	// todo
	...
}

private func mainChart() -&gt; 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,完全是针对我的需求而写,所以有的地方没有检查到位,可能没有删干净_| -_-|_

0

评论区