From dd837c7aef7ba86e572a865020f0fa5a79e8c282 Mon Sep 17 00:00:00 2001 From: Majid Jabrayilov Date: Mon, 20 Jul 2020 17:12:14 +0400 Subject: [PATCH] charting code added to the package --- Package.swift | 6 ++ Sources/SwiftUICharts/AxisView.swift | 37 +++++++ Sources/SwiftUICharts/Bar.swift | 98 +++++++++++++++++++ Sources/SwiftUICharts/BarChartView.swift | 67 +++++++++++++ Sources/SwiftUICharts/BarsView.swift | 76 ++++++++++++++ Sources/SwiftUICharts/ChartGrid.swift | 44 +++++++++ .../SwiftUICharts/HorizontalBarChart.swift | 59 +++++++++++ Sources/SwiftUICharts/LabelsView.swift | 45 +++++++++ Sources/SwiftUICharts/LegendView.swift | 38 +++++++ Sources/SwiftUICharts/LineChartView.swift | 85 ++++++++++++++++ .../RandomAccessCollection.swift | 13 +++ Sources/SwiftUICharts/SwiftUICharts.swift | 3 - Tests/LinuxMain.swift | 1 - .../SwiftUIChartsTests.swift | 9 +- 14 files changed, 569 insertions(+), 12 deletions(-) create mode 100644 Sources/SwiftUICharts/AxisView.swift create mode 100644 Sources/SwiftUICharts/Bar.swift create mode 100644 Sources/SwiftUICharts/BarChartView.swift create mode 100644 Sources/SwiftUICharts/BarsView.swift create mode 100644 Sources/SwiftUICharts/ChartGrid.swift create mode 100644 Sources/SwiftUICharts/HorizontalBarChart.swift create mode 100644 Sources/SwiftUICharts/LabelsView.swift create mode 100644 Sources/SwiftUICharts/LegendView.swift create mode 100644 Sources/SwiftUICharts/LineChartView.swift create mode 100644 Sources/SwiftUICharts/RandomAccessCollection.swift delete mode 100644 Sources/SwiftUICharts/SwiftUICharts.swift diff --git a/Package.swift b/Package.swift index a6a32ce..ba977ed 100644 --- a/Package.swift +++ b/Package.swift @@ -5,6 +5,12 @@ import PackageDescription let package = Package( name: "SwiftUICharts", + platforms: [ + .macOS("10.16"), + .iOS("14"), + .watchOS("7"), + .tvOS("14") + ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. .library( diff --git a/Sources/SwiftUICharts/AxisView.swift b/Sources/SwiftUICharts/AxisView.swift new file mode 100644 index 0000000..5d39e5f --- /dev/null +++ b/Sources/SwiftUICharts/AxisView.swift @@ -0,0 +1,37 @@ +// +// AxisView.swift +// CardioBot +// +// Created by Majid Jabrayilov on 6/27/20. +// Copyright © 2020 Majid Jabrayilov. All rights reserved. +// +import SwiftUI + +struct AxisView: View { + let bars: [Bar] + + var body: some View { + VStack { + bars.max().map { + Text(String(Int($0.value))) + .foregroundColor(.secondary) + .font(.caption) + } + Spacer() + bars.max().map { + Text(String(Int($0.value / 2))) + .foregroundColor(.secondary) + .font(.caption) + } + Spacer() + } + } +} + +#if DEBUG +struct AxisView_Previews: PreviewProvider { + static var previews: some View { + AxisView(bars: Bar.mock) + } +} +#endif diff --git a/Sources/SwiftUICharts/Bar.swift b/Sources/SwiftUICharts/Bar.swift new file mode 100644 index 0000000..8753c5a --- /dev/null +++ b/Sources/SwiftUICharts/Bar.swift @@ -0,0 +1,98 @@ +// +// Bar.swift +// CardioBot +// +// Created by Majid Jabrayilov on 5/13/20. +// Copyright © 2020 Majid Jabrayilov. All rights reserved. +// +import SwiftUI + +public struct Legend { + let color: Color + let label: LocalizedStringKey + let order: Int + + public init(color: Color, label: LocalizedStringKey, order: Int = 0) { + self.color = color + self.label = label + self.order = order + } +} + +extension Legend: Comparable { + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.order < rhs.order + } +} + +extension Legend: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(color) + } +} + +public struct Bar { + let value: Double + let label: LocalizedStringKey + let legend: Legend + let visible: Bool + + public init(value: Double, label: LocalizedStringKey, legend: Legend, visible: Bool = true) { + self.value = value + self.label = label + self.legend = legend + self.visible = visible + } +} + +extension Bar: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(legend) + hasher.combine(value) + } +} + +extension Bar: Comparable { + public static func < (lhs: Bar, rhs: Bar) -> Bool { + lhs.value < rhs.value + } +} + +#if DEBUG +extension Bar { + static var mock: [Bar] { + let highIntensity = Legend(color: .orange, label: "High Intensity", order: 5) + let buildFitness = Legend(color: .yellow, label: "Build Fitness", order: 4) + let fatBurning = Legend(color: .green, label: "Fat Burning", order: 3) + let warmUp = Legend(color: .blue, label: "Warm Up", order: 2) + let low = Legend(color: .gray, label: "Low", order: 1) + + return [ + .init(value: 70, label: "1", legend: low), + .init(value: 90, label: "2", legend: warmUp), + .init(value: 91, label: "3", legend: warmUp), + .init(value: 92, label: "4", legend: warmUp), + .init(value: 130, label: "5", legend: fatBurning), + .init(value: 124, label: "6", legend: fatBurning), + .init(value: 135, label: "7", legend: fatBurning), + .init(value: 133, label: "8", legend: fatBurning), + .init(value: 136, label: "9", legend: fatBurning), + .init(value: 138, label: "10", legend: fatBurning), + .init(value: 150, label: "11", legend: buildFitness), + .init(value: 151, label: "12", legend: buildFitness), + .init(value: 150, label: "13", legend: buildFitness), + .init(value: 136, label: "14", legend: fatBurning), + .init(value: 135, label: "15", legend: fatBurning), + .init(value: 130, label: "16", legend: fatBurning), + .init(value: 130, label: "17", legend: fatBurning), + .init(value: 150, label: "18", legend: buildFitness), + .init(value: 151, label: "19", legend: buildFitness), + .init(value: 150, label: "20", legend: buildFitness), + .init(value: 160, label: "21", legend: highIntensity), + .init(value: 159, label: "22", legend: highIntensity), + .init(value: 161, label: "23", legend: highIntensity), + .init(value: 158, label: "24", legend: highIntensity), + ] + } +} +#endif diff --git a/Sources/SwiftUICharts/BarChartView.swift b/Sources/SwiftUICharts/BarChartView.swift new file mode 100644 index 0000000..7675bb9 --- /dev/null +++ b/Sources/SwiftUICharts/BarChartView.swift @@ -0,0 +1,67 @@ +// +// BarChartView.swift +// SleepBot +// +// Created by Majid Jabrayilov on 6/21/19. +// Copyright © 2019 Majid Jabrayilov. All rights reserved. +// +import SwiftUI + +public struct BarChartView: View { + let bars: [Bar] + var limit: Bar? + var showAxis = true + var showLabels = true + var labelCount = 3 + var showLegends = true + + public init( + bars: [Bar], + limit: Bar? = nil, + showAxis: Bool = true, + showLabels: Bool = true, + labelCount: Int = 3, + showLegends: Bool = true + ) { + self.bars = bars + self.limit = limit + self.showAxis = showAxis + self.showLabels = showLabels + self.labelCount = labelCount + self.showLegends = showLegends + } + + public var body: some View { + VStack { + HStack(spacing: 0) { + BarsView(bars: bars, limit: limit) + + if showAxis { + AxisView(bars: bars) + .fixedSize(horizontal: true, vertical: false) + } + } + #if os(iOS) + if showLabels { + LabelsView(bars: bars, labelCount: labelCount) + .accessibility(hidden: true) + } + #endif + if showLegends { + LegendView(bars: limit.map { [$0] + bars} ?? bars) + .padding() + .accessibility(hidden: true) + } + } + } +} + +#if DEBUG +struct BarChartView_Previews : PreviewProvider { + static var previews: some View { + let limit = Legend(color: .purple, label: "Trend") + let limitBar = Bar(value: 100, label: "Trend", legend: limit) + return BarChartView(bars: Bar.mock, limit: limitBar) + } +} +#endif diff --git a/Sources/SwiftUICharts/BarsView.swift b/Sources/SwiftUICharts/BarsView.swift new file mode 100644 index 0000000..746866a --- /dev/null +++ b/Sources/SwiftUICharts/BarsView.swift @@ -0,0 +1,76 @@ +// +// BarsView.swift +// CardioBot +// +// Created by Majid Jabrayilov on 6/28/20. +// Copyright © 2020 Majid Jabrayilov. All rights reserved. +// +import SwiftUI + +struct BarsView: View { + let bars: [Bar] + let limit: Bar? + + private var max: Double { + if let max = bars.map({ $0.value }).max(), max > 0 { + return max + } else { + return 1 + } + } + + private var grid: some View { + ChartGrid(bars: bars) + .stroke( + Color.secondary, + style: StrokeStyle( + lineWidth: 1, + lineCap: .round, + lineJoin: .round, + miterLimit: 0, + dash: [1, 8], + dashPhase: 1 + ) + ) + } + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .bottomTrailing) { + grid + HStack(alignment: .bottom, spacing: bars.count > 30 ? 0 : 2) { + ForEach(bars.filter(\.visible), id: \.self) { bar in + Capsule() + .fill(bar.legend.color) + .accessibility(label: Text(bar.label)) + .accessibility(value: Text(bar.legend.label)) + .frame(height: CGFloat(bar.value / self.max) * geometry.size.height) + } + } + + limit.map { limit in + ZStack { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .frame(height: 4) + .foregroundColor(limit.legend.color) + Text(limit.label) + .padding(.horizontal) + .foregroundColor(.white) + .background(limit.legend.color) + .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) + } + .alignmentGuide(VerticalAlignment.bottom) { $0[.bottom] - $0.height / 2 } + .offset(y: CGFloat(limit.value / self.max) * -geometry.size.height) + } + } + }.frame(minHeight: 100) + } +} + +#if DEBUG +struct BarsView_Previews: PreviewProvider { + static var previews: some View { + BarsView(bars: Bar.mock, limit: nil) + } +} +#endif diff --git a/Sources/SwiftUICharts/ChartGrid.swift b/Sources/SwiftUICharts/ChartGrid.swift new file mode 100644 index 0000000..956e588 --- /dev/null +++ b/Sources/SwiftUICharts/ChartGrid.swift @@ -0,0 +1,44 @@ +// +// ChartGrid.swift +// CardioBot +// +// Created by Majid Jabrayilov on 7/4/20. +// Copyright © 2020 Majid Jabrayilov. All rights reserved. +// + +import SwiftUI + +struct ChartGrid: Shape { + let bars: [Bar] + + func path(in rect: CGRect) -> Path { + Path { path in + path.move(to: CGPoint(x: 0, y: 0)) + path.addLine(to: CGPoint(x: rect.width, y: 0)) + + path.move(to: CGPoint(x: 0, y: rect.height)) + path.addLine(to: CGPoint(x: rect.width, y: rect.height)) + + path.move(to: CGPoint(x: 0, y: rect.height / 2)) + path.addLine(to: CGPoint(x: rect.width, y: rect.height / 2)) + } + } +} + +#if DEBUG +struct BarChartGrid_Previews: PreviewProvider { + static var previews: some View { + ChartGrid(bars: Bar.mock) + .stroke( + style: StrokeStyle( + lineWidth: 1, + lineCap: .round, + lineJoin: .round, + miterLimit: 0, + dash: [1, 8], + dashPhase: 1 + ) + ) + } +} +#endif diff --git a/Sources/SwiftUICharts/HorizontalBarChart.swift b/Sources/SwiftUICharts/HorizontalBarChart.swift new file mode 100644 index 0000000..ace3442 --- /dev/null +++ b/Sources/SwiftUICharts/HorizontalBarChart.swift @@ -0,0 +1,59 @@ +// +// HorizontalBarChart.swift +// CardioBot +// +// Created by Majid Jabrayilov on 5/12/20. +// Copyright © 2020 Majid Jabrayilov. All rights reserved. +// +import SwiftUI + +public struct HorizontalBarChart: View { + let bars: [Bar] + + public init(bars: [Bar]) { + self.bars = bars + } + + var max: Double { bars.max()?.value ?? 0} + + public var body: some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(bars, id: \.self) { bar in + HStack { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .foregroundColor(bar.legend.color) + .frame(width: CGFloat(bar.value / self.max) * 100, height: 16) + + Circle() + .foregroundColor(bar.legend.color) + .frame(width: 8, height: 8) + + Text(bar.legend.label) + Text(", ") + Text(bar.label) + } + } + } + } +} + +struct HorizontalBarChart_Previews: PreviewProvider { + static var previews: some View { + let veryLow = Legend(color: .black, label: "Very Low") + let low = Legend(color: .gray, label: "Low") + let resting = Legend(color: .blue, label: "Resting") + let highResting = Legend(color: .orange, label: "High Resting") + let elevated = Legend(color: .red, label: "Elevated") + + let bars: [Bar] = [ + Bar(value: 0.1, label: "10%", legend: veryLow), + Bar(value: 0.15, label: "15%", legend: low), + Bar(value: 0.60, label: "60%", legend: resting), + Bar(value: 0.1, label: "10%", legend: highResting), + Bar(value: 0.05, label: "5%", legend: elevated) + ] + + return List { + return HorizontalBarChart(bars: bars) + } + .listStyle(InsetGroupedListStyle()) + } +} diff --git a/Sources/SwiftUICharts/LabelsView.swift b/Sources/SwiftUICharts/LabelsView.swift new file mode 100644 index 0000000..8c62f3a --- /dev/null +++ b/Sources/SwiftUICharts/LabelsView.swift @@ -0,0 +1,45 @@ +// +// LabelsView.swift +// CardioBot +// +// Created by Majid Jabrayilov on 6/27/20. +// Copyright © 2020 Majid Jabrayilov. All rights reserved. +// +import SwiftUI + +struct LabelsView: View { + let bars: [Bar] + var labelCount = 3 + + private var threshold: Int { + let threshold = bars.count / labelCount + + switch threshold { + case 0...1: return 1 + case 1...2: return 2 + default: return threshold + } + } + + var body: some View { + HStack(spacing: 0) { + ForEach(bars.indexed(), id: \.1.self) { index, bar in + if index % self.threshold == 0 { + Text(bar.label) + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + .font(.caption) + Spacer() + } + } + } + } +} + +#if DEBUG +struct LabelsView_Previews: PreviewProvider { + static var previews: some View { + LabelsView(bars: Bar.mock) + } +} +#endif diff --git a/Sources/SwiftUICharts/LegendView.swift b/Sources/SwiftUICharts/LegendView.swift new file mode 100644 index 0000000..ecdb963 --- /dev/null +++ b/Sources/SwiftUICharts/LegendView.swift @@ -0,0 +1,38 @@ +// +// LegendView.swift +// CardioBot +// +// Created by Majid Jabrayilov on 6/27/20. +// Copyright © 2020 Majid Jabrayilov. All rights reserved. +// +import SwiftUI + +struct LegendView: View { + private let legends: [Legend] + + init(bars: [Bar]) { + legends = Array(Set(bars.map { $0.legend })).sorted() + } + + var body: some View { + LazyVGrid(columns: [.init(.adaptive(minimum: 100))], alignment: .leading) { + ForEach(legends, id: \.color) { legend in + HStack(alignment: .center) { + Circle() + .fill(legend.color) + .frame(width: 16, height: 16) + + Text(legend.label) + } + } + } + } +} + +#if DEBUG +struct LegendView_Previews: PreviewProvider { + static var previews: some View { + LegendView(bars: Bar.mock) + } +} +#endif diff --git a/Sources/SwiftUICharts/LineChartView.swift b/Sources/SwiftUICharts/LineChartView.swift new file mode 100644 index 0000000..5c4820d --- /dev/null +++ b/Sources/SwiftUICharts/LineChartView.swift @@ -0,0 +1,85 @@ +// +// LineChartView.swift +// CardioBot +// +// Created by Majid Jabrayilov on 6/27/20. +// Copyright © 2020 Majid Jabrayilov. All rights reserved. +// +import SwiftUI + +public struct LineChartView: View { + let bars: [Bar] + + public init(bars: [Bar]) { + self.bars = bars + } + + private var gradient: LinearGradient { + let colors = bars.map(\.legend).map(\.color) + return LinearGradient( + gradient: Gradient(colors: colors), + startPoint: .leading, + endPoint: .trailing + ) + } + + public var body: some View { + VStack { + HStack(spacing: 0) { + ZStack { + ChartGrid(bars: bars) + .stroke( + Color.secondary, + style: StrokeStyle( + lineWidth: 1, + lineCap: .round, + lineJoin: .round, + miterLimit: 0, + dash: [1, 8], + dashPhase: 1 + ) + ) + LineChart(bars: bars) + .fill(gradient) + .frame(minHeight: 100) + } + AxisView(bars: bars) + } + LabelsView(bars: bars) + LegendView(bars: bars) + } + } +} + +private struct LineChart: Shape { + let bars: [Bar] + var closePath: Bool = true + + func path(in rect: CGRect) -> Path { + Path { path in + let start = CGFloat(bars.first?.value ?? 0) / CGFloat(bars.max()?.value ?? 1) + path.move(to: CGPoint(x: 0, y: rect.height - rect.height * start)) + let stepX = rect.width / CGFloat(bars.count) + var currentX: CGFloat = 0 + bars.forEach { + currentX += stepX + let y = CGFloat($0.value / (bars.max()?.value ?? 1)) * rect.height + path.addLine(to: CGPoint(x: currentX, y: rect.height - y)) + } + + if closePath { + path.addLine(to: CGPoint(x: currentX, y: rect.height)) + path.addLine(to: CGPoint(x: 0, y: rect.height)) + path.closeSubpath() + } + } + } +} + +#if DEBUG +struct LineChartView_Previews: PreviewProvider { + static var previews: some View { + LineChartView(bars: Bar.mock) + } +} +#endif diff --git a/Sources/SwiftUICharts/RandomAccessCollection.swift b/Sources/SwiftUICharts/RandomAccessCollection.swift new file mode 100644 index 0000000..5437be2 --- /dev/null +++ b/Sources/SwiftUICharts/RandomAccessCollection.swift @@ -0,0 +1,13 @@ +// +// File.swift +// +// +// Created by Majid Jabrayilov on 20.07.20. +// +import Foundation + +extension RandomAccessCollection { + func indexed() -> Array<(offset: Int, element: Element)> { + Array(enumerated()) + } +} diff --git a/Sources/SwiftUICharts/SwiftUICharts.swift b/Sources/SwiftUICharts/SwiftUICharts.swift deleted file mode 100644 index 17e9a07..0000000 --- a/Sources/SwiftUICharts/SwiftUICharts.swift +++ /dev/null @@ -1,3 +0,0 @@ -struct SwiftUICharts { - var text = "Hello, World!" -} diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift index 8e7efab..f844c2c 100644 --- a/Tests/LinuxMain.swift +++ b/Tests/LinuxMain.swift @@ -1,5 +1,4 @@ import XCTest - import SwiftUIChartsTests var tests = [XCTestCaseEntry]() diff --git a/Tests/SwiftUIChartsTests/SwiftUIChartsTests.swift b/Tests/SwiftUIChartsTests/SwiftUIChartsTests.swift index 30c51e2..f128bb3 100644 --- a/Tests/SwiftUIChartsTests/SwiftUIChartsTests.swift +++ b/Tests/SwiftUIChartsTests/SwiftUIChartsTests.swift @@ -2,14 +2,7 @@ import XCTest @testable import SwiftUICharts final class SwiftUIChartsTests: XCTestCase { - func testExample() { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. - XCTAssertEqual(SwiftUICharts().text, "Hello, World!") - } - static var allTests = [ - ("testExample", testExample), + static var allTests: [(String, () -> Void)] = [ ] }