diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..7f54e512 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,34 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + + + + +## Description + + +## Expected Behavior + + +## Actual Behavior + + +## Possible Fix + + +## Steps to Reproduce + + + +## Your Environment + +* Version of this package used: +* Device/Simulator: +* Operating System and version: +* Link to your project: \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..d4b1ee3d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Ask for a new feature +title: '' +labels: '' +assignees: '' + +--- + + + +## Detailed Description + + +## Context + + + +## Possible Implementation + \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/v2Ticket.md b/.github/ISSUE_TEMPLATE/v2Ticket.md new file mode 100644 index 00000000..ac9ec0cb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/v2Ticket.md @@ -0,0 +1,11 @@ +--- +name: v2 ticket +about: Create tasks for the upcoming new version +title: '' +labels: v2 +assignees: '' + +--- +# v2 ticket + +## Ticket description: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..5eabcf1b --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,29 @@ + + +## Description + + +## Motivation and Context + + + +## How Has This Been Tested? + + + + +## Screenshots (if appropriate): + +## Types of changes + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to change) +- [ ] Non-functional change (Updating Documentation, CI automation, etc..) + +## Checklist: + + +- [ ] My code follows the code style of this project. +- [ ] My change requires a change to the documentation. +- [ ] I have updated the documentation accordingly. diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml new file mode 100644 index 00000000..ebe51e65 --- /dev/null +++ b/.github/workflows/swift.yml @@ -0,0 +1,22 @@ +name: Swift + +on: + push: + branches: + - master + - new-version + pull_request: + branches: + - master + - new-version +jobs: + build: + + runs-on: macos-latest + + steps: + - uses: actions/checkout@v2 + - name: Build + run: swift build -v + - name: Run tests + run: swift test -v diff --git a/.gitignore b/.gitignore index 02c08753..0c62eec1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /.build /Packages /*.xcodeproj +.swiftpm diff --git a/.swiftpm/xcode/package.xcworkspace/xcuserdata/andrassamu.xcuserdatad/UserInterfaceState.xcuserstate b/.swiftpm/xcode/package.xcworkspace/xcuserdata/andrassamu.xcuserdatad/UserInterfaceState.xcuserstate index aae2473e..057e951c 100644 Binary files a/.swiftpm/xcode/package.xcworkspace/xcuserdata/andrassamu.xcuserdatad/UserInterfaceState.xcuserstate and b/.swiftpm/xcode/package.xcworkspace/xcuserdata/andrassamu.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/.swiftpm/xcode/package.xcworkspace/xcuserdata/roderic.xcuserdatad/UserInterfaceState.xcuserstate b/.swiftpm/xcode/package.xcworkspace/xcuserdata/roderic.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 00000000..aae6dfe7 Binary files /dev/null and b/.swiftpm/xcode/package.xcworkspace/xcuserdata/roderic.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/.swiftpm/xcode/package.xcworkspace/xcuserdata/samuandris.xcuserdatad/UserInterfaceState.xcuserstate b/.swiftpm/xcode/package.xcworkspace/xcuserdata/samuandris.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 00000000..40db97a0 Binary files /dev/null and b/.swiftpm/xcode/package.xcworkspace/xcuserdata/samuandris.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/.swiftpm/xcode/xcuserdata/samuandris.xcuserdatad/xcschemes/xcschememanagement.plist b/.swiftpm/xcode/xcuserdata/samuandris.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 00000000..443dea97 --- /dev/null +++ b/.swiftpm/xcode/xcuserdata/samuandris.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + SwiftUICharts.xcscheme_^#shared#^_ + + orderHint + 3 + + + + diff --git a/Package.swift b/Package.swift index ffd10e06..293b294d 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.1 +// swift-tools-version:5.3 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "SwiftUICharts", platforms: [ - .iOS(.v13),.watchOS(.v6) + .iOS(.v13), .watchOS(.v6), .macOS(.v11) ], products: [ // Products define the executables and libraries produced by a package, and make them visible to other packages. diff --git a/README.md b/README.md index 23f773a2..dd022632 100644 --- a/README.md +++ b/README.md @@ -2,18 +2,43 @@ Swift package for displaying charts effortlessly. -![SwiftUI Charts](./showcase1.gif "SwiftUI Charts") +## V2 Beta is here 🎉🎉🎉 + +V2 focuses on providing a strong and easy to use base, on which you can build your beautiful custom charts. It provides basic building blocks, like a chart view (bar, pie, line and ring chart), grid view, card view, interactive label for displaying the curent chart value. +So you decide, whether you build a fully fledged interactive view, or just display a bare bone chart + +* [How to install SwiftUI ChartView](https://github.com/AppPear/ChartView/wiki/How-to-install-SwiftUI-ChartView) + +* [How to create your first chart](https://github.com/AppPear/ChartView/wiki/How-to-create-your-first-chart) + +### It supports interactions and animations + + +### It is fully customizable, and works together with native SwiftUI elements well + + + +## Original (stable) version: + + + +### Usage It supports: * Line charts * Bar charts * Pie charts +### Slack +Join our Slack channel for day to day conversation and more insights: + +[Slack invite link](https://join.slack.com/t/swiftuichartview/shared_invite/zt-g6mxioq8-j3iUTF1YKX7D23ML3qcc4g) + ### Installation: -It requires iOS 13 and xCode 11! +It requires iOS 13 and Xcode 11! -In xCode got to `File -> Swift Packages -> Add Package Dependency` and paste inthe repo's url: `https://github.com/AppPear/ChartView` +In Xcode go to `File -> Swift Packages -> Add Package Dependency` and paste in the repo's url: `https://github.com/AppPear/ChartView` ### Usage: @@ -26,7 +51,47 @@ You can display a Chart by adding a chart view to your parent view: Added an example project, with **iOS, watchOS** target: https://github.com/AppPear/ChartViewDemo ## Line charts -![Line Charts](./showcase3.gif "Line Charts") + +**LineChartView with multiple lines!** +First release of this feature, interaction is disabled for now, I'll figure it out how could be the best to interact with multiple lines with a single touch. + + + +Usage: +```swift +MultiLineChartView(data: [([8,32,11,23,40,28], GradientColors.green), ([90,99,78,111,70,60,77], GradientColors.purple), ([34,56,72,38,43,100,50], GradientColors.orngPink)], title: "Title") +``` +Gradient colors are now under the `GradientColor` struct you can create your own gradient by `GradientColor(start: Color, end: Color)` + +Available preset gradients: +* orange +* blue +* green +* blu +* bluPurpl +* purple +* prplPink +* prplNeon +* orngPink + +**Full screen view called LineView!!!** + + +```swift + LineView(data: [8,23,54,32,12,37,7,23,43], title: "Line chart", legend: "Full screen") // legend is optional, use optional .padding() +``` + +Adopts to dark mode automatically + + + +You can add your custom darkmode style by specifying: + +```swift +let myCustomStyle = ChartStyle(...) +let myCutsomDarkModeStyle = ChartStyle(...) +myCustomStyle.darkModeStyle = myCutsomDarkModeStyle +``` **Line chart is interactive, so you can drag across to reveal the data points** @@ -36,27 +101,61 @@ You can add a line chart with the following code: LineChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", legend: "Legendary") // legend is optional ``` +**Turn drop shadow off by adding to the Initialiser: `dropShadow: false`** + ## Bar charts -![Bar Charts](./showcase2.gif "Bar Charts") + +**[New feature] you can display labels also along values and points for each bar to descirbe your data better!** **Bar chart is interactive, so you can drag across to reveal the data points** You can add a bar chart with the following code: +Labels and points: + ```swift - BarChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", legend: "Legendary") // legend is optional + BarChartView(data: ChartData(values: [("2018 Q4",63150), ("2019 Q1",50900), ("2019 Q2",77550), ("2019 Q3",79600), ("2019 Q4",92550)]), title: "Sales", legend: "Quarterly") // legend is optional ``` +Only points: + +```swift + BarChartView(data: ChartData(points: [8,23,54,32,12,37,7,23,43]), title: "Title", legend: "Legendary") // legend is optional +``` + +**ChartData** structure +Stores values in data pairs (actually tuple): `(String,Double)` +* you can have duplicate values +* keeps the data order + +You can initialise ChartData multiple ways: +* For integer values: `ChartData(points: [8,23,54,32,12,37,7,23,43])` +* For floating point values: `ChartData(points: [2.34,3.14,4.56])` +* For label,value pairs: `ChartData(values: [("2018 Q4",63150), ("2019 Q1",50900)])` + You can add different formats: -* Small `Form.small` -* Medium `Form.medium` -* Large `Form.large` - - ```swift - BarChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", form: Form.small) - ``` - +* Small `ChartForm.small` +* Medium `ChartForm.medium` +* Large `ChartForm.large` + +```swift +BarChartView(data: ChartData(points: [8,23,54,32,12,37,7,23,43]), title: "Title", form: ChartForm.small) +``` + +For floating point numbers, you can set a custom specifier: + +```swift +BarChartView(data: ChartData(points:[1.23,2.43,3.37]) ,title: "A", valueSpecifier: "%.2f") +``` +For integers you can disable by passing: `valueSpecifier: "%.0f"` + + +You can set your custom image in the upper right corner by passing in the initialiser: `cornerImage:Image(systemName: "waveform.path.ecg")` + + + **Turn drop shadow off by adding to the Initialiser: `dropShadow: false`** + ### You can customize styling of the chart with a ChartStyle object: Customizable: @@ -67,7 +166,7 @@ Customizable: * legend text color ```swift - let chartStyle = ChartStyle(backgroundColor: Color.black, accentColor: Colors.OrangeStart, secondGradientColor: Colors.OrangeEnd, chartFormSize: Form.medium, textColor: Color.white, legendTextColor: Color.white ) + let chartStyle = ChartStyle(backgroundColor: Color.black, accentColor: Colors.OrangeStart, secondGradientColor: Colors.OrangeEnd, chartFormSize: ChartForm.medium, textColor: Color.white, legendTextColor: Color.white ) ... BarChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", style: chartStyle) ``` @@ -84,33 +183,38 @@ You can access built-in styles: * barChartMidnightGreenLight * barChartMidnightGreenDark -![Midnightgreen](./midnightgreen.gif "Midnightgreen") - -![Custom Charts](./showcase5.png "Custom Charts") + + -### You can customize the size of the chart with a Form object: +### You can customize the size of the chart with a ChartForm object: -**Form** +**ChartForm** * `.small` * `.medium` * `.large` * `.detail` ```swift -BarChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", form: Form.small) +BarChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", form: ChartForm.small) ``` +### You can choose whether bar is animated or not after completing your gesture. + +If you want to animate back movement after completing your gesture, you set `animatedToBack` as `true`. + ### WatchOS support for Bar charts: -![Pie Charts](./watchos1.png "Pie Charts") + ## Pie charts -![Pie Charts](./showcase4.png "Pie Charts") + -You can add a line chart with the following code: +You can add a pie chart with the following code: ```swift PieChartView(data: [8,23,54,32], title: "Title", legend: "Legendary") // legend is optional ``` +**Turn drop shadow off by adding to the Initialiser: `dropShadow: false`** + diff --git a/Sources/SwiftUICharts/BarChart/BarChartCell.swift b/Sources/SwiftUICharts/BarChart/BarChartCell.swift index 1414ddb8..a3500b7f 100644 --- a/Sources/SwiftUICharts/BarChart/BarChartCell.swift +++ b/Sources/SwiftUICharts/BarChart/BarChartCell.swift @@ -17,19 +17,14 @@ public struct BarChartCell : View { return Double(width)/(Double(numberOfDataPoints) * 1.5) } var accentColor: Color - var secondGradientAccentColor: Color? - var gradientColors:[Color] { - if (secondGradientAccentColor != nil) { - return [secondGradientAccentColor!, accentColor] - } - return [accentColor, accentColor] - } + var gradient: GradientColor? + @State var scaleValue: Double = 0 @Binding var touchLocation: CGFloat public var body: some View { ZStack { RoundedRectangle(cornerRadius: 4) - .fill(LinearGradient(gradient: Gradient(colors: gradientColors), startPoint: .bottom, endPoint: .top)) + .fill(LinearGradient(gradient: gradient?.getGradient() ?? GradientColor(start: accentColor, end: accentColor).getGradient(), startPoint: .bottom, endPoint: .top)) } .frame(width: CGFloat(self.cellWidth)) .scaleEffect(CGSize(width: 1, height: self.scaleValue), anchor: .bottom) @@ -43,7 +38,7 @@ public struct BarChartCell : View { #if DEBUG struct ChartCell_Previews : PreviewProvider { static var previews: some View { - BarChartCell(value: Double(0.75), width: 320, numberOfDataPoints: 12, accentColor: Colors.OrangeStart, secondGradientAccentColor: nil, touchLocation: .constant(-1)) + BarChartCell(value: Double(0.75), width: 320, numberOfDataPoints: 12, accentColor: Colors.OrangeStart, gradient: nil, touchLocation: .constant(-1)) } } #endif diff --git a/Sources/SwiftUICharts/BarChart/BarChartRow.swift b/Sources/SwiftUICharts/BarChart/BarChartRow.swift index 242f4efd..0ccd935e 100644 --- a/Sources/SwiftUICharts/BarChart/BarChartRow.swift +++ b/Sources/SwiftUICharts/BarChart/BarChartRow.swift @@ -9,19 +9,30 @@ import SwiftUI public struct BarChartRow : View { - var data: [Int] + var data: [Double] var accentColor: Color - var secondGradientAccentColor: Color? - var maxValue: Int { - data.max() ?? 0 + var gradient: GradientColor? + + var maxValue: Double { + guard let max = data.max() else { + return 1 + } + return max != 0 ? max : 1 } @Binding var touchLocation: CGFloat public var body: some View { GeometryReader { geometry in HStack(alignment: .bottom, spacing: (geometry.frame(in: .local).width-22)/CGFloat(self.data.count * 3)){ - ForEach(0.. CGFloat(i)/CGFloat(self.data.count) && self.touchLocation < CGFloat(i+1)/CGFloat(self.data.count) ? CGSize(width: 1.4, height: 1.1) : CGSize(width: 1, height: 1), anchor: .bottom) + .animation(.spring()) } } @@ -37,7 +48,10 @@ public struct BarChartRow : View { #if DEBUG struct ChartRow_Previews : PreviewProvider { static var previews: some View { - BarChartRow(data: [8,23,54,32,12,37,7], accentColor: Colors.OrangeStart, touchLocation: .constant(-1)) + Group { + BarChartRow(data: [0], accentColor: Colors.OrangeStart, touchLocation: .constant(-1)) + BarChartRow(data: [8,23,54,32,12,37,7], accentColor: Colors.OrangeStart, touchLocation: .constant(-1)) + } } } #endif diff --git a/Sources/SwiftUICharts/BarChart/BarChartView.swift b/Sources/SwiftUICharts/BarChart/BarChartView.swift index d6013c7e..6a502e7c 100644 --- a/Sources/SwiftUICharts/BarChart/BarChartView.swift +++ b/Sources/SwiftUICharts/BarChart/BarChartView.swift @@ -9,98 +9,152 @@ import SwiftUI public struct BarChartView : View { - public var data: [Int] + @Environment(\.colorScheme) var colorScheme: ColorScheme + private var data: ChartData public var title: String public var legend: String? public var style: ChartStyle + public var darkModeStyle: ChartStyle public var formSize:CGSize -// let selectionFeedbackGenerator = UISelectionFeedbackGenerator() + public var dropShadow: Bool + public var cornerImage: Image? + public var valueSpecifier:String + public var animatedToBack: Bool @State private var touchLocation: CGFloat = -1.0 @State private var showValue: Bool = false - @State private var currentValue: Int = 0 { + @State private var showLabelValue: Bool = false + @State private var currentValue: Double = 0 { didSet{ if(oldValue != self.currentValue && self.showValue) { -// selectionFeedbackGenerator.selectionChanged() HapticFeedback.playSelection() } } } var isFullWidth:Bool { - return self.formSize == Form.large + return self.formSize == ChartForm.large } - public init(data: [Int], title: String, legend: String? = nil, style: ChartStyle = Styles.barChartStyleOrangeLight, form: CGSize? = Form.medium){ + public init(data:ChartData, title: String, legend: String? = nil, style: ChartStyle = Styles.barChartStyleOrangeLight, form: CGSize? = ChartForm.medium, dropShadow: Bool? = true, cornerImage:Image? = Image(systemName: "waveform.path.ecg"), valueSpecifier: String? = "%.1f", animatedToBack: Bool = false){ self.data = data self.title = title self.legend = legend self.style = style + self.darkModeStyle = style.darkModeStyle != nil ? style.darkModeStyle! : Styles.barChartStyleOrangeDark self.formSize = form! + self.dropShadow = dropShadow! + self.cornerImage = cornerImage + self.valueSpecifier = valueSpecifier! + self.animatedToBack = animatedToBack } public var body: some View { ZStack{ Rectangle() - .fill(self.style.backgroundColor) + .fill(self.colorScheme == .dark ? self.darkModeStyle.backgroundColor : self.style.backgroundColor) .cornerRadius(20) - .shadow(color: Color.gray, radius: 8 ) + .shadow(color: self.style.dropShadowColor, radius: self.dropShadow ? 8 : 0) VStack(alignment: .leading){ HStack{ if(!showValue){ Text(self.title) .font(.headline) - .foregroundColor(self.style.textColor) + .foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.textColor : self.style.textColor) }else{ - Text("\(self.currentValue)") + Text("\(self.currentValue, specifier: self.valueSpecifier)") .font(.headline) - .foregroundColor(self.style.textColor) + .foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.textColor : self.style.textColor) } - if(self.formSize == Form.large && self.legend != nil && !showValue) { + if(self.formSize == ChartForm.large && self.legend != nil && !showValue) { Text(self.legend!) .font(.callout) - .foregroundColor(self.style.accentColor) + .foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.accentColor : self.style.accentColor) .transition(.opacity) .animation(.easeOut) } Spacer() - Image(systemName: "waveform.path.ecg") + self.cornerImage .imageScale(.large) - .foregroundColor(self.style.legendTextColor) + .foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.legendTextColor : self.style.legendTextColor) }.padding() - BarChartRow(data: data, accentColor: self.style.accentColor, secondGradientAccentColor: self.style.secondGradientColor, touchLocation: self.$touchLocation) - if self.legend != nil && self.formSize == Form.medium { + BarChartRow(data: data.points.map{$0.1}, + accentColor: self.colorScheme == .dark ? self.darkModeStyle.accentColor : self.style.accentColor, + gradient: self.colorScheme == .dark ? self.darkModeStyle.gradientColor : self.style.gradientColor, + touchLocation: self.$touchLocation) + if self.legend != nil && self.formSize == ChartForm.medium && !self.showLabelValue{ Text(self.legend!) .font(.headline) - .foregroundColor(self.style.legendTextColor) + .foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.legendTextColor : self.style.legendTextColor) .padding() + }else if (self.data.valuesGiven && self.getCurrentValue() != nil) { + LabelView(arrowOffset: self.getArrowOffset(touchLocation: self.touchLocation), + title: .constant(self.getCurrentValue()!.0)) + .offset(x: self.getLabelViewOffset(touchLocation: self.touchLocation), y: -6) + .foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.legendTextColor : self.style.legendTextColor) } } - }.frame(minWidth:self.formSize.width, maxWidth: self.isFullWidth ? .infinity : self.formSize.width, minHeight:self.formSize.height, maxHeight:self.formSize.height) + }.frame(minWidth:self.formSize.width, + maxWidth: self.isFullWidth ? .infinity : self.formSize.width, + minHeight:self.formSize.height, + maxHeight:self.formSize.height) .gesture(DragGesture() .onChanged({ value in self.touchLocation = value.location.x/self.formSize.width self.showValue = true - self.currentValue = self.getCurrentValue() + self.currentValue = self.getCurrentValue()?.1 ?? 0 + if(self.data.valuesGiven && self.formSize == ChartForm.medium) { + self.showLabelValue = true + } }) .onEnded({ value in - self.showValue = false - self.touchLocation = -1 + if animatedToBack { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + withAnimation(Animation.easeOut(duration: 1)) { + self.showValue = false + self.showLabelValue = false + self.touchLocation = -1 + } + } + } else { + self.showValue = false + self.showLabelValue = false + self.touchLocation = -1 + } }) ) .gesture(TapGesture() ) } - func getCurrentValue()-> Int{ - let index = max(0,min(self.data.count-1,Int(floor((self.touchLocation*self.formSize.width)/(self.formSize.width/CGFloat(self.data.count)))))) - return self.data[index] + func getArrowOffset(touchLocation:CGFloat) -> Binding { + let realLoc = (self.touchLocation * self.formSize.width) - 50 + if realLoc < 10 { + return .constant(realLoc - 10) + }else if realLoc > self.formSize.width-110 { + return .constant((self.formSize.width-110 - realLoc) * -1) + } else { + return .constant(0) + } + } + + func getLabelViewOffset(touchLocation:CGFloat) -> CGFloat { + return min(self.formSize.width-110,max(10,(self.touchLocation * self.formSize.width) - 50)) + } + + func getCurrentValue() -> (String,Double)? { + guard self.data.points.count > 0 else { return nil} + let index = max(0,min(self.data.points.count-1,Int(floor((self.touchLocation*self.formSize.width)/(self.formSize.width/CGFloat(self.data.points.count)))))) + return self.data.points[index] } } #if DEBUG struct ChartView_Previews : PreviewProvider { static var previews: some View { - BarChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", legend: "Legendary") + BarChartView(data: TestData.values , + title: "Model 3 sales", + legend: "Quarterly", + valueSpecifier: "%.0f") } } #endif diff --git a/Sources/SwiftUICharts/BarChart/LabelView.swift b/Sources/SwiftUICharts/BarChart/LabelView.swift new file mode 100644 index 00000000..f17ae7be --- /dev/null +++ b/Sources/SwiftUICharts/BarChart/LabelView.swift @@ -0,0 +1,46 @@ +// +// LabelView.swift +// BarChart +// +// Created by Samu András on 2020. 01. 08.. +// Copyright © 2020. Samu András. All rights reserved. +// + +import SwiftUI + +struct LabelView: View { + @Binding var arrowOffset: CGFloat + @Binding var title:String + var body: some View { + VStack{ + ArrowUp().fill(Color.white).frame(width: 20, height: 12, alignment: .center).shadow(color: Color.gray, radius: 8, x: 0, y: 0).offset(x: getArrowOffset(offset:self.arrowOffset), y: 12) + ZStack{ + RoundedRectangle(cornerRadius: 8).frame(width: 100, height: 32, alignment: .center).foregroundColor(Color.white).shadow(radius: 8) + Text(self.title).font(.caption).bold() + ArrowUp().fill(Color.white).frame(width: 20, height: 12, alignment: .center).zIndex(999).offset(x: getArrowOffset(offset:self.arrowOffset), y: -20) + + } + } + } + + func getArrowOffset(offset: CGFloat) -> CGFloat { + return max(-36,min(36, offset)) + } +} + +struct ArrowUp: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: 0, y: rect.height)) + path.addLine(to: CGPoint(x: rect.width/2, y: 0)) + path.addLine(to: CGPoint(x: rect.width, y: rect.height)) + path.closeSubpath() + return path + } +} + +struct LabelView_Previews: PreviewProvider { + static var previews: some View { + LabelView(arrowOffset: .constant(0), title: .constant("Tesla model 3")) + } +} diff --git a/Sources/SwiftUICharts/Helpers.swift b/Sources/SwiftUICharts/Helpers.swift index 9f7e9293..5198c841 100644 --- a/Sources/SwiftUICharts/Helpers.swift +++ b/Sources/SwiftUICharts/Helpers.swift @@ -1,6 +1,6 @@ // // File.swift -// +// // // Created by András Samu on 2019. 07. 19.. // @@ -15,10 +15,11 @@ public struct Colors { public static let color2Accent:Color = Color(hexString: "#4266E8") public static let color3:Color = Color(hexString: "#FCECEA") public static let color3Accent:Color = Color(hexString: "#E1614C") - public static let OrangeStart:Color = Color(hexString: "#FF782C") - public static let OrangeEnd:Color = Color(hexString: "#EC2301") + public static let OrangeEnd:Color = Color(hexString: "#FF782C") + public static let OrangeStart:Color = Color(hexString: "#EC2301") public static let LegendText:Color = Color(hexString: "#A7A6A8") public static let LegendColor:Color = Color(hexString: "#E8E7EA") + public static let LegendDarkColor:Color = Color(hexString: "#545454") public static let IndicatorKnob:Color = Color(hexString: "#FF57A6") public static let GradientUpperBlue:Color = Color(hexString: "#C2E8FF") public static let GradinetUpperBlue1:Color = Color(hexString: "#A8E1FF") @@ -29,115 +30,216 @@ public struct Colors { public static let BorderBlue:Color = Color(hexString: "#4EBCFF") } +public struct GradientColor { + public let start: Color + public let end: Color + + public init(start: Color, end: Color) { + self.start = start + self.end = end + } + + public func getGradient() -> Gradient { + return Gradient(colors: [start, end]) + } +} + +public struct GradientColors { + public static let orange = GradientColor(start: Colors.OrangeStart, end: Colors.OrangeEnd) + public static let blue = GradientColor(start: Colors.GradientPurple, end: Colors.GradientNeonBlue) + public static let green = GradientColor(start: Color(hexString: "0BCDF7"), end: Color(hexString: "A2FEAE")) + public static let blu = GradientColor(start: Color(hexString: "0591FF"), end: Color(hexString: "29D9FE")) + public static let bluPurpl = GradientColor(start: Color(hexString: "4ABBFB"), end: Color(hexString: "8C00FF")) + public static let purple = GradientColor(start: Color(hexString: "741DF4"), end: Color(hexString: "C501B0")) + public static let prplPink = GradientColor(start: Color(hexString: "BC05AF"), end: Color(hexString: "FF1378")) + public static let prplNeon = GradientColor(start: Color(hexString: "FE019A"), end: Color(hexString: "FE0BF4")) + public static let orngPink = GradientColor(start: Color(hexString: "FF8E2D"), end: Color(hexString: "FF4E7A")) +} + public struct Styles { public static let lineChartStyleOne = ChartStyle( backgroundColor: Color.white, accentColor: Colors.OrangeStart, secondGradientColor: Colors.OrangeEnd, textColor: Color.black, - legendTextColor: Color.gray) + legendTextColor: Color.gray, + dropShadowColor: Color.gray) public static let barChartStyleOrangeLight = ChartStyle( backgroundColor: Color.white, accentColor: Colors.OrangeStart, secondGradientColor: Colors.OrangeEnd, textColor: Color.black, - legendTextColor: Color.gray) + legendTextColor: Color.gray, + dropShadowColor: Color.gray) public static let barChartStyleOrangeDark = ChartStyle( backgroundColor: Color.black, accentColor: Colors.OrangeStart, secondGradientColor: Colors.OrangeEnd, textColor: Color.white, - legendTextColor: Color.gray) + legendTextColor: Color.gray, + dropShadowColor: Color.gray) public static let barChartStyleNeonBlueLight = ChartStyle( backgroundColor: Color.white, accentColor: Colors.GradientNeonBlue, secondGradientColor: Colors.GradientPurple, textColor: Color.black, - legendTextColor: Color.gray) + legendTextColor: Color.gray, + dropShadowColor: Color.gray) public static let barChartStyleNeonBlueDark = ChartStyle( backgroundColor: Color.black, accentColor: Colors.GradientNeonBlue, secondGradientColor: Colors.GradientPurple, textColor: Color.white, - legendTextColor: Color.gray) + legendTextColor: Color.gray, + dropShadowColor: Color.gray) public static let barChartMidnightGreenDark = ChartStyle( backgroundColor: Color(hexString: "#36534D"), //3B5147, 313D34 accentColor: Color(hexString: "#FFD603"), secondGradientColor: Color(hexString: "#FFCA04"), textColor: Color.white, - legendTextColor: Color(hexString: "#D2E5E1")) + legendTextColor: Color(hexString: "#D2E5E1"), + dropShadowColor: Color.gray) public static let barChartMidnightGreenLight = ChartStyle( backgroundColor: Color.white, accentColor: Color(hexString: "#84A094"), //84A094 , 698378 secondGradientColor: Color(hexString: "#50675D"), textColor: Color.black, - legendTextColor:Color.gray) + legendTextColor:Color.gray, + dropShadowColor: Color.gray) public static let pieChartStyleOne = ChartStyle( backgroundColor: Color.white, + accentColor: Colors.OrangeEnd, + secondGradientColor: Colors.OrangeStart, + textColor: Color.black, + legendTextColor: Color.gray, + dropShadowColor: Color.gray) + + public static let lineViewDarkMode = ChartStyle( + backgroundColor: Color.black, accentColor: Colors.OrangeStart, secondGradientColor: Colors.OrangeEnd, - textColor: Color.black, - legendTextColor: Color.gray) + textColor: Color.white, + legendTextColor: Color.white, + dropShadowColor: Color.gray) } -public struct Form { +public struct ChartForm { #if os(watchOS) public static let small = CGSize(width:120, height:90) public static let medium = CGSize(width:120, height:160) public static let large = CGSize(width:180, height:90) + public static let extraLarge = CGSize(width:180, height:90) public static let detail = CGSize(width:180, height:160) #else public static let small = CGSize(width:180, height:120) public static let medium = CGSize(width:180, height:240) public static let large = CGSize(width:360, height:120) + public static let extraLarge = CGSize(width:360, height:240) public static let detail = CGSize(width:180, height:120) #endif - - } -public struct ChartStyle { +public class ChartStyle { public var backgroundColor: Color public var accentColor: Color - public var secondGradientColor: Color + public var gradientColor: GradientColor public var textColor: Color public var legendTextColor: Color + public var dropShadowColor: Color + public weak var darkModeStyle: ChartStyle? - public init(backgroundColor: Color, accentColor: Color, secondGradientColor: Color, textColor: Color, legendTextColor: Color){ + public init(backgroundColor: Color, accentColor: Color, secondGradientColor: Color, textColor: Color, legendTextColor: Color, dropShadowColor: Color){ self.backgroundColor = backgroundColor self.accentColor = accentColor - self.secondGradientColor = secondGradientColor + self.gradientColor = GradientColor(start: accentColor, end: secondGradientColor) self.textColor = textColor self.legendTextColor = legendTextColor + self.dropShadowColor = dropShadowColor + } + + public init(backgroundColor: Color, accentColor: Color, gradientColor: GradientColor, textColor: Color, legendTextColor: Color, dropShadowColor: Color){ + self.backgroundColor = backgroundColor + self.accentColor = accentColor + self.gradientColor = gradientColor + self.textColor = textColor + self.legendTextColor = legendTextColor + self.dropShadowColor = dropShadowColor } public init(formSize: CGSize){ self.backgroundColor = Color.white self.accentColor = Colors.OrangeStart - self.secondGradientColor = Colors.OrangeEnd + self.gradientColor = GradientColors.orange self.legendTextColor = Color.gray self.textColor = Color.black + self.dropShadowColor = Color.gray + } +} + +public class ChartData: ObservableObject, Identifiable { + @Published var points: [(String,Double)] + var valuesGiven: Bool = false + var ID = UUID() + + public init(points:[N]) { + self.points = points.map{("", Double($0))} + } + public init(values:[(String,N)]){ + self.points = values.map{($0.0, Double($0.1))} + self.valuesGiven = true + } + public init(values:[(String,N)]){ + self.points = values.map{($0.0, Double($0.1))} + self.valuesGiven = true + } + public init(numberValues:[(N,N)]){ + self.points = numberValues.map{(String($0.0), Double($0.1))} + self.valuesGiven = true + } + public init(numberValues:[(N,N)]){ + self.points = numberValues.map{(String($0.0), Double($0.1))} + self.valuesGiven = true + } + + public func onlyPoints() -> [Double] { + return self.points.map{ $0.1 } } } -class ChartData: ObservableObject { - @Published var points: [Int] = [Int]() - @Published var currentPoint: Int? = nil +public class MultiLineChartData: ChartData { + var gradient: GradientColor + + public init(points:[N], gradient: GradientColor) { + self.gradient = gradient + super.init(points: points) + } + + public init(points:[N], color: Color) { + self.gradient = GradientColor(start: color, end: color) + super.init(points: points) + } - init(points:[Int]) { - self.points = points + public func getGradient() -> GradientColor { + return self.gradient } } -class TestData{ +public class TestData{ static public var data:ChartData = ChartData(points: [37,72,51,22,39,47,66,85,50]) + static public var values:ChartData = ChartData(values: [("2017 Q3",220), + ("2017 Q4",1550), + ("2018 Q1",8180), + ("2018 Q2",18440), + ("2018 Q3",55840), + ("2018 Q4",63150), ("2019 Q1",50900), ("2019 Q2",77550), ("2019 Q3",79600), ("2019 Q4",92550)]) + } extension Color { @@ -166,11 +268,15 @@ class HapticFeedback { static func playSelection() -> Void { WKInterfaceDevice.current().play(.click) } - #else + #elseif os(iOS) //iOS implementation let selectionFeedbackGenerator = UISelectionFeedbackGenerator() static func playSelection() -> Void { UISelectionFeedbackGenerator().selectionChanged() } + #else + static func playSelection() -> Void { + //No-op + } #endif } diff --git a/Sources/SwiftUICharts/LineChart/Legend.swift b/Sources/SwiftUICharts/LineChart/Legend.swift index a706c0d5..10190810 100644 --- a/Sources/SwiftUICharts/LineChart/Legend.swift +++ b/Sources/SwiftUICharts/LineChart/Legend.swift @@ -12,23 +12,42 @@ struct Legend: View { @ObservedObject var data: ChartData @Binding var frame: CGRect @Binding var hideHorizontalLines: Bool - + @Environment(\.colorScheme) var colorScheme: ColorScheme + var specifier: String = "%.2f" + let padding:CGFloat = 3 + var stepWidth: CGFloat { + if data.points.count < 2 { + return 0 + } return frame.size.width / CGFloat(data.points.count-1) } var stepHeight: CGFloat { - return frame.size.height / CGFloat(data.points.max()! + data.points.min()!) + let points = self.data.onlyPoints() + if let min = points.min(), let max = points.max(), min != max { + if (min < 0){ + return (frame.size.height-padding) / CGFloat(max - min) + }else{ + return (frame.size.height-padding) / CGFloat(max - min) + } + } + return 0 + } + + var min: CGFloat { + let points = self.data.onlyPoints() + return CGFloat(points.min() ?? 0) } var body: some View { ZStack(alignment: .topLeading){ ForEach((0...4), id: \.self) { height in HStack(alignment: .center){ - Text("\(self.getYLegend()![height])").offset(x: 0, y: (self.frame.height-CGFloat(self.getYLegend()![height])*self.stepHeight)-(self.frame.height/2)) + Text("\(self.getYLegendSafe(height: height), specifier: specifier)").offset(x: 0, y: self.getYposition(height: height) ) .foregroundColor(Colors.LegendText) .font(.caption) - self.line(atHeight: CGFloat(self.getYLegend()![height]), width: self.frame.width) - .stroke(Colors.LegendColor, style: StrokeStyle(lineWidth: 1.5, lineCap: .round, dash: [5,height == 0 ? 0 : 10])) + self.line(atHeight: self.getYLegendSafe(height: height), width: self.frame.width) + .stroke(self.colorScheme == .dark ? Colors.LegendDarkColor : Colors.LegendColor, style: StrokeStyle(lineWidth: 1.5, lineCap: .round, dash: [5,height == 0 ? 0 : 10])) .opacity((self.hideHorizontalLines && height != 0) ? 0 : 1) .rotationEffect(.degrees(180), anchor: .center) .rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0)) @@ -41,30 +60,41 @@ struct Legend: View { } } + func getYLegendSafe(height:Int)->CGFloat{ + if let legend = getYLegend() { + return CGFloat(legend[height]) + } + return 0 + } + + func getYposition(height: Int)-> CGFloat { + if let legend = getYLegend() { + return (self.frame.height-((CGFloat(legend[height]) - min)*self.stepHeight))-(self.frame.height/2) + } + return 0 + + } + func line(atHeight: CGFloat, width: CGFloat) -> Path { var hLine = Path() - hLine.move(to: CGPoint(x:5, y: atHeight*stepHeight)) - hLine.addLine(to: CGPoint(x: width, y: atHeight*stepHeight)) + hLine.move(to: CGPoint(x:5, y: (atHeight-min)*stepHeight)) + hLine.addLine(to: CGPoint(x: width, y: (atHeight-min)*stepHeight)) return hLine } - func getYLegend() -> [Int]? { - guard let max = data.points.max() else { return nil } - guard let min = data.points.min() else { return nil } - if(min > 0){ - let upperBound = ((max/10)+1) * 10 - let step = upperBound/4 - return [step * 0,step * 1, step * 2, step * 3, step * 4] - } - - return nil + func getYLegend() -> [Double]? { + let points = self.data.onlyPoints() + guard let max = points.max() else { return nil } + guard let min = points.min() else { return nil } + let step = Double(max - min)/4 + return [min+step * 0, min+step * 1, min+step * 2, min+step * 3, min+step * 4] } } struct Legend_Previews: PreviewProvider { static var previews: some View { GeometryReader{ geometry in - Legend(data: TestData.data, frame: .constant(geometry.frame(in: .local)), hideHorizontalLines: .constant(false)) + Legend(data: ChartData(points: [0.2,0.4,1.4,4.5]), frame: .constant(geometry.frame(in: .local)), hideHorizontalLines: .constant(false)) }.frame(width: 320, height: 200) } } diff --git a/Sources/SwiftUICharts/LineChart/Line.swift b/Sources/SwiftUICharts/LineChart/Line.swift index 363c4085..d29c1874 100644 --- a/Sources/SwiftUICharts/LineChart/Line.swift +++ b/Sources/SwiftUICharts/LineChart/Line.swift @@ -8,28 +8,57 @@ import SwiftUI -struct Line: View { +public struct Line: View { @ObservedObject var data: ChartData @Binding var frame: CGRect @Binding var touchLocation: CGPoint @Binding var showIndicator: Bool + @Binding var minDataValue: Double? + @Binding var maxDataValue: Double? @State private var showFull: Bool = false @State var showBackground: Bool = true - + var gradient: GradientColor = GradientColor(start: Colors.GradientPurple, end: Colors.GradientNeonBlue) + var index:Int = 0 + let padding:CGFloat = 30 + var curvedLines: Bool = true var stepWidth: CGFloat { + if data.points.count < 2 { + return 0 + } return frame.size.width / CGFloat(data.points.count-1) } var stepHeight: CGFloat { - return frame.size.height / CGFloat(data.points.max()! + data.points.min()!) + var min: Double? + var max: Double? + let points = self.data.onlyPoints() + if minDataValue != nil && maxDataValue != nil { + min = minDataValue! + max = maxDataValue! + }else if let minPoint = points.min(), let maxPoint = points.max(), minPoint != maxPoint { + min = minPoint + max = maxPoint + }else { + return 0 + } + if let min = min, let max = max, min != max { + if (min <= 0){ + return (frame.size.height-padding) / CGFloat(max - min) + }else{ + return (frame.size.height-padding) / CGFloat(max - min) + } + } + return 0 } var path: Path { - return Path.quadCurvedPathWithPoints(points: data.points, step: CGPoint(x: stepWidth, y: stepHeight)) + let points = self.data.onlyPoints() + return curvedLines ? Path.quadCurvedPathWithPoints(points: points, step: CGPoint(x: stepWidth, y: stepHeight), globalOffset: minDataValue) : Path.linePathWithPoints(points: points, step: CGPoint(x: stepWidth, y: stepHeight)) } var closedPath: Path { - return Path.quadClosedCurvedPathWithPoints(points: data.points, step: CGPoint(x: stepWidth, y: stepHeight)) + let points = self.data.onlyPoints() + return curvedLines ? Path.quadClosedCurvedPathWithPoints(points: points, step: CGPoint(x: stepWidth, y: stepHeight), globalOffset: minDataValue) : Path.closedLinePathWithPoints(points: points, step: CGPoint(x: stepWidth, y: stepHeight)) } - var body: some View { + public var body: some View { ZStack { if(self.showFull && self.showBackground){ self.closedPath @@ -41,13 +70,16 @@ struct Line: View { } self.path .trim(from: 0, to: self.showFull ? 1:0) - .stroke(LinearGradient(gradient: Gradient(colors: [Colors.GradientPurple, Colors.GradientNeonBlue]), startPoint: .leading, endPoint: .trailing) ,style: StrokeStyle(lineWidth: 3)) + .stroke(LinearGradient(gradient: gradient.getGradient(), startPoint: .leading, endPoint: .trailing) ,style: StrokeStyle(lineWidth: 3, lineJoin: .round)) .rotationEffect(.degrees(180), anchor: .center) .rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0)) - .animation(.easeOut(duration: 1.2)) - .onAppear(){ - self.showFull.toggle() - }.drawingGroup() + .animation(Animation.easeOut(duration: 1.2).delay(Double(self.index)*0.4)) + .onAppear { + self.showFull = true + } + .onDisappear { + self.showFull = false + } if(self.showIndicator) { IndicatorPoint() .position(self.getClosestPointOnPath(touchLocation: self.touchLocation)) @@ -58,102 +90,16 @@ struct Line: View { } func getClosestPointOnPath(touchLocation: CGPoint) -> CGPoint { - let percentage:CGFloat = min(max(touchLocation.x,0)/self.frame.width,1) - let closest = self.path.percentPoint(percentage) + let closest = self.path.point(to: touchLocation.x) return closest } } -extension CGPoint { - static func getMidPoint(point1: CGPoint, point2: CGPoint) -> CGPoint { - return CGPoint( - x: point1.x + (point2.x - point1.x) / 2, - y: point1.y + (point2.y - point1.y) / 2 - ) - } - - func dist(to: CGPoint) -> CGFloat { - return sqrt((pow(self.x - to.x, 2) + pow(self.y - to.y, 2))) - } - - static func midPointForPoints(p1:CGPoint, p2:CGPoint) -> CGPoint { - return CGPoint(x:(p1.x + p2.x) / 2,y: (p1.y + p2.y) / 2) - } - - static func controlPointForPoints(p1:CGPoint, p2:CGPoint) -> CGPoint { - var controlPoint = CGPoint.midPointForPoints(p1:p1, p2:p2) - let diffY = abs(p2.y - controlPoint.y) - - if (p1.y < p2.y){ - controlPoint.y += diffY - } else if (p1.y > p2.y) { - controlPoint.y -= diffY - } - return controlPoint - } -} -extension Path { - static func quadCurvedPathWithPoints(points:[Int], step:CGPoint) -> Path { - var path = Path() - var p1 = CGPoint(x: 0, y: CGFloat(points[0])*step.y) - path.move(to: p1) - if(points.count < 2){ - path.addLine(to: CGPoint(x: step.x, y: step.y*CGFloat(points[1]))) - return path - } - for pointIndex in 1.. Path { - var path = Path() - path.move(to: .zero) - var p1 = CGPoint(x: 0, y: CGFloat(points[0])*step.y) - path.addLine(to: p1) - if(points.count < 2){ - path.addLine(to: CGPoint(x: step.x, y: step.y*CGFloat(points[1]))) - return path - } - for pointIndex in 1.. CGPoint { - // percent difference between points - let diff: CGFloat = 0.001 - let comp: CGFloat = 1 - diff - - // handle limits - let pct = percent > 1 ? 0 : (percent < 0 ? 1 : percent) - - let f = pct > comp ? comp : pct - let t = pct > comp ? 1 : pct + diff - let tp = self.trimmedPath(from: f, to: t) - - return CGPoint(x: tp.boundingRect.midX, y: tp.boundingRect.midY) - } - -} - struct Line_Previews: PreviewProvider { static var previews: some View { GeometryReader{ geometry in - Line(data: TestData.data, frame: .constant(geometry.frame(in: .local)), touchLocation: .constant(CGPoint(x: 300, y: 12)), showIndicator: .constant(true)) + Line(data: ChartData(points: [12,-230,10,54]), frame: .constant(geometry.frame(in: .local)), touchLocation: .constant(CGPoint(x: 100, y: 12)), showIndicator: .constant(true), minDataValue: .constant(nil), maxDataValue: .constant(nil)) }.frame(width: 320, height: 160) } } diff --git a/Sources/SwiftUICharts/LineChart/LineChartView.swift b/Sources/SwiftUICharts/LineChart/LineChartView.swift index 3cd0580c..8e48faa1 100644 --- a/Sources/SwiftUICharts/LineChart/LineChartView.swift +++ b/Sources/SwiftUICharts/LineChart/LineChartView.swift @@ -9,46 +9,80 @@ import SwiftUI public struct LineChartView: View { -// let selectionFeedbackGenerator = UISelectionFeedbackGenerator() + @Environment(\.colorScheme) var colorScheme: ColorScheme @ObservedObject var data:ChartData public var title: String public var legend: String? public var style: ChartStyle + public var darkModeStyle: ChartStyle + public var formSize:CGSize + public var dropShadow: Bool + public var valueSpecifier:String + @State private var touchLocation:CGPoint = .zero @State private var showIndicatorDot: Bool = false - @State private var currentValue: Int = 2 { + @State private var currentValue: Double = 2 { didSet{ if (oldValue != self.currentValue && showIndicatorDot) { -// selectionFeedbackGenerator.selectionChanged() HapticFeedback.playSelection() } } } - let frame = CGSize(width: 180, height: 120) + var frame = CGSize(width: 180, height: 120) + private var rateValue: Int? - public init(data: [Int], title: String, legend: String? = nil, style: ChartStyle = Styles.lineChartStyleOne, form: CGSize? = Form.medium){ + public init(data: [Double], + title: String, + legend: String? = nil, + style: ChartStyle = Styles.lineChartStyleOne, + form: CGSize? = ChartForm.medium, + rateValue: Int?, + dropShadow: Bool? = true, + valueSpecifier: String? = "%.1f") { + self.data = ChartData(points: data) self.title = title self.legend = legend self.style = style + self.darkModeStyle = style.darkModeStyle != nil ? style.darkModeStyle! : Styles.lineViewDarkMode self.formSize = form! + frame = CGSize(width: self.formSize.width, height: self.formSize.height/2) + self.dropShadow = dropShadow! + self.valueSpecifier = valueSpecifier! + self.rateValue = rateValue } public var body: some View { ZStack(alignment: .center){ - RoundedRectangle(cornerRadius: 20).fill(self.style.backgroundColor).frame(width: frame.width, height: 240, alignment: .center).shadow(radius: 8) + RoundedRectangle(cornerRadius: 20) + .fill(self.colorScheme == .dark ? self.darkModeStyle.backgroundColor : self.style.backgroundColor) + .frame(width: frame.width, height: 240, alignment: .center) + .shadow(color: self.style.dropShadowColor, radius: self.dropShadow ? 8 : 0) VStack(alignment: .leading){ if(!self.showIndicatorDot){ VStack(alignment: .leading, spacing: 8){ - Text(self.title).font(.title).bold().foregroundColor(self.style.textColor) + Text(self.title) + .font(.title) + .bold() + .foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.textColor : self.style.textColor) if (self.legend != nil){ - Text(self.legend!).font(.callout).foregroundColor(self.style.legendTextColor) + Text(self.legend!) + .font(.callout) + .foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.legendTextColor :self.style.legendTextColor) } HStack { - Image(systemName: "arrow.up") - Text("14%") + + if let rateValue = self.rateValue + { + if (rateValue ?? 0 >= 0){ + Image(systemName: "arrow.up") + }else{ + Image(systemName: "arrow.down") + } + Text("\(rateValue!)%") + } } } .transition(.opacity) @@ -57,18 +91,22 @@ public struct LineChartView: View { }else{ HStack{ Spacer() - Text("\(self.currentValue)") + Text("\(self.currentValue, specifier: self.valueSpecifier)") .font(.system(size: 41, weight: .bold, design: .default)) .offset(x: 0, y: 30) Spacer() } .transition(.scale) - .animation(.spring()) - } Spacer() GeometryReader{ geometry in - Line(data: self.data, frame: .constant(geometry.frame(in: .local)), touchLocation: self.$touchLocation, showIndicator: self.$showIndicatorDot) + Line(data: self.data, + frame: .constant(geometry.frame(in: .local)), + touchLocation: self.$touchLocation, + showIndicator: self.$showIndicatorDot, + minDataValue: .constant(nil), + maxDataValue: .constant(nil) + ) } .frame(width: frame.width, height: frame.height) .clipShape(RoundedRectangle(cornerRadius: 20)) @@ -87,14 +125,15 @@ public struct LineChartView: View { ) } - func getClosestDataPoint(toPoint: CGPoint, width:CGFloat, height: CGFloat) -> CGPoint { - let stepWidth: CGFloat = width / CGFloat(data.points.count-1) - let stepHeight: CGFloat = height / CGFloat(data.points.max()! + data.points.min()!) + @discardableResult func getClosestDataPoint(toPoint: CGPoint, width:CGFloat, height: CGFloat) -> CGPoint { + let points = self.data.onlyPoints() + let stepWidth: CGFloat = width / CGFloat(points.count-1) + let stepHeight: CGFloat = height / CGFloat(points.max()! + points.min()!) let index:Int = Int(round((toPoint.x)/stepWidth)) - if (index >= 0 && index < data.points.count){ - self.currentValue = self.data.points[index] - return CGPoint(x: CGFloat(index)*stepWidth, y: CGFloat(self.data.points[index])*stepHeight) + if (index >= 0 && index < points.count){ + self.currentValue = points[index] + return CGPoint(x: CGFloat(index)*stepWidth, y: CGFloat(points[index])*stepHeight) } return .zero } @@ -105,6 +144,9 @@ struct WidgetView_Previews: PreviewProvider { Group { LineChartView(data: [8,23,54,32,12,37,7,23,43], title: "Line chart", legend: "Basic") .environment(\.colorScheme, .light) + + LineChartView(data: [282.502, 284.495, 283.51, 285.019, 285.197, 286.118, 288.737, 288.455, 289.391, 287.691, 285.878, 286.46, 286.252, 284.652, 284.129, 284.188], title: "Line chart", legend: "Basic") + .environment(\.colorScheme, .light) } } } diff --git a/Sources/SwiftUICharts/LineChart/LineView.swift b/Sources/SwiftUICharts/LineChart/LineView.swift new file mode 100644 index 00000000..c69434a7 --- /dev/null +++ b/Sources/SwiftUICharts/LineChart/LineView.swift @@ -0,0 +1,135 @@ +// +// LineView.swift +// LineChart +// +// Created by András Samu on 2019. 09. 02.. +// Copyright © 2019. András Samu. All rights reserved. +// + +import SwiftUI + +public struct LineView: View { + @ObservedObject var data: ChartData + public var title: String? + public var legend: String? + public var style: ChartStyle + public var darkModeStyle: ChartStyle + public var valueSpecifier: String + public var legendSpecifier: String + + @Environment(\.colorScheme) var colorScheme: ColorScheme + @State private var showLegend = false + @State private var dragLocation:CGPoint = .zero + @State private var indicatorLocation:CGPoint = .zero + @State private var closestPoint: CGPoint = .zero + @State private var opacity:Double = 0 + @State private var currentDataNumber: Double = 0 + @State private var hideHorizontalLines: Bool = false + + public init(data: [Double], + title: String? = nil, + legend: String? = nil, + style: ChartStyle = Styles.lineChartStyleOne, + valueSpecifier: String? = "%.1f", + legendSpecifier: String? = "%.2f") { + + self.data = ChartData(points: data) + self.title = title + self.legend = legend + self.style = style + self.valueSpecifier = valueSpecifier! + self.legendSpecifier = legendSpecifier! + self.darkModeStyle = style.darkModeStyle != nil ? style.darkModeStyle! : Styles.lineViewDarkMode + } + + public var body: some View { + GeometryReader{ geometry in + VStack(alignment: .leading, spacing: 8) { + Group{ + if (self.title != nil){ + Text(self.title!) + .font(.title) + .bold().foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.textColor : self.style.textColor) + } + if (self.legend != nil){ + Text(self.legend!) + .font(.callout) + .foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.legendTextColor : self.style.legendTextColor) + } + }.offset(x: 0, y: 20) + ZStack{ + GeometryReader{ reader in + Rectangle() + .foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.backgroundColor : self.style.backgroundColor) + if(self.showLegend){ + Legend(data: self.data, + frame: .constant(reader.frame(in: .local)), hideHorizontalLines: self.$hideHorizontalLines, specifier: legendSpecifier) + .transition(.opacity) + .animation(Animation.easeOut(duration: 1).delay(1)) + } + Line(data: self.data, + frame: .constant(CGRect(x: 0, y: 0, width: reader.frame(in: .local).width - 30, height: reader.frame(in: .local).height + 25)), + touchLocation: self.$indicatorLocation, + showIndicator: self.$hideHorizontalLines, + minDataValue: .constant(nil), + maxDataValue: .constant(nil), + showBackground: false, + gradient: self.style.gradientColor + ) + .offset(x: 30, y: 0) + .onAppear(){ + self.showLegend = true + } + .onDisappear(){ + self.showLegend = false + } + } + .frame(width: geometry.frame(in: .local).size.width, height: 240) + .offset(x: 0, y: 40 ) + MagnifierRect(currentNumber: self.$currentDataNumber, valueSpecifier: self.valueSpecifier) + .opacity(self.opacity) + .offset(x: self.dragLocation.x - geometry.frame(in: .local).size.width/2, y: 36) + } + .frame(width: geometry.frame(in: .local).size.width, height: 240) + .gesture(DragGesture() + .onChanged({ value in + self.dragLocation = value.location + self.indicatorLocation = CGPoint(x: max(value.location.x-30,0), y: 32) + self.opacity = 1 + self.closestPoint = self.getClosestDataPoint(toPoint: value.location, width: geometry.frame(in: .local).size.width-30, height: 240) + self.hideHorizontalLines = true + }) + .onEnded({ value in + self.opacity = 0 + self.hideHorizontalLines = false + }) + ) + } + } + } + + func getClosestDataPoint(toPoint: CGPoint, width:CGFloat, height: CGFloat) -> CGPoint { + let points = self.data.onlyPoints() + let stepWidth: CGFloat = width / CGFloat(points.count-1) + let stepHeight: CGFloat = height / CGFloat(points.max()! + points.min()!) + + let index:Int = Int(floor((toPoint.x-15)/stepWidth)) + if (index >= 0 && index < points.count){ + self.currentDataNumber = points[index] + return CGPoint(x: CGFloat(index)*stepWidth, y: CGFloat(points[index])*stepHeight) + } + return .zero + } +} + +struct LineView_Previews: PreviewProvider { + static var previews: some View { + Group { + LineView(data: [8,23,54,32,12,37,7,23,43], title: "Full chart", style: Styles.lineChartStyleOne) + + LineView(data: [282.502, 284.495, 283.51, 285.019, 285.197, 286.118, 288.737, 288.455, 289.391, 287.691, 285.878, 286.46, 286.252, 284.652, 284.129, 284.188], title: "Full chart", style: Styles.lineChartStyleOne) + + } + } +} + diff --git a/Sources/SwiftUICharts/LineChart/MagnifierRect.swift b/Sources/SwiftUICharts/LineChart/MagnifierRect.swift new file mode 100644 index 00000000..479f0bf6 --- /dev/null +++ b/Sources/SwiftUICharts/LineChart/MagnifierRect.swift @@ -0,0 +1,34 @@ +// +// MagnifierRect.swift +// +// +// Created by Samu András on 2020. 03. 04.. +// + +import SwiftUI + +public struct MagnifierRect: View { + @Binding var currentNumber: Double + var valueSpecifier:String + @Environment(\.colorScheme) var colorScheme: ColorScheme + public var body: some View { + ZStack{ + Text("\(self.currentNumber, specifier: valueSpecifier)") + .font(.system(size: 18, weight: .bold)) + .offset(x: 0, y:-110) + .foregroundColor(self.colorScheme == .dark ? Color.white : Color.black) + if (self.colorScheme == .dark ){ + RoundedRectangle(cornerRadius: 16) + .stroke(Color.white, lineWidth: self.colorScheme == .dark ? 2 : 0) + .frame(width: 60, height: 260) + }else{ + RoundedRectangle(cornerRadius: 16) + .frame(width: 60, height: 280) + .foregroundColor(Color.white) + .shadow(color: Colors.LegendText, radius: 12, x: 0, y: 6 ) + .blendMode(.multiply) + } + } + .offset(x: 0, y: -15) + } +} diff --git a/Sources/SwiftUICharts/LineChart/MultiLineChartView.swift b/Sources/SwiftUICharts/LineChart/MultiLineChartView.swift new file mode 100644 index 00000000..93a5f6fb --- /dev/null +++ b/Sources/SwiftUICharts/LineChart/MultiLineChartView.swift @@ -0,0 +1,164 @@ +// +// File.swift +// +// +// Created by Samu András on 2020. 02. 19.. +// + +import SwiftUI + +public struct MultiLineChartView: View { + @Environment(\.colorScheme) var colorScheme: ColorScheme + var data:[MultiLineChartData] + public var title: String + public var legend: String? + public var style: ChartStyle + public var darkModeStyle: ChartStyle + public var formSize: CGSize + public var dropShadow: Bool + public var valueSpecifier:String + + @State private var touchLocation:CGPoint = .zero + @State private var showIndicatorDot: Bool = false + @State private var currentValue: Double = 2 { + didSet{ + if (oldValue != self.currentValue && showIndicatorDot) { + HapticFeedback.playSelection() + } + + } + } + + var globalMin:Double { + if let min = data.flatMap({$0.onlyPoints()}).min() { + return min + } + return 0 + } + + var globalMax:Double { + if let max = data.flatMap({$0.onlyPoints()}).max() { + return max + } + return 0 + } + + var frame = CGSize(width: 180, height: 120) + private var rateValue: Int? + + public init(data: [([Double], GradientColor)], + title: String, + legend: String? = nil, + style: ChartStyle = Styles.lineChartStyleOne, + form: CGSize = ChartForm.medium, + rateValue: Int? = nil, + dropShadow: Bool = true, + valueSpecifier: String = "%.1f") { + + self.data = data.map({ MultiLineChartData(points: $0.0, gradient: $0.1)}) + self.title = title + self.legend = legend + self.style = style + self.darkModeStyle = style.darkModeStyle != nil ? style.darkModeStyle! : Styles.lineViewDarkMode + self.formSize = form + frame = CGSize(width: self.formSize.width, height: self.formSize.height/2) + self.rateValue = rateValue + self.dropShadow = dropShadow + self.valueSpecifier = valueSpecifier + } + + public var body: some View { + ZStack(alignment: .center){ + RoundedRectangle(cornerRadius: 20) + .fill(self.colorScheme == .dark ? self.darkModeStyle.backgroundColor : self.style.backgroundColor) + .frame(width: frame.width, height: 240, alignment: .center) + .shadow(radius: self.dropShadow ? 8 : 0) + VStack(alignment: .leading){ + if(!self.showIndicatorDot){ + VStack(alignment: .leading, spacing: 8){ + Text(self.title) + .font(.title) + .bold() + .foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.textColor : self.style.textColor) + if (self.legend != nil){ + Text(self.legend!) + .font(.callout) + .foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.legendTextColor : self.style.legendTextColor) + } + HStack { + if (rateValue ?? 0 >= 0){ + Image(systemName: "arrow.up") + }else{ + Image(systemName: "arrow.down") + } + Text("\(rateValue ?? 0)%") + } + } + .transition(.opacity) + .animation(.easeIn(duration: 0.1)) + .padding([.leading, .top]) + }else{ + HStack{ + Spacer() + Text("\(self.currentValue, specifier: self.valueSpecifier)") + .font(.system(size: 41, weight: .bold, design: .default)) + .offset(x: 0, y: 30) + Spacer() + } + .transition(.scale) + } + Spacer() + GeometryReader{ geometry in + ZStack{ + ForEach(0.. CGPoint { +// let points = self.data.onlyPoints() +// let stepWidth: CGFloat = width / CGFloat(points.count-1) +// let stepHeight: CGFloat = height / CGFloat(points.max()! + points.min()!) +// +// let index:Int = Int(round((toPoint.x)/stepWidth)) +// if (index >= 0 && index < points.count){ +// self.currentValue = points[index] +// return CGPoint(x: CGFloat(index)*stepWidth, y: CGFloat(points[index])*stepHeight) +// } +// return .zero +// } +} + +struct MultiWidgetView_Previews: PreviewProvider { + static var previews: some View { + Group { + MultiLineChartView(data: [([8,23,54,32,12,37,7,23,43], GradientColors.orange)], title: "Line chart", legend: "Basic") + .environment(\.colorScheme, .light) + } + } +} diff --git a/Sources/SwiftUICharts/LineChart/Path+QuadCurve.swift b/Sources/SwiftUICharts/LineChart/Path+QuadCurve.swift new file mode 100644 index 00000000..83cf114b --- /dev/null +++ b/Sources/SwiftUICharts/LineChart/Path+QuadCurve.swift @@ -0,0 +1,353 @@ +// +// File.swift +// +// +// Created by xspyhack on 2020/1/21. +// + +import SwiftUI + +extension Path { + func trimmedPath(for percent: CGFloat) -> Path { + // percent difference between points + let boundsDistance: CGFloat = 0.001 + let completion: CGFloat = 1 - boundsDistance + + let pct = percent > 1 ? 0 : (percent < 0 ? 1 : percent) + + let start = pct > completion ? completion : pct - boundsDistance + let end = pct > completion ? 1 : pct + boundsDistance + return trimmedPath(from: start, to: end) + } + + func point(for percent: CGFloat) -> CGPoint { + let path = trimmedPath(for: percent) + return CGPoint(x: path.boundingRect.midX, y: path.boundingRect.midY) + } + + func point(to maxX: CGFloat) -> CGPoint { + let total = length + let sub = length(to: maxX) + let percent = sub / total + return point(for: percent) + } + + var length: CGFloat { + var ret: CGFloat = 0.0 + var start: CGPoint? + var point = CGPoint.zero + + forEach { ele in + switch ele { + case .move(let to): + if start == nil { + start = to + } + point = to + case .line(let to): + ret += point.line(to: to) + point = to + case .quadCurve(let to, let control): + ret += point.quadCurve(to: to, control: control) + point = to + case .curve(let to, let control1, let control2): + ret += point.curve(to: to, control1: control1, control2: control2) + point = to + case .closeSubpath: + if let to = start { + ret += point.line(to: to) + point = to + } + start = nil + } + } + return ret + } + + func length(to maxX: CGFloat) -> CGFloat { + var ret: CGFloat = 0.0 + var start: CGPoint? + var point = CGPoint.zero + var finished = false + + forEach { ele in + if finished { + return + } + switch ele { + case .move(let to): + if to.x > maxX { + finished = true + return + } + if start == nil { + start = to + } + point = to + case .line(let to): + if to.x > maxX { + finished = true + ret += point.line(to: to, x: maxX) + return + } + ret += point.line(to: to) + point = to + case .quadCurve(let to, let control): + if to.x > maxX { + finished = true + ret += point.quadCurve(to: to, control: control, x: maxX) + return + } + ret += point.quadCurve(to: to, control: control) + point = to + case .curve(let to, let control1, let control2): + if to.x > maxX { + finished = true + ret += point.curve(to: to, control1: control1, control2: control2, x: maxX) + return + } + ret += point.curve(to: to, control1: control1, control2: control2) + point = to + case .closeSubpath: + fatalError("Can't include closeSubpath") + } + } + return ret + } + + static func quadCurvedPathWithPoints(points:[Double], step:CGPoint, globalOffset: Double? = nil) -> Path { + var path = Path() + if (points.count < 2){ + return path + } + let offset = globalOffset ?? points.min()! +// guard let offset = points.min() else { return path } + var p1 = CGPoint(x: 0, y: CGFloat(points[0]-offset)*step.y) + path.move(to: p1) + for pointIndex in 1.. Path { + var path = Path() + if (points.count < 2){ + return path + } + let offset = globalOffset ?? points.min()! + +// guard let offset = points.min() else { return path } + path.move(to: .zero) + var p1 = CGPoint(x: 0, y: CGFloat(points[0]-offset)*step.y) + path.addLine(to: p1) + for pointIndex in 1.. Path { + var path = Path() + if (points.count < 2){ + return path + } + guard let offset = points.min() else { return path } + let p1 = CGPoint(x: 0, y: CGFloat(points[0]-offset)*step.y) + path.move(to: p1) + for pointIndex in 1.. Path { + var path = Path() + if (points.count < 2){ + return path + } + guard let offset = points.min() else { return path } + var p1 = CGPoint(x: 0, y: CGFloat(points[0]-offset)*step.y) + path.move(to: p1) + for pointIndex in 1.. CGPoint { + let a = (to.y - self.y) / (to.x - self.x) + let y = self.y + (x - self.x) * a + return CGPoint(x: x, y: y) + } + + func line(to: CGPoint) -> CGFloat { + dist(to: to) + } + + func line(to: CGPoint, x: CGFloat) -> CGFloat { + dist(to: point(to: to, x: x)) + } + + func quadCurve(to: CGPoint, control: CGPoint) -> CGFloat { + var dist: CGFloat = 0 + let steps: CGFloat = 100 + + for i in 0.. CGFloat { + var dist: CGFloat = 0 + let steps: CGFloat = 100 + + for i in 0..= x { + return dist + } else if b.x > x { + dist += a.line(to: b, x: x) + return dist + } else if b.x == x { + dist += a.line(to: b) + return dist + } + + dist += a.line(to: b) + } + return dist + } + + func point(to: CGPoint, t: CGFloat, control: CGPoint) -> CGPoint { + let x = CGPoint.value(x: self.x, y: to.x, t: t, c: control.x) + let y = CGPoint.value(x: self.y, y: to.y, t: t, c: control.y) + + return CGPoint(x: x, y: y) + } + + func curve(to: CGPoint, control1: CGPoint, control2: CGPoint) -> CGFloat { + var dist: CGFloat = 0 + let steps: CGFloat = 100 + + for i in 0.. CGFloat { + var dist: CGFloat = 0 + let steps: CGFloat = 100 + + for i in 0..= x { + return dist + } else if b.x > x { + dist += a.line(to: b, x: x) + return dist + } else if b.x == x { + dist += a.line(to: b) + return dist + } + + dist += a.line(to: b) + } + + return dist + } + + func point(to: CGPoint, t: CGFloat, control1: CGPoint, control2: CGPoint) -> CGPoint { + let x = CGPoint.value(x: self.x, y: to.x, t: t, c1: control1.x, c2: control2.x) + let y = CGPoint.value(x: self.y, y: to.y, t: t, c1: control1.y, c2: control2.x) + + return CGPoint(x: x, y: y) + } + + static func value(x: CGFloat, y: CGFloat, t: CGFloat, c: CGFloat) -> CGFloat { + var value: CGFloat = 0.0 + // (1-t)^2 * p0 + 2 * (1-t) * t * c1 + t^2 * p1 + value += pow(1-t, 2) * x + value += 2 * (1-t) * t * c + value += pow(t, 2) * y + return value + } + + static func value(x: CGFloat, y: CGFloat, t: CGFloat, c1: CGFloat, c2: CGFloat) -> CGFloat { + var value: CGFloat = 0.0 + // (1-t)^3 * p0 + 3 * (1-t)^2 * t * c1 + 3 * (1-t) * t^2 * c2 + t^3 * p1 + value += pow(1-t, 3) * x + value += 3 * pow(1-t, 2) * t * c1 + value += 3 * (1-t) * pow(t, 2) * c2 + value += pow(t, 3) * y + return value + } + + static func getMidPoint(point1: CGPoint, point2: CGPoint) -> CGPoint { + return CGPoint( + x: point1.x + (point2.x - point1.x) / 2, + y: point1.y + (point2.y - point1.y) / 2 + ) + } + + func dist(to: CGPoint) -> CGFloat { + return sqrt((pow(self.x - to.x, 2) + pow(self.y - to.y, 2))) + } + + static func midPointForPoints(p1:CGPoint, p2:CGPoint) -> CGPoint { + return CGPoint(x:(p1.x + p2.x) / 2,y: (p1.y + p2.y) / 2) + } + + static func controlPointForPoints(p1:CGPoint, p2:CGPoint) -> CGPoint { + var controlPoint = CGPoint.midPointForPoints(p1:p1, p2:p2) + let diffY = abs(p2.y - controlPoint.y) + + if (p1.y < p2.y){ + controlPoint.y += diffY + } else if (p1.y > p2.y) { + controlPoint.y -= diffY + } + return controlPoint + } +} + diff --git a/Sources/SwiftUICharts/PieChart/PieChartCell.swift b/Sources/SwiftUICharts/PieChart/PieChartCell.swift index 352ce6b7..f511165e 100644 --- a/Sources/SwiftUICharts/PieChart/PieChartCell.swift +++ b/Sources/SwiftUICharts/PieChart/PieChartCell.swift @@ -12,7 +12,7 @@ struct PieSlice: Identifiable { var id = UUID() var startDeg: Double var endDeg: Double - var value: Int + var value: Double var normalizedValue: Double } diff --git a/Sources/SwiftUICharts/PieChart/PieChartHelpers.swift b/Sources/SwiftUICharts/PieChart/PieChartHelpers.swift new file mode 100644 index 00000000..2c2af8b1 --- /dev/null +++ b/Sources/SwiftUICharts/PieChart/PieChartHelpers.swift @@ -0,0 +1,41 @@ +// +// File.swift +// +// +// Created by 曾文志 on 2020/7/30. +// + +import SwiftUI + +func isPointInCircle(point: CGPoint, circleRect: CGRect) -> Bool { + let r = min(circleRect.width, circleRect.height) / 2 + let center = CGPoint(x: circleRect.midX, y: circleRect.midY) + let dx = point.x - center.x + let dy = point.y - center.y + let distance = sqrt(dx * dx + dy * dy) + return distance <= r +} + +func degree(for point: CGPoint, inCircleRect circleRect: CGRect) -> Double { + let center = CGPoint(x: circleRect.midX, y: circleRect.midY) + let dx = point.x - center.x + let dy = point.y - center.y + let acuteDegree = Double(atan(dy / dx)) * (180 / .pi) + + let isInBottomRight = dx >= 0 && dy >= 0 + let isInBottomLeft = dx <= 0 && dy >= 0 + let isInTopLeft = dx <= 0 && dy <= 0 + let isInTopRight = dx >= 0 && dy <= 0 + + if isInBottomRight { + return acuteDegree + } else if isInBottomLeft { + return 180 - abs(acuteDegree) + } else if isInTopLeft { + return 180 + abs(acuteDegree) + } else if isInTopRight { + return 360 - abs(acuteDegree) + } + + return 0 +} diff --git a/Sources/SwiftUICharts/PieChart/PieChartRow.swift b/Sources/SwiftUICharts/PieChart/PieChartRow.swift index 354ebfff..a462cd96 100644 --- a/Sources/SwiftUICharts/PieChart/PieChartRow.swift +++ b/Sources/SwiftUICharts/PieChart/PieChartRow.swift @@ -9,7 +9,7 @@ import SwiftUI public struct PieChartRow : View { - var data: [Int] + var data: [Double] var backgroundColor: Color var accentColor: Color var slices: [PieSlice] { @@ -25,13 +25,42 @@ public struct PieChartRow : View { } return tempSlices } + + @Binding var showValue: Bool + @Binding var currentValue: Double + + @State private var currentTouchedIndex = -1 { + didSet { + if oldValue != currentTouchedIndex { + showValue = currentTouchedIndex != -1 + currentValue = showValue ? slices[currentTouchedIndex].value : 0 + } + } + } + public var body: some View { GeometryReader { geometry in ZStack{ ForEach(0.. touchDegree }) ?? -1 + } else { + self.currentTouchedIndex = -1 + } + }) + .onEnded({ value in + self.currentTouchedIndex = -1 + })) } } } @@ -39,8 +68,12 @@ public struct PieChartRow : View { #if DEBUG struct PieChartRow_Previews : PreviewProvider { static var previews: some View { - PieChartRow(data:[8,23,54,32,12,37,7,23,43], backgroundColor: Color(red: 252.0/255.0, green: 236.0/255.0, blue: 234.0/255.0), accentColor: Color(red: 225.0/255.0, green: 97.0/255.0, blue: 76.0/255.0)).frame(width: 100, height: 100) - + Group { + PieChartRow(data:[8,23,54,32,12,37,7,23,43], backgroundColor: Color(red: 252.0/255.0, green: 236.0/255.0, blue: 234.0/255.0), accentColor: Color(red: 225.0/255.0, green: 97.0/255.0, blue: 76.0/255.0), showValue: Binding.constant(false), currentValue: Binding.constant(0)) + .frame(width: 100, height: 100) + PieChartRow(data:[0], backgroundColor: Color(red: 252.0/255.0, green: 236.0/255.0, blue: 234.0/255.0), accentColor: Color(red: 225.0/255.0, green: 97.0/255.0, blue: 76.0/255.0), showValue: Binding.constant(false), currentValue: Binding.constant(0)) + .frame(width: 100, height: 100) + } } } #endif diff --git a/Sources/SwiftUICharts/PieChart/PieChartView.swift b/Sources/SwiftUICharts/PieChart/PieChartView.swift index 59a06aed..81609bd1 100644 --- a/Sources/SwiftUICharts/PieChart/PieChartView.swift +++ b/Sources/SwiftUICharts/PieChart/PieChartView.swift @@ -9,17 +9,34 @@ import SwiftUI public struct PieChartView : View { - public var data: [Int] + public var data: [Double] public var title: String public var legend: String? public var style: ChartStyle public var formSize:CGSize - public init(data: [Int], title: String, legend: String? = nil, style: ChartStyle = Styles.pieChartStyleOne, form: CGSize? = Form.medium ){ + public var dropShadow: Bool + public var valueSpecifier:String + + @State private var showValue = false + @State private var currentValue: Double = 0 { + didSet{ + if(oldValue != self.currentValue && self.showValue) { + HapticFeedback.playSelection() + } + } + } + + public init(data: [Double], title: String, legend: String? = nil, style: ChartStyle = Styles.pieChartStyleOne, form: CGSize? = ChartForm.medium, dropShadow: Bool? = true, valueSpecifier: String? = "%.1f"){ self.data = data self.title = title self.legend = legend self.style = style self.formSize = form! + if self.formSize == ChartForm.large { + self.formSize = ChartForm.extraLarge + } + self.dropShadow = dropShadow! + self.valueSpecifier = valueSpecifier! } public var body: some View { @@ -27,18 +44,24 @@ public struct PieChartView : View { Rectangle() .fill(self.style.backgroundColor) .cornerRadius(20) - .shadow(color: Color.gray, radius: 12) + .shadow(color: self.style.dropShadowColor, radius: self.dropShadow ? 12 : 0) VStack(alignment: .leading){ HStack{ - Text(self.title) - .font(.headline) - .foregroundColor(self.style.textColor) + if(!showValue){ + Text(self.title) + .font(.headline) + .foregroundColor(self.style.textColor) + }else{ + Text("\(self.currentValue, specifier: self.valueSpecifier)") + .font(.headline) + .foregroundColor(self.style.textColor) + } Spacer() Image(systemName: "chart.pie.fill") .imageScale(.large) .foregroundColor(self.style.legendTextColor) }.padding() - PieChartRow(data: data, backgroundColor: self.style.backgroundColor, accentColor: self.style.accentColor) + PieChartRow(data: data, backgroundColor: self.style.backgroundColor, accentColor: self.style.accentColor, showValue: $showValue, currentValue: $currentValue) .foregroundColor(self.style.accentColor).padding(self.legend != nil ? 0 : 12).offset(y:self.legend != nil ? 0 : -10) if(self.legend != nil) { Text(self.legend!) diff --git a/midnightgreen.gif b/midnightgreen.gif deleted file mode 100644 index 7fc8f746..00000000 Binary files a/midnightgreen.gif and /dev/null differ diff --git a/showcase1.gif b/showcase1.gif deleted file mode 100644 index 52bba15a..00000000 Binary files a/showcase1.gif and /dev/null differ diff --git a/showcase2.gif b/showcase2.gif deleted file mode 100644 index 81a85c64..00000000 Binary files a/showcase2.gif and /dev/null differ diff --git a/showcase3.gif b/showcase3.gif deleted file mode 100644 index 497fed2a..00000000 Binary files a/showcase3.gif and /dev/null differ diff --git a/showcase4.png b/showcase4.png deleted file mode 100644 index a58c861f..00000000 Binary files a/showcase4.png and /dev/null differ diff --git a/showcase5.png b/showcase5.png deleted file mode 100644 index 319cd116..00000000 Binary files a/showcase5.png and /dev/null differ diff --git a/watchos1.png b/watchos1.png deleted file mode 100644 index 65d42e5e..00000000 Binary files a/watchos1.png and /dev/null differ