
在這篇文章中,我將演示如何創(chuàng)建顯示在一個(gè)流行的跑步和自行車應(yīng)用程序中的活動(dòng)歷史圖表。這次我將把它分解成小塊,并在此過程中進(jìn)行解釋。

概述
我們將把這篇文章分成幾個(gè)不同的部分。你可以隨意點(diǎn)擊某個(gè)部分的鏈接,直接跳轉(zhuǎn)過去。
Model-ActivityLog
如果我們要重新創(chuàng)建一個(gè)顯示活動(dòng)歷史的視圖,那么我們需要一些方法來組織和存儲(chǔ)數(shù)據(jù)。下面是ActivityLog的結(jié)構(gòu)體定義。我們將使用它來存儲(chǔ)活動(dòng)數(shù)據(jù),并將其顯示在圖形和文本中。(在這里,我們不會(huì)實(shí)現(xiàn)單位轉(zhuǎn)換)
struct ActivityLog {
var distance: Double // Miles
var duration: Double // Seconds
var elevation: Double // Feet
var date: Date
}
除此之外,我們還將定義一些測試數(shù)據(jù)來幫助我們。
class ActivityTestData {
static let testData: [ActivityLog] = [
ActivityLog(distance: 1.77, duration: 2100, elevation: 156, date: Date(timeIntervalSince1970: 1609282718)),
ActivityLog(distance: 3.01, duration: 2800, elevation: 156, date: Date(timeIntervalSince1970: 1607813915)),
ActivityLog(distance: 8.12, duration: 3400, elevation: 156, date: Date(timeIntervalSince1970: 1607381915)),
ActivityLog(distance: 2.22, duration: 3400, elevation: 156, date: Date(timeIntervalSince1970: 1606604315)),
ActivityLog(distance: 3.12, duration: 3400, elevation: 156, date: Date(timeIntervalSince1970: 1606604315)),
ActivityLog(distance: 9.01, duration: 3200, elevation: 156, date: Date(timeIntervalSince1970: 1605653915)),
ActivityLog(distance: 7.20, duration: 3400, elevation: 156, date: Date(timeIntervalSince1970: 1605653915)),
ActivityLog(distance: 4.76, duration: 3200, elevation: 156, date: Date(timeIntervalSince1970: 1604876315)),
ActivityLog(distance: 12.12, duration: 2100, elevation: 156, date: Date(timeIntervalSince1970: 1604876315)),
ActivityLog(distance: 6.01, duration: 3400, elevation: 156, date: Date(timeIntervalSince1970: 1604185115)),
ActivityLog(distance: 8.20, duration: 3400, elevation: 156, date: Date(timeIntervalSince1970: 1603234715)),
ActivityLog(distance: 4.76, duration: 2100, elevation: 156, date: Date(timeIntervalSince1970: 1603234715))
]
}
現(xiàn)在我們已經(jīng)定義了模型,我們可以將注意力轉(zhuǎn)移到創(chuàng)建自定義SwiftUI視圖上。
構(gòu)建活動(dòng)圖表
我們將創(chuàng)建一個(gè)新的SwiftUIView文件,并命名為ActivityGraph.它將接受一個(gè)ActivityLog數(shù)組以及當(dāng)前選定的星期索引的綁定。該程序只顯示了過去的12周,所以這是我們的索引值將涵蓋(0-11)。
struct ActivityGraph: View {
var logs: [ActivityLog]
@Binding var selectedIndex: Int
init(logs: [ActivityLog], selectedIndex: Binding<Int>) {
self._selectedIndex = selectedIndex
self.logs = logs // 我們接下來將對(duì)日志進(jìn)行分組
}
var body: some View {
// Nothing yet...
}
}
Logs按周分組
如果你回想一下我們的模型,ActivityLog結(jié)構(gòu)體只代表一個(gè)活動(dòng)(比如跑步、散步、徒步等)。然而,我們也可以使用它來將整個(gè)星期的統(tǒng)計(jì)數(shù)據(jù)聚集到一個(gè)ActivityLog中。我們將在ActivityGraph的init()中這樣做.通過將logs數(shù)組壓縮到僅12個(gè)實(shí)例,我們可以簡化圖形的創(chuàng)建??纯聪旅媸窃趺醋龅摹?/p>
注意,這是時(shí)間的滾動(dòng)視圖。統(tǒng)計(jì)數(shù)據(jù)不會(huì)從每周的開始分組,而是從當(dāng)前開始的7天。
init(logs: [ActivityLog], selectedIndex: Binding<Int>) {
self._selectedIndex = selectedIndex
let curr = Date() // 今天的日期
// 按時(shí)間順序?qū)θ罩具M(jìn)行排序
let sortedLogs = logs.sorted { (log1, log2) -> Bool in
log1.date > log2.date
}
var mergedLogs: [ActivityLog] = []
// 回顧過去12周的情況
for i in 0..<12 {
var weekLog: ActivityLog = ActivityLog(distance: 0, duration: 0, elevation: 0, date: Date())
for log in sortedLogs {
// 如果日志在特定的星期內(nèi),那么添加到每周總數(shù)
if log.date.distance(to: curr.addingTimeInterval(TimeInterval(-604800 * i))) < 604800 && log.date < curr.addingTimeInterval(TimeInterval(-604800 * i)) {
weekLog.distance += log.distance
weekLog.duration += log.duration
weekLog.elevation += log.elevation
}
}
mergedLogs.insert(weekLog, at: 0)
}
self.logs = mergedLogs
}
繪制網(wǎng)格
目前,body主體代碼是空的。讓我們先畫出圖形的網(wǎng)格。我將為圖的每一部分編寫函數(shù),使主體代碼更容易閱讀。例如:
var body: some View {
drawGrid()
//.opacity(0.2)
//.overlay(drawActivityGradient(logs: logs))
//.overlay(drawActivityLine(logs: logs))
//.overlay(drawLogPoints(logs: logs))
//.overlay(addUserInteraction(logs: logs))
}
這將是我們在body中看到的最終代碼的樣子。我們將首先編寫drawGrid函數(shù),在編寫函數(shù)時(shí)取消后面的注釋。drawGrid()函數(shù)相當(dāng)簡單。界面有兩條水平的黑線,并且包含了一組垂直的黑線。你可以看到,我們用SwiftUI做的唯一一件事就是設(shè)置線的寬度或高度。
func drawGrid() -> some View {
VStack(spacing: 0) {
Color.black.frame(height: 1, alignment: .center)
HStack(spacing: 0) {
Color.clear
.frame(width: 8, height: 100)
ForEach(0..<11) { i in
Color.black.frame(width: 1, height: 100, alignment: .center)
Spacer()
}
Color.black.frame(width: 1, height: 100, alignment: .center)
Color.clear
.frame(width: 8, height: 100)
}
Color.black.frame(height: 1, alignment: .center)
}
}

繪制漸變線
接下來,我們將編寫drawActivityGradient(logs:)函數(shù)。這將為圖層添加一些樣式,以便更好地展示數(shù)據(jù)的高低。思路是在這個(gè)矩形中創(chuàng)建一個(gè)LinearGradient,然后覆蓋到圖層中。讓我們看下代碼:
func drawActivityGradient(logs: [ActivityLog]) -> some View {
LinearGradient(gradient: Gradient(colors: [Color(red: 251/255, green: 82/255, blue: 0), .white]), startPoint: .top, endPoint: .bottom)
.padding(.horizontal, 8)
.padding(.bottom, 1)
.opacity(0.8)
.mask(
GeometryReader { geo in
Path { p in
// 用于視圖縮放的數(shù)據(jù)
let maxNum = logs.reduce(0) { (res, log) -> Double in
return max(res, log.distance)
}
let scale = geo.size.height / CGFloat(maxNum)
//每個(gè)周的繪制索引 (0-11)
var index: CGFloat = 0
// 添加繪制的起始的x,y點(diǎn)坐標(biāo)
p.move(to: CGPoint(x: 8, y: geo.size.height - (CGFloat(logs[Int(index)].distance) * scale)))
// 繪制添加線
for _ in logs {
if index != 0 {
p.addLine(to: CGPoint(x: 8 + ((geo.size.width - 16) / 11) * index, y: geo.size.height - (CGFloat(logs[Int(index)].distance) * scale)))
}
index += 1
}
// 形成閉環(huán)路徑
p.addLine(to: CGPoint(x: 8 + ((geo.size.width - 16) / 11) * (index - 1), y: geo.size.height))
p.addLine(to: CGPoint(x: 8, y: geo.size.height))
p.closeSubpath()
}
}
)
}
如果您現(xiàn)在取消對(duì)在body代碼中.overlay(drawActivityGradient(logs: logs))繪制漸變的調(diào)用的注釋:
var body: some View {
drawGrid()
.opacity(0.2)
.overlay(drawActivityGradient(logs: logs))
//.overlay(drawActivityLine(logs: logs))
//.overlay(drawLogPoints(logs: logs))
//.overlay(addUserInteraction(logs: logs))
}
然后您應(yīng)該會(huì)看到類似下圖的內(nèi)容。

繪制活動(dòng)線
繪制線條函數(shù)的工作原理與漸變函數(shù)類似。唯一的區(qū)別是,我們不會(huì)關(guān)閉路徑并使用它作為一個(gè)遮罩。我們簡單地畫一條線,給它一些顏色。參見下面的drawActivityLine(logs:)函數(shù)。
func drawActivityLine(logs: [ActivityLog]) -> some View {
GeometryReader { geo in
Path { p in
let maxNum = logs.reduce(0) { (res, log) -> Double in
return max(res, log.distance)
}
let scale = geo.size.height / CGFloat(maxNum)
var index: CGFloat = 0
p.move(to: CGPoint(x: 8, y: geo.size.height - (CGFloat(logs[0].distance) * scale)))
for _ in logs {
if index != 0 {
p.addLine(to: CGPoint(x: 8 + ((geo.size.width - 16) / 11) * index, y: geo.size.height - (CGFloat(logs[Int(index)].distance) * scale)))
}
index += 1
}
}
.stroke(style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round, miterLimit: 80, dash: [], dashPhase: 0))
.foregroundColor(Color(red: 251/255, green: 82/255, blue: 0))
}
}
取消body變量中的行注釋后,您應(yīng)該會(huì)在預(yù)覽畫布中看到如下圖所示的內(nèi)容。

繪制點(diǎn)
我們的下一個(gè)函數(shù),drawLogPoints(logs:)將在圖形上放置圓圈點(diǎn)作為覆蓋。請(qǐng)參見下面的代碼:
func drawLogPoints(logs: [ActivityLog]) -> some View {
GeometryReader { geo in
let maxNum = logs.reduce(0) { (res, log) -> Double in
return max(res, log.distance)
}
let scale = geo.size.height / CGFloat(maxNum)
ForEach(logs.indices) { i in
Circle()
.stroke(style: StrokeStyle(lineWidth: 4, lineCap: .round, lineJoin: .round, miterLimit: 80, dash: [], dashPhase: 0))
.frame(width: 10, height: 10, alignment: .center)
.foregroundColor(Color(red: 251/255, green: 82/255, blue: 0))
.background(Color.white)
.cornerRadius(5)
.offset(x: 8 + ((geo.size.width - 16) / 11) * CGFloat(i) - 5, y: (geo.size.height - (CGFloat(logs[i].distance) * scale)) - 5)
}
}
}
通過在body變量中取消注釋繪制點(diǎn)的那行代碼,您應(yīng)該在畫布預(yù)覽中獲得以下結(jié)果。

添加用戶交互
現(xiàn)在我們已經(jīng)到了構(gòu)建圖表的最后一步。我們將為用戶添加拖動(dòng)圖形的能力。這將在圖形選擇的位置顯示一條垂直線。

它的工作方式是通過向視圖添加一個(gè)DragGesture,在這個(gè)過程中我們將獲得用戶的觸摸位置。
使用該位置,我們將沿著活動(dòng)圖形放置一條垂直線和一個(gè)點(diǎn)。
同樣,我們將編寫一個(gè)名為addUserInteraction(logs:)的函數(shù)讓它返回一個(gè)View視圖。
func addUserInteraction(logs: [ActivityLog]) -> some View {
GeometryReader { geo in
let maxNum = logs.reduce(0) { (res, log) -> Double in
return max(res, log.distance)
}
let scale = geo.size.height / CGFloat(maxNum)
ZStack(alignment: .leading) {
// 線和點(diǎn)疊加
// 添加拖動(dòng)手勢代碼
}
}
}
首先讓我們設(shè)計(jì)垂直的線和圓點(diǎn)覆蓋疊加。
func addUserInteraction(logs: [ActivityLog]) -> some View {
GeometryReader { geo in
let maxNum = logs.reduce(0) { (res, log) -> Double in
return max(res, log.distance)
}
let scale = geo.size.height / CGFloat(maxNum)
ZStack(alignment: .leading) {
// 線和點(diǎn)疊加
Color(red: 251/255, green: 82/255, blue: 0)
.frame(width: 2)
.overlay(
Circle()
.frame(width: 24, height: 24, alignment: .center)
.foregroundColor(Color(red: 251/255, green: 82/255, blue: 0))
.opacity(0.2)
.overlay(
Circle()
.fill()
.frame(width: 12, height: 12, alignment: .center)
.foregroundColor(Color(red: 251/255, green: 82/255, blue: 0))
)
, alignment: .bottom) // 設(shè)置和圓底部對(duì)齊
// 添加拖動(dòng)手勢代碼
}
}
}
為了讓視圖遵循用戶的觸摸,我們需要偏移視圖,包括垂直線和圓覆蓋。
為此,我們需要添加一些新的@State變量。這樣做的目的是,當(dāng)用戶選擇垂直線時(shí),垂直線會(huì)捕捉到用戶的觸摸位置,但當(dāng)用戶抬起手指時(shí),垂直線又會(huì)捕捉到最近的記錄點(diǎn)。
@State var lineOffset: CGFloat = 8 // 垂直線的偏移量
@State var selectedXPos: CGFloat = 8 // 手勢位置X點(diǎn)
@State var selectedYPos: CGFloat = 0 // 手勢位置Y點(diǎn)
@State var isSelected: Bool = false // 用戶是否觸摸圖形
現(xiàn)在定義了這些變量后,我們可以添加使視圖偏移的代碼。
func addUserInteraction(logs: [ActivityLog]) -> some View {
GeometryReader { geo in
let maxNum = logs.reduce(0) { (res, log) -> Double in
return max(res, log.distance)
}
let scale = geo.size.height / CGFloat(maxNum)
ZStack(alignment: .leading) {
// 線和點(diǎn)疊加
Color(red: 251/255, green: 82/255, blue: 0)
.frame(width: 2)
.overlay(
Circle()
.frame(width: 24, height: 24, alignment: .center)
.foregroundColor(Color(red: 251/255, green: 82/255, blue: 0))
.opacity(0.2)
.overlay(
Circle()
.fill()
.frame(width: 12, height: 12, alignment: .center)
.foregroundColor(Color(red: 251/255, green: 82/255, blue: 0))
)
.offset(x: 0, y: isSelected ? 12 - (selectedYPos * scale) : 12 - (CGFloat(logs[selectedIndex].distance) * scale))
, alignment: .bottom)
.offset(x: isSelected ? lineOffset : 8 + ((geo.size.width - 16) / 11) * CGFloat(selectedIndex), y: 0)
.animation(Animation.spring().speed(4))
// 添加拖動(dòng)手勢代碼
}
}
}
這樣我們就可以添加DragGesture代碼了。我們將添加這是一個(gè)幾乎完全透明的視圖,它將捕獲用戶觸摸事件。
func addUserInteraction(logs: [ActivityLog]) -> some View {
GeometryReader { geo in
let maxNum = logs.reduce(0) { (res, log) -> Double in
return max(res, log.distance)
}
let scale = geo.size.height / CGFloat(maxNum)
ZStack(alignment: .leading) {
// 線和點(diǎn)疊加的代碼放在前面
// ....
// 拖動(dòng)手勢代碼
Color.white.opacity(0.1)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { touch in
let xPos = touch.location.x
self.isSelected = true
let index = (xPos - 8) / (((geo.size.width - 16) / 11))
if index > 0 && index < 11 {
let m = (logs[Int(index) + 1].distance - logs[Int(index)].distance)
self.selectedYPos = CGFloat(m) * index.truncatingRemainder(dividingBy: 1) + CGFloat(logs[Int(index)].distance)
}
if index.truncatingRemainder(dividingBy: 1) >= 0.5 && index < 11 {
self.selectedIndex = Int(index) + 1
} else {
self.selectedIndex = Int(index)
}
self.selectedXPos = min(max(8, xPos), geo.size.width - 8)
self.lineOffset = min(max(8, xPos), geo.size.width - 8)
}
.onEnded { touch in
let xPos = touch.location.x
self.isSelected = false
let index = (xPos - 8) / (((geo.size.width - 16) / 11))
if index.truncatingRemainder(dividingBy: 1) >= 0.5 && index < 11 {
self.selectedIndex = Int(index) + 1
} else {
self.selectedIndex = Int(index)
}
}
)
}
}
}

構(gòu)造活動(dòng)統(tǒng)計(jì)文本
現(xiàn)在我們的圖表已經(jīng)完成了,我們把它放到項(xiàng)目中用于顯示活動(dòng)統(tǒng)計(jì)信息。我繼續(xù)創(chuàng)建了一個(gè)名為ActivityStatsText的新的swifitUI視圖,并傳遞了與圖表相同的參數(shù)。這里我不會(huì)深入講解,但是我將日志按周分組,就像圖表一樣,并在視圖中顯示了這些周的里程、持續(xù)時(shí)間和海拔統(tǒng)計(jì)數(shù)據(jù)。selectedIndex變量綁定在父視圖上,它與提供給圖表的父視圖相同。這樣,當(dāng)用戶點(diǎn)擊圖形時(shí),統(tǒng)計(jì)文本根據(jù)用戶選擇的活動(dòng)日志而變化。
struct ActivityHistoryText: View {
var logs: [ActivityLog]
var mileMax: Int
@Binding var selectedIndex: Int
var dateFormatter: DateFormatter {
let formatter = DateFormatter()
formatter.dateFormat = "MMM dd"
return formatter
}
init(logs: [ActivityLog], selectedIndex: Binding<Int>) {
self._selectedIndex = selectedIndex
let curr = Date() // 當(dāng)前日期
let sortedLogs = logs.sorted { (log1, log2) -> Bool in
log1.date > log2.date
} // 按時(shí)間順序?qū)θ罩具M(jìn)行排序
var mergedLogs: [ActivityLog] = []
for i in 0..<12 {
var weekLog: ActivityLog = ActivityLog(distance: 0, duration: 0, elevation: 0, date: Date())
for log in sortedLogs {
if log.date.distance(to: curr.addingTimeInterval(TimeInterval(-604800 * i))) < 604800 && log.date < curr.addingTimeInterval(TimeInterval(-604800 * i)) {
weekLog.distance += log.distance
weekLog.duration += log.duration
weekLog.elevation += log.elevation
}
}
mergedLogs.insert(weekLog, at: 0)
}
self.logs = mergedLogs
self.mileMax = Int(mergedLogs.max(by: { $0.distance < $1.distance })?.distance ?? 0)
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("\(dateFormatter.string(from: logs[selectedIndex].date.addingTimeInterval(-604800))) - \(dateFormatter.string(from: logs[selectedIndex].date))".uppercased())
.font(Font.body.weight(.heavy))
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text("Distance")
.font(.caption)
.foregroundColor(Color.black.opacity(0.5))
Text(String(format: "%.2f mi", logs[selectedIndex].distance))
.font(Font.system(size: 20, weight: .medium, design: .default))
}
Color.gray
.opacity(0.5)
.frame(width: 1, height: 30, alignment: .center)
VStack(alignment: .leading, spacing: 4) {
Text("Time")
.font(.caption)
.foregroundColor(Color.black.opacity(0.5))
Text(String(format: "%.0fh", logs[selectedIndex].duration / 3600) + String(format: " %.0fm", logs[selectedIndex].duration.truncatingRemainder(dividingBy: 3600) / 60))
.font(Font.system(size: 20, weight: .medium, design: .default))
}
Color.gray
.opacity(0.5)
.frame(width: 1, height: 30, alignment: .center)
VStack(alignment: .leading, spacing: 4) {
Text("Elevation")
.font(.caption)
.foregroundColor(Color.black.opacity(0.5))
Text(String(format: "%.0f ft", logs[selectedIndex].elevation))
.font(Font.system(size: 20, weight: .medium, design: .default))
}
Spacer()
}
VStack(alignment: .leading, spacing: 5) {
Text("LAST 12 WEEKS")
.font(Font.caption.weight(.heavy))
.foregroundColor(Color.black.opacity(0.7))
Text("\(mileMax) mi")
.font(Font.caption)
.foregroundColor(Color.black.opacity(0.5))
}.padding(.top, 10)
}
}
活動(dòng)數(shù)據(jù)視圖
這是父視圖,它包含圖表視圖和文本視圖:
struct ActivityHistoryView: View {
@State var selectedIndex: Int = 0
var body: some View {
VStack(spacing: 16) {
// 統(tǒng)計(jì)數(shù)據(jù)文本視圖
ActivityHistoryText(logs: ActivityTestData.testData, selectedIndex: $selectedIndex)
// 圖表
ActivityGraph(logs: ActivityTestData.testData, selectedIndex: $selectedIndex)
}.padding()
}
}