├── .gitignore ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── LightChart │ ├── ChartType.swift │ ├── Charts │ ├── CurvedChart.swift │ └── LineChart.swift │ ├── DataRepresentable.swift │ ├── LightChart.swift │ └── Math.swift └── Tests ├── LightChartTests ├── LightChartTests.swift └── XCTestManifests.swift └── LinuxMain.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | .swiftpm 7 | .swift-version 8 | xcuserdata 9 | Package.resolved -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Alexey Pichukov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "LightChart", 8 | platforms: [ 9 | .iOS(.v13), 10 | .watchOS(.v6), 11 | .macOS(.v10_15) 12 | ], 13 | products: [ 14 | .library( 15 | name: "LightChart", 16 | targets: ["LightChart"]), 17 | ], 18 | dependencies: [], 19 | targets: [ 20 | .target( 21 | name: "LightChart", 22 | dependencies: []), 23 | .testTarget( 24 | name: "LightChartTests", 25 | dependencies: ["LightChart"]), 26 | ] 27 | ) 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 |
5 | 6 | 7 | 8 | **LightChart** is a lightweight **SwiftUI** package with line charts implementation. You can use it when you need only a chart that will perfectly fit into your View. It doesn't have any interaction, titles, different types of diagram or anything else, just a representation of your data set. 9 | 10 | Currently `LightChart` have only `line` type of chart with sharp corners or a curved one. That's how it looks in the real application on currency exchange rates example: 11 | 12 |
13 | 14 |
15 | 16 | ## Installation 17 | 18 | It's a Swift Package, so you need to do the following: 19 | 20 | - Open `File` in Xcode menu 21 | - Open `Swift Packages` 22 | - Choose `Add Package Dependency...` option 23 | - In the `Enter package repository URL` field paste this URL: `https://github.com/pichukov/LightChart` 24 | - Choose any existing version or a `master` branch option 25 | 26 | ## Usage 27 | 28 | Add `import LightChart` 29 | Add `LightChartView` into your SwiftUI code 30 | ```swift 31 | LightChartView(data: [4, 8, 12, 10, 25]) 32 | ``` 33 | By default it will draw a red line chart: 34 | 35 |
36 | 37 |
38 | 39 | To customize it you can use several properties: 40 | 41 | #### type 42 | The type of chart, it's an `enum` with two cases: 43 | - `.line` is a default type that will draw a chart presented above 44 | - `.curved` will draw a curved chart, for example: 45 | ```swift 46 | LightChartView(data: [2, 17, 9, 23, 10], type: .curved) 47 | ``` 48 |
49 | 50 |
51 | 52 | #### visualType 53 | The visual part of the chart. An `enum` with three cases: 54 | - `.outline(color: Color, lineWidth: CGFloat)` to specify a `color` and `lineWidth` for `stroke` 55 | ```swift 56 | LightChartView(data: [2, 17, 9, 23, 10], 57 | type: .curved, 58 | visualType: .outline(color: .green, lineWidth: 5)) 59 | ``` 60 |
61 | 62 |
63 | 64 | - `.filled(color: Color, lineWidth: CGFloat)` use stroke color to fill the chart with a gradient 65 | ```swift 66 | LightChartView(data: [2, 17, 9, 23, 10], 67 | type: .curved, 68 | visualType: .filled(color: .green, lineWidth: 5)) 69 | ``` 70 |
71 | 72 |
73 | 74 | - `.customFilled(color: Color, lineWidth: CGFloat, fillGradient: LinearGradient)` also provides an option to change the fill gradient 75 | ```swift 76 | LightChartView(data: [2, 17, 9, 23, 10], 77 | type: .curved, 78 | visualType: .customFilled(color: .red, 79 | lineWidth: 3, 80 | fillGradient: LinearGradient( 81 | gradient: .init(colors: [Color.orange.opacity(0.7), Color.orange.opacity(0.1)]), 82 | startPoint: .init(x: 0.5, y: 1), 83 | endPoint: .init(x: 0.5, y: 0) 84 | ))) 85 | ``` 86 |
87 | 88 |
89 | 90 | #### offset 91 | By default the `offset` is `0` and it means that the chart takes up the entire area of the parent view. You can move the chart line up with changing the `offset` value. For example if you will use the `offset: 0.2` you chart line will move up and take only 80% of the parent view 92 | ```swift 93 | LightChartView(data: [2, 17, 9, 23, 10], 94 | type: .curved, 95 | visualType: .filled(color: .green, lineWidth: 3), 96 | offset: 0.2) 97 | ``` 98 |
99 | 100 |
101 | As you can see on picture above the offset effects only the chart line but not the fill gradient 102 | 103 | #### currentValueLineType 104 | There is an option to add a horizontal line that will point the last value of the data set. A `currentValueLineType` is an `enum` that has three cases: 105 | - `.none` is a default value that doesn't show any line 106 | - `.line(color: Color, lineWidth: CGFloat)` adds a line with `color` and `lineWidth` for a `stroke` 107 | ```swift 108 | LightChartView(data: [2, 17, 9, 23, 10], 109 | type: .curved, 110 | visualType: .filled(color: .green, lineWidth: 3), 111 | offset: 0.2, 112 | currentValueLineType: .line(color: .gray, lineWidth: 1)) 113 | ``` 114 |
115 | 116 |
117 | 118 | - `.dash(color: Color, lineWidth: CGFloat, dash: [CGFloat])` adds a dashed line 119 | ```swift 120 | LightChartView(data: [2, 17, 9, 23, 10], 121 | type: .curved, 122 | visualType: .filled(color: .green, lineWidth: 3), 123 | offset: 0.2, 124 | currentValueLineType: .dash(color: .gray, lineWidth: 1, dash: [5])) 125 | ``` 126 |
127 | 128 |
129 | -------------------------------------------------------------------------------- /Sources/LightChart/ChartType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChartType.swift 3 | // 4 | // 5 | // Created by Alexey Pichukov on 19.08.2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public enum ChartType { 11 | case line 12 | case curved 13 | } 14 | 15 | public enum ChartVisualType { 16 | case outline(color: Color, lineWidth: CGFloat) 17 | case filled(color: Color, lineWidth: CGFloat) 18 | case customFilled(color: Color, lineWidth: CGFloat, fillGradient: LinearGradient) 19 | } 20 | 21 | public enum CurrentValueLineType { 22 | case none 23 | case line(color: Color, lineWidth: CGFloat) 24 | case dash(color: Color, lineWidth: CGFloat, dash: [CGFloat]) 25 | } 26 | -------------------------------------------------------------------------------- /Sources/LightChart/Charts/CurvedChart.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Alexey Pichukov on 20.08.2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct CurvedChart: View { 11 | 12 | private let data: [Double] 13 | private let frame: CGRect 14 | private let offset: Double 15 | private let type: ChartVisualType 16 | private let currentValueLineType: CurrentValueLineType 17 | private var points: [CGPoint] = [] 18 | 19 | /// Creates a new `CurvedChart` 20 | /// 21 | /// - Parameters: 22 | /// - data: A data set that should be presented on the chart 23 | /// - frame: A frame from the parent view 24 | /// - visualType: A type of chart, `.outline` by default 25 | /// - offset: An offset for the chart, a space below the chart in percentage (0 - 1) 26 | /// For example `offset: 0.2` means that the chart will occupy 80% of the upper 27 | /// part of the view 28 | /// - currentValueLineType: A type of current value line (`none` for no line on chart) 29 | public init(data: [Double], 30 | frame: CGRect, 31 | visualType: ChartVisualType = .outline(color: .red, lineWidth: 2), 32 | offset: Double = 0, 33 | currentValueLineType: CurrentValueLineType = .none) { 34 | self.data = data 35 | self.frame = frame 36 | self.type = visualType 37 | self.offset = offset 38 | self.currentValueLineType = currentValueLineType 39 | self.points = points(forData: data, 40 | frame: frame, 41 | offset: offset, 42 | lineWidth: lineWidth(visualType: visualType)) 43 | } 44 | 45 | public var body: some View { 46 | ZStack { 47 | chart 48 | .rotationEffect(.degrees(180), anchor: .center) 49 | .rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0)) 50 | .drawingGroup() 51 | line 52 | .rotationEffect(.degrees(180), anchor: .center) 53 | .rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0)) 54 | .drawingGroup() 55 | } 56 | } 57 | 58 | private var chart: some View { 59 | switch type { 60 | case .outline(let color, let lineWidth): 61 | return AnyView(curvedPath(points: points) 62 | .stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineJoin: .round))) 63 | case .filled(let color, let lineWidth): 64 | return AnyView(ZStack { 65 | curvedPathGradient(points: points) 66 | .fill(LinearGradient( 67 | gradient: .init(colors: [color.opacity(0.2), color.opacity(0.02)]), 68 | startPoint: .init(x: 0.5, y: 1), 69 | endPoint: .init(x: 0.5, y: 0) 70 | )) 71 | curvedPath(points: points) 72 | .stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineJoin: .round)) 73 | }) 74 | case .customFilled(let color, let lineWidth, let fillGradient): 75 | return AnyView(ZStack { 76 | curvedPathGradient(points: points) 77 | .fill(fillGradient) 78 | curvedPath(points: points) 79 | .stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineJoin: .round)) 80 | }) 81 | } 82 | } 83 | 84 | private var line: some View { 85 | switch currentValueLineType { 86 | case .none: 87 | return AnyView(EmptyView()) 88 | case .line(let color, let lineWidth): 89 | return AnyView( 90 | currentValueLinePath(points: points) 91 | .stroke(color, style: StrokeStyle(lineWidth: lineWidth)) 92 | ) 93 | case .dash(let color, let lineWidth, let dash): 94 | return AnyView( 95 | currentValueLinePath(points: points) 96 | .stroke(color, style: StrokeStyle(lineWidth: lineWidth, dash: dash)) 97 | ) 98 | } 99 | } 100 | 101 | // MARK: private functions 102 | 103 | private func curvedPath(points: [CGPoint]) -> Path { 104 | func mid(_ point1: CGPoint, _ point2: CGPoint) -> CGPoint { 105 | return CGPoint(x: (point1.x + point2.x) / 2, y:(point1.y + point2.y) / 2) 106 | } 107 | 108 | func control(_ point1: CGPoint, _ point2: CGPoint) -> CGPoint { 109 | var controlPoint = mid(point1, point2) 110 | let delta = abs(point2.y - controlPoint.y) 111 | 112 | if point1.y < point2.y { 113 | controlPoint.y += delta 114 | } else if point1.y > point2.y { 115 | controlPoint.y -= delta 116 | } 117 | 118 | return controlPoint 119 | } 120 | 121 | var path = Path() 122 | guard points.count > 1 else { 123 | return path 124 | } 125 | 126 | var startPoint = points[0] 127 | path.move(to: startPoint) 128 | 129 | guard points.count > 2 else { 130 | path.addLine(to: points[1]) 131 | return path 132 | } 133 | 134 | for i in 1.. Path { 148 | var path = curvedPath(points: points) 149 | guard let lastPoint = points.last else { 150 | return path 151 | } 152 | path.addLine(to: CGPoint(x: lastPoint.x, y: 0)) 153 | path.addLine(to: CGPoint(x: 0, y: 0)) 154 | path.addLine(to: CGPoint(x: 0, y: points[0].y)) 155 | 156 | return path 157 | } 158 | 159 | private func currentValueLinePath(points: [CGPoint]) -> Path { 160 | var path = Path() 161 | guard let lastPoint = points.last else { 162 | return path 163 | } 164 | path.move(to: CGPoint(x: 0, y: lastPoint.y)) 165 | path.addLine(to: lastPoint) 166 | return path 167 | } 168 | } 169 | 170 | extension CurvedChart: DataRepresentable { } 171 | -------------------------------------------------------------------------------- /Sources/LightChart/Charts/LineChart.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LineChart.swift 3 | // 4 | // 5 | // Created by Alexey Pichukov on 19.08.2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct LineChart: View { 11 | 12 | private let data: [Double] 13 | private let frame: CGRect 14 | private let offset: Double 15 | private let type: ChartVisualType 16 | private let currentValueLineType: CurrentValueLineType 17 | private var points: [CGPoint] = [] 18 | 19 | /// Creates a new `LineChart` 20 | /// 21 | /// - Parameters: 22 | /// - data: A data set that should be presented on the chart 23 | /// - frame: A frame from the parent view 24 | /// - visualType: A type of chart, `.outline` by default 25 | /// - offset: An offset for the chart, a space below the chart in percentage (0 - 1) 26 | /// For example `offset: 0.2` means that the chart will occupy 80% of the upper 27 | /// part of the view 28 | /// - currentValueLineType: A type of current value line (`none` for no line on chart) 29 | public init(data: [Double], 30 | frame: CGRect, 31 | visualType: ChartVisualType = .outline(color: .red, lineWidth: 2), 32 | offset: Double = 0, 33 | currentValueLineType: CurrentValueLineType = .none) { 34 | self.data = data 35 | self.frame = frame 36 | self.type = visualType 37 | self.offset = offset 38 | self.currentValueLineType = currentValueLineType 39 | self.points = points(forData: data, 40 | frame: frame, 41 | offset: offset, 42 | lineWidth: lineWidth(visualType: visualType)) 43 | } 44 | 45 | public var body: some View { 46 | ZStack { 47 | chart 48 | .rotationEffect(.degrees(180), anchor: .center) 49 | .rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0)) 50 | .drawingGroup() 51 | line 52 | .rotationEffect(.degrees(180), anchor: .center) 53 | .rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0)) 54 | .drawingGroup() 55 | } 56 | } 57 | 58 | private var chart: some View { 59 | switch type { 60 | case .outline(let color, let lineWidth): 61 | return AnyView(linePath(points: points) 62 | .stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineJoin: .round))) 63 | case .filled(let color, let lineWidth): 64 | return AnyView(ZStack { 65 | linePathGradient(points: points) 66 | .fill(LinearGradient( 67 | gradient: .init(colors: [color.opacity(0.2), color.opacity(0.02)]), 68 | startPoint: .init(x: 0.5, y: 1), 69 | endPoint: .init(x: 0.5, y: 0) 70 | )) 71 | linePath(points: points) 72 | .stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineJoin: .round)) 73 | }) 74 | case .customFilled(let color, let lineWidth, let fillGradient): 75 | return AnyView(ZStack { 76 | linePathGradient(points: points) 77 | .fill(fillGradient) 78 | linePath(points: points) 79 | .stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineJoin: .round)) 80 | }) 81 | } 82 | } 83 | 84 | private var line: some View { 85 | switch currentValueLineType { 86 | case .none: 87 | return AnyView(EmptyView()) 88 | case .line(let color, let lineWidth): 89 | return AnyView( 90 | currentValueLinePath(points: points) 91 | .stroke(color, style: StrokeStyle(lineWidth: lineWidth)) 92 | ) 93 | case .dash(let color, let lineWidth, let dash): 94 | return AnyView( 95 | currentValueLinePath(points: points) 96 | .stroke(color, style: StrokeStyle(lineWidth: lineWidth, dash: dash)) 97 | ) 98 | } 99 | } 100 | 101 | // MARK: private functions 102 | 103 | private func linePath(points: [CGPoint]) -> Path { 104 | var path = Path() 105 | guard points.count > 1 else { 106 | return path 107 | } 108 | path.move(to: points[0]) 109 | for i in 1.. Path { 116 | var path = linePath(points: points) 117 | guard let lastPoint = points.last else { 118 | return path 119 | } 120 | path.addLine(to: CGPoint(x: lastPoint.x, y: 0)) 121 | path.addLine(to: CGPoint(x: 0, y: 0)) 122 | path.addLine(to: CGPoint(x: 0, y: points[0].y)) 123 | 124 | return path 125 | } 126 | 127 | private func currentValueLinePath(points: [CGPoint]) -> Path { 128 | var path = Path() 129 | guard let lastPoint = points.last else { 130 | return path 131 | } 132 | path.move(to: CGPoint(x: 0, y: lastPoint.y)) 133 | path.addLine(to: lastPoint) 134 | return path 135 | } 136 | } 137 | 138 | extension LineChart: DataRepresentable { } 139 | -------------------------------------------------------------------------------- /Sources/LightChart/DataRepresentable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DataRepresentable.swift 3 | // 4 | // 5 | // Created by Alexey Pichukov on 19.08.2020. 6 | // 7 | 8 | import Foundation 9 | import CoreGraphics 10 | 11 | protocol DataRepresentable { 12 | func points(forData data: [Double], frame: CGRect, offset: Double, lineWidth: CGFloat) -> [CGPoint] 13 | func lineWidth(visualType: ChartVisualType) -> CGFloat 14 | } 15 | 16 | extension DataRepresentable { 17 | 18 | func points(forData data: [Double], frame: CGRect, offset: Double, lineWidth: CGFloat) -> [CGPoint] { 19 | var vector = Math.stretchOut(Math.norm(data)) 20 | if offset != 0 { 21 | vector = Math.stretchIn(vector, offset: offset) 22 | } 23 | var points: [CGPoint] = [] 24 | let isSame = sameValues(in: vector) 25 | for i in 0.. CGFloat { 34 | switch visualType { 35 | case .outline(_, let lineWidth): 36 | return lineWidth 37 | case .filled(_, let lineWidth): 38 | return lineWidth 39 | case .customFilled(_, let lineWidth, _): 40 | return lineWidth 41 | } 42 | } 43 | 44 | private func sameValues(in vector: [Double]) -> Bool { 45 | guard let prev = vector.first else { 46 | return true 47 | } 48 | for value in vector { 49 | if value != prev { 50 | return false 51 | } 52 | } 53 | return true 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/LightChart/LightChart.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct LightChartView: View { 4 | 5 | private let data: [Double] 6 | private let type: ChartType 7 | private let visualType: ChartVisualType 8 | private let offset: Double 9 | private let currentValueLineType: CurrentValueLineType 10 | 11 | public init(data: [Double], 12 | type: ChartType = .line, 13 | visualType: ChartVisualType = .outline(color: .red, lineWidth: 2), 14 | offset: Double = 0, 15 | currentValueLineType: CurrentValueLineType = .none) { 16 | self.data = data 17 | self.type = type 18 | self.visualType = visualType 19 | self.offset = offset 20 | self.currentValueLineType = currentValueLineType 21 | } 22 | 23 | public var body: some View { 24 | GeometryReader { reader in 25 | chart(withFrame: CGRect(x: 0, 26 | y: 0, 27 | width: reader.frame(in: .local).width , 28 | height: reader.frame(in: .local).height)) 29 | } 30 | } 31 | 32 | private func chart(withFrame frame: CGRect) -> AnyView { 33 | switch type { 34 | case .line: 35 | return AnyView( 36 | LineChart(data: data, 37 | frame: frame, 38 | visualType: visualType, 39 | offset: offset, 40 | currentValueLineType: currentValueLineType) 41 | ) 42 | case .curved: 43 | return AnyView( 44 | CurvedChart(data: data, 45 | frame: frame, 46 | visualType: visualType, 47 | offset: offset, 48 | currentValueLineType: currentValueLineType) 49 | ) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/LightChart/Math.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Math.swift 3 | // 4 | // 5 | // Created by Alexey Pichukov on 19.08.2020. 6 | // 7 | 8 | import Foundation 9 | import CoreGraphics 10 | 11 | struct Math { 12 | 13 | static func norm(_ vector: [Double]) -> [Double] { 14 | let norm = sqrt(Double(vector.reduce(0) { $0 + $1 * $1 })) 15 | return norm == 0 ? vector : vector.map { $0 / norm } 16 | } 17 | 18 | static func stretchOut(_ vector: [Double]) -> [Double] { 19 | guard let min = vector.min(), 20 | let rawMax = vector.max() else { 21 | return vector 22 | } 23 | let max = rawMax - min 24 | return vector.map { ($0 - min) / (max != 0 ? max : 1) } 25 | } 26 | 27 | static func stretchIn(_ vector: [Double], offset: Double) -> [Double] { 28 | guard let max = vector.max() else { 29 | return vector 30 | } 31 | let newMax = max - offset 32 | return vector.map { $0 * newMax + offset } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Tests/LightChartTests/LightChartTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import LightChart 3 | 4 | final class LightChartTests: XCTestCase { 5 | func testExample() { } 6 | 7 | static var allTests = [ 8 | ("testExample", testExample), 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /Tests/LightChartTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(LightChartTests.allTests), 7 | ] 8 | } 9 | #endif 10 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import LightChartTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += LightChartTests.allTests() 7 | XCTMain(tests) 8 | --------------------------------------------------------------------------------