Skip to content

Commit ebaaf81

Browse files
authored
feat: new protocol for chained functions, and added support for expli… (#252)
* feat: new protocol for chained functions, and added support for explicit Y ranges. X coming as well * feat: add new axis interface (#253)
1 parent d7e9802 commit ebaaf81

40 files changed

+730
-379
lines changed
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import SwiftUI
2+
3+
public struct AxisLabels<Content: View>: View {
4+
struct YAxisViewKey: ViewPreferenceKey { }
5+
struct ChartViewKey: ViewPreferenceKey { }
6+
7+
var axisLabelsData = AxisLabelsData()
8+
var axisLabelsStyle = AxisLabelsStyle()
9+
10+
@State private var yAxisWidth: CGFloat = 25
11+
@State private var chartWidth: CGFloat = 0
12+
@State private var chartHeight: CGFloat = 0
13+
14+
let content: () -> Content
15+
16+
public init(@ViewBuilder content: @escaping () -> Content) {
17+
self.content = content
18+
}
19+
20+
var yAxis: some View {
21+
VStack(spacing: 0.0) {
22+
ForEach(Array(axisLabelsData.axisYLabels.reversed().enumerated()), id: \.element) { index, axisYData in
23+
Text(axisYData)
24+
.font(axisLabelsStyle.axisFont)
25+
.foregroundColor(axisLabelsStyle.axisFontColor)
26+
.frame(height: getYHeight(index: index,
27+
chartHeight: chartHeight,
28+
count: axisLabelsData.axisYLabels.count),
29+
alignment: getYAlignment(index: index, count: axisLabelsData.axisYLabels.count))
30+
}
31+
}
32+
.padding([.leading, .trailing], 4.0)
33+
.background(ViewGeometry<YAxisViewKey>())
34+
.onPreferenceChange(YAxisViewKey.self) { value in
35+
yAxisWidth = value.first?.size.width ?? 0.0
36+
}
37+
}
38+
39+
func xAxis(chartWidth: CGFloat) -> some View {
40+
HStack(spacing: 0.0) {
41+
ForEach(Array(axisLabelsData.axisXLabels.enumerated()), id: \.element) { index, axisXData in
42+
Text(axisXData)
43+
.font(axisLabelsStyle.axisFont)
44+
.foregroundColor(axisLabelsStyle.axisFontColor)
45+
.frame(width: chartWidth / CGFloat(axisLabelsData.axisXLabels.count - 1))
46+
}
47+
}
48+
.frame(height: 24.0, alignment: .top)
49+
}
50+
51+
var chart: some View {
52+
self.content()
53+
.background(ViewGeometry<ChartViewKey>())
54+
.onPreferenceChange(ChartViewKey.self) { value in
55+
chartWidth = value.first?.size.width ?? 0.0
56+
chartHeight = value.first?.size.height ?? 0.0
57+
}
58+
}
59+
60+
public var body: some View {
61+
VStack(spacing: 0.0) {
62+
HStack {
63+
if axisLabelsStyle.axisLabelsYPosition == .leading {
64+
yAxis
65+
} else {
66+
Spacer(minLength: yAxisWidth)
67+
}
68+
chart
69+
if axisLabelsStyle.axisLabelsYPosition == .leading {
70+
Spacer(minLength: yAxisWidth)
71+
} else {
72+
yAxis
73+
}
74+
}
75+
xAxis(chartWidth: chartWidth)
76+
}
77+
}
78+
79+
private func getYHeight(index: Int, chartHeight: CGFloat, count: Int) -> CGFloat {
80+
if index == 0 || index == count - 1 {
81+
return chartHeight / (CGFloat(count - 1) * 2) + 10
82+
}
83+
84+
return chartHeight / CGFloat(count - 1)
85+
}
86+
87+
private func getYAlignment(index: Int, count: Int) -> Alignment {
88+
if index == 0 {
89+
return .top
90+
}
91+
92+
if index == count - 1 {
93+
return .bottom
94+
}
95+
96+
return .center
97+
}
98+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import SwiftUI
2+
3+
extension AxisLabels {
4+
public func setAxisYLabels(_ labels: [String],
5+
position: AxisLabelsYPosition = .leading) -> AxisLabels {
6+
self.axisLabelsData.axisYLabels = labels
7+
self.axisLabelsStyle.axisLabelsYPosition = position
8+
return self
9+
}
10+
11+
public func setAxisXLabels(_ labels: [String]) -> AxisLabels {
12+
self.axisLabelsData.axisXLabels = labels
13+
return self
14+
}
15+
16+
public func setAxisYLabels(_ labels: [(Double, String)],
17+
range: ClosedRange<Int>,
18+
position: AxisLabelsYPosition = .leading) -> AxisLabels {
19+
let overreach = range.overreach + 1
20+
var labelArray = [String](repeating: "", count: overreach)
21+
labels.forEach {
22+
let index = Int($0.0) - range.lowerBound
23+
if labelArray[safe: index] != nil {
24+
labelArray[index] = $0.1
25+
}
26+
}
27+
28+
self.axisLabelsData.axisYLabels = labelArray
29+
self.axisLabelsStyle.axisLabelsYPosition = position
30+
31+
return self
32+
}
33+
34+
public func setAxisXLabels(_ labels: [(Double, String)], range: ClosedRange<Int>) -> AxisLabels {
35+
let overreach = range.overreach + 1
36+
var labelArray = [String](repeating: "", count: overreach)
37+
labels.forEach {
38+
let index = Int($0.0) - range.lowerBound
39+
if labelArray[safe: index] != nil {
40+
labelArray[index] = $0.1
41+
}
42+
}
43+
44+
self.axisLabelsData.axisXLabels = labelArray
45+
return self
46+
}
47+
48+
public func setColor(_ color: Color) -> AxisLabels {
49+
self.axisLabelsStyle.axisFontColor = color
50+
return self
51+
}
52+
53+
public func setFont(_ font: Font) -> AxisLabels {
54+
self.axisLabelsStyle.axisFont = font
55+
return self
56+
}
57+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import Foundation
2+
3+
public enum AxisLabelsYPosition {
4+
case leading
5+
case trailing
6+
}
7+
8+
public enum AxisLabelsXPosition {
9+
case top
10+
case bottom
11+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import SwiftUI
2+
3+
public final class AxisLabelsStyle: ObservableObject {
4+
@Published public var axisFont: Font = .callout
5+
@Published public var axisFontColor: Color = .primary
6+
@Published var axisLabelsYPosition: AxisLabelsYPosition = .leading
7+
@Published var axisLabelsXPosition: AxisLabelsXPosition = .bottom
8+
public init() {
9+
// no-op
10+
}
11+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import SwiftUI
2+
3+
public final class AxisLabelsData: ObservableObject {
4+
@Published public var axisYLabels: [String] = []
5+
@Published public var axisXLabels: [String] = []
6+
7+
public init() {
8+
// no-op
9+
}
10+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import SwiftUI
22

33
/// Protocol for any type of chart, to get access to underlying data
4-
public protocol ChartBase {
4+
public protocol ChartBase: View {
55
var chartData: ChartData { get }
66
}

Sources/SwiftUICharts/Base/Chart/ChartData.swift

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,37 +2,70 @@ import SwiftUI
22

33
/// An observable wrapper for an array of data for use in any chart
44
public class ChartData: ObservableObject {
5-
@Published public var data: [(String, Double)] = []
5+
@Published public var data: [(Double, Double)] = []
6+
public var rangeY: ClosedRange<Double>?
7+
public var rangeX: ClosedRange<Double>?
68

79
var points: [Double] {
8-
data.map { $0.1 }
10+
data.filter { rangeX?.contains($0.0) ?? true }.map { $0.1 }
911
}
1012

11-
var values: [String] {
12-
data.map { $0.0 }
13+
var values: [Double] {
14+
data.filter { rangeX?.contains($0.0) ?? true }.map { $0.0 }
1315
}
1416

1517
var normalisedPoints: [Double] {
1618
let absolutePoints = points.map { abs($0) }
17-
return points.map { $0 / (absolutePoints.max() ?? 1.0) }
19+
var maxPoint = absolutePoints.max()
20+
if let rangeY = rangeY {
21+
maxPoint = Double(rangeY.overreach)
22+
return points.map { ($0 - rangeY.lowerBound) / (maxPoint ?? 1.0) }
23+
}
24+
25+
return points.map { $0 / (maxPoint ?? 1.0) }
1826
}
1927

20-
var normalisedRange: Double {
21-
(normalisedPoints.max() ?? 0.0) - (normalisedPoints.min() ?? 0.0)
28+
var normalisedValues: [Double] {
29+
let absoluteValues = values.map { abs($0) }
30+
var maxValue = absoluteValues.max()
31+
if let rangeX = rangeX {
32+
maxValue = Double(rangeX.overreach)
33+
return values.map { ($0 - rangeX.lowerBound) / (maxValue ?? 1.0) }
34+
}
35+
36+
return values.map { $0 / (maxValue ?? 1.0) }
37+
}
38+
39+
var normalisedData: [(Double, Double)] {
40+
Array(zip(normalisedValues, normalisedPoints))
41+
}
42+
43+
var normalisedYRange: Double {
44+
return rangeY == nil ? (normalisedPoints.max() ?? 0.0) - (normalisedPoints.min() ?? 0.0) : 1
45+
}
46+
47+
var normalisedXRange: Double {
48+
return rangeX == nil ? (normalisedValues.max() ?? 0.0) - (normalisedValues.min() ?? 0.0) : 1
2249
}
2350

2451
var isInNegativeDomain: Bool {
25-
(points.min() ?? 0.0) < 0
52+
if let rangeY = rangeY {
53+
return rangeY.lowerBound < 0
54+
}
55+
56+
return (points.min() ?? 0.0) < 0
2657
}
2758

2859
/// Initialize with data array
2960
/// - Parameter data: Array of `Double`
30-
public init(_ data: [Double]) {
31-
self.data = data.map { ("", $0) }
61+
public init(_ data: [Double], rangeY: ClosedRange<FloatLiteralType>? = nil) {
62+
self.data = data.enumerated().map{ (index, value) in (Double(index), value) }
63+
self.rangeY = rangeY
3264
}
3365

34-
public init(_ data: [(String, Double)]) {
66+
public init(_ data: [(Double, Double)], rangeY: ClosedRange<FloatLiteralType>? = nil) {
3567
self.data = data
68+
self.rangeY = rangeY
3669
}
3770

3871
public init() {
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import SwiftUI
2+
3+
public struct ViewGeometry<T>: View where T: PreferenceKey {
4+
public var body: some View {
5+
GeometryReader { geometry in
6+
Color.clear
7+
.preference(key: T.self, value: [ViewSizeData(size: geometry.size)] as! T.Value)
8+
}
9+
}
10+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import SwiftUI
2+
3+
public protocol ViewPreferenceKey: PreferenceKey {
4+
typealias Value = [ViewSizeData]
5+
}
6+
7+
public extension ViewPreferenceKey {
8+
static var defaultValue: [ViewSizeData] {
9+
[]
10+
}
11+
12+
static func reduce(value: inout [ViewSizeData], nextValue: () -> [ViewSizeData]) {
13+
value.append(contentsOf: nextValue())
14+
}
15+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import SwiftUI
2+
3+
public struct ViewSizeData: Identifiable, Equatable, Hashable {
4+
public let id: UUID = UUID()
5+
public let size: CGSize
6+
7+
public static func == (lhs: Self, rhs: Self) -> Bool {
8+
return lhs.id == rhs.id
9+
}
10+
11+
public func hash(into hasher: inout Hasher) {
12+
hasher.combine(id)
13+
}
14+
}

Sources/SwiftUICharts/Base/Extensions/Array+Extension.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,10 @@ extension Array where Element == ColorGradient {
1717
return self[index]
1818
}
1919
}
20+
21+
extension Collection {
22+
/// Returns the element at the specified index if it is within bounds, otherwise nil.
23+
subscript (safe index: Index) -> Element? {
24+
return indices.contains(index) ? self[index] : nil
25+
}
26+
}
Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,23 @@
11
import SwiftUI
22

3-
extension View where Self: ChartBase {
4-
5-
/// Set data for a chart
6-
/// - Parameter data: array of `Double`
7-
/// - Returns: modified `View` with data attached
8-
public func data(_ data: [Double]) -> some View {
9-
chartData.data = data.map { ("", $0) }
3+
extension ChartBase {
4+
public func data(_ data: [Double]) -> some ChartBase {
5+
chartData.data = data.enumerated().map{ (index, value) in (Double(index), value) }
106
return self
11-
.environmentObject(chartData)
12-
.environmentObject(ChartValue())
137
}
148

15-
public func data(_ data: [(String, Double)]) -> some View {
9+
public func data(_ data: [(Double, Double)]) -> some ChartBase {
1610
chartData.data = data
1711
return self
18-
.environmentObject(chartData)
19-
.environmentObject(ChartValue())
12+
}
13+
14+
public func rangeY(_ range: ClosedRange<FloatLiteralType>) -> some ChartBase{
15+
chartData.rangeY = range
16+
return self
17+
}
18+
19+
public func rangeX(_ range: ClosedRange<FloatLiteralType>) -> some ChartBase{
20+
chartData.rangeX = range
21+
return self
2022
}
2123
}

0 commit comments

Comments
 (0)