SwiftUI:圖表繪制

SwiftUI_Strava_Example@half.png

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

IMG_6409.jpg

概述

我們將把這篇文章分成幾個(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中。我們將在ActivityGraphinit()中這樣做.通過將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)
    }
}
graph_grid.png

繪制漸變線

接下來,我們將編寫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)容。

drawGradient.png

繪制活動(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)容。

add_line.png

繪制點(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é)果。

points_overlayed.png

添加用戶交互

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

dragging_across_graph.png

它的工作方式是通過向視圖添加一個(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)
                            }
                        }
                )
        }

    }
}
result_Graph.gif

構(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()
    }
}
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容