├── Sources └── Content │ ├── Views │ ├── IndicatorsView │ │ ├── IndicatorViewConfiguration.swift │ │ └── IndicatorsView.swift │ ├── SpeedDisplayView │ │ └── SpeedDisplayView.swift │ ├── SpeedIndicatorView │ │ └── SpeedIndicatorView.swift │ ├── GaugeView │ │ └── GaugeView.swift │ └── SpeedometerView │ │ └── SpeedometerView.swift │ ├── Extensions │ ├── AngularGradient+Extensions.swift │ ├── Gradient+Extensions.swift │ └── Color+Extensions.swift │ ├── Shapes │ ├── ArcLineShape.swift │ ├── ArcShape.swift │ └── SpeedometerShape.swift │ └── Calculator │ └── Calculator.swift ├── Package.swift ├── .gitignore └── README.md /Sources/Content/Views/IndicatorsView/IndicatorViewConfiguration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IndicatorViewConfiguration.swift 3 | // SpeedometerSwiftUI 4 | // 5 | // Created by Lidor Fadida on 19/07/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | struct IndicatorViewConfiguration: Identifiable { 11 | let id: String = UUID().uuidString 12 | let index: Int 13 | let angle: Double 14 | } 15 | -------------------------------------------------------------------------------- /Sources/Content/Extensions/AngularGradient+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AngularGradient+Extensions.swift 3 | // SpeedometerSwiftUI 4 | // 5 | // Created by Lidor Fadida on 19/07/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension AngularGradient { 11 | static let speedometerAngularGradient = AngularGradient( 12 | gradient: Gradient.speedometerGradient, 13 | center: .center 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.10 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: "SpeedometerSwiftUI", 8 | platforms: [.iOS(.v16)], 9 | products: [ 10 | .library( 11 | name: "Content", 12 | targets: ["SpeedometerSwiftUI"]), 13 | ], 14 | targets: [ 15 | .target( 16 | name: "SpeedometerSwiftUI") 17 | ] 18 | ) 19 | -------------------------------------------------------------------------------- /Sources/Content/Extensions/Gradient+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Gradient+Extensions.swift 3 | // SpeedometerSwiftUI 4 | // 5 | // Created by Lidor Fadida on 19/07/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension Gradient { 11 | static let speedometerGradient = Gradient(stops: [ 12 | Gradient.Stop(color: .crimsonRed, location: 0.0), 13 | Gradient.Stop(color: .crimsonRed, location: 0.25), 14 | Gradient.Stop(color: .forestGreen, location: 0.50), 15 | Gradient.Stop(color: .goldenYellow, location: 0.75), 16 | Gradient.Stop(color: .crimsonRed, location: 1.0) 17 | ]) 18 | } 19 | -------------------------------------------------------------------------------- /Sources/Content/Shapes/ArcLineShape.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ArcLineShape.swift 3 | // SpeedometerSwiftUI 4 | // 5 | // Created by Lidor Fadida on 19/07/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | ///Draws a line from rect.midX , rect.midY to angle calculated position . 11 | struct ArcLineShape: Shape { 12 | var animatableData: Double 13 | 14 | func path(in rect: CGRect) -> Path { 15 | let targetPosition = Calculator.position(in: rect, angle: animatableData) 16 | return Path { path in 17 | path.move(to: CGPoint(x: rect.midX, y: rect.midY)) 18 | path.addLine(to: targetPosition) 19 | } 20 | } 21 | } 22 | 23 | -------------------------------------------------------------------------------- /Sources/Content/Views/SpeedDisplayView/SpeedDisplayView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SpeedDisplayView.swift 3 | // SpeedometerSwiftUI 4 | // 5 | // Created by Lidor Fadida on 19/07/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SpeedDisplayView: View { 11 | let progress: CGFloat 12 | let numberOfSegments: Int 13 | 14 | var body: some View { 15 | VStack(spacing: 6.0) { 16 | Text(String(format: "%.f", progress * CGFloat(numberOfSegments))) 17 | .font(.largeTitle) 18 | .bold() 19 | .monospaced() 20 | .transaction { transaction in 21 | transaction.animation = nil 22 | } 23 | Text("KM/H") 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/Content/Shapes/ArcShape.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ArcShape.swift 3 | // SpeedometerSwiftUI 4 | // 5 | // Created by Lidor Fadida on 19/07/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | ///A simple reusable arc 11 | struct ArcShape: Shape { 12 | let startAngle: Angle 13 | let endAngle: Angle 14 | let clockwise: Bool 15 | 16 | func path(in rect: CGRect) -> Path { 17 | let center = CGPoint(x: rect.midX, y: rect.midY) 18 | let radius = rect.width / 2.0 19 | return Path { path in 20 | path.addArc( 21 | center: center, 22 | radius: radius, 23 | startAngle: startAngle, 24 | endAngle: endAngle, 25 | clockwise: clockwise 26 | ) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/Content/Extensions/Color+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Color+Extensions.swift 3 | // SpeedometerSwiftUI 4 | // 5 | // Created by Lidor Fadida on 19/07/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension Color { 11 | static let crimsonRed = Color( 12 | red: 234.0 / 255.0, 13 | green: 67.0 / 255.0, 14 | blue: 53.0 / 255.0 15 | ) 16 | static let forestGreen = Color( 17 | red: 52.0 / 255.0, 18 | green: 168.0 / 255.0, 19 | blue: 83.0 / 255.0 20 | ) 21 | static let goldenYellow = Color( 22 | red: 251.0 / 255.0, 23 | green: 188.0 / 255.0, 24 | blue: 5.0 / 255.0 25 | ) 26 | static let strongRed = Color( 27 | red: 253.0 / 255.0, 28 | green: 32.0 / 255.0, 29 | blue: 32.0 / 255.0 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /Sources/Content/Views/SpeedIndicatorView/SpeedIndicatorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SpeedIndicatorView.swift 3 | // SpeedometerSwiftUI 4 | // 5 | // Created by Lidor Fadida on 20/07/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SpeedIndicatorView: View { 11 | let labelValue: Int 12 | let rect: CGRect 13 | let angle: Double 14 | let radius: CGFloat 15 | let fontSize: CGFloat 16 | 17 | var body: some View { 18 | Text("\(labelValue)") 19 | .font(.system(size: fontSize)) 20 | .offset( 21 | x: Calculator.position( 22 | in: rect, 23 | angle: angle).x - radius, 24 | 25 | y: Calculator.position( 26 | in: rect, 27 | angle: angle).y - radius 28 | ) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/Content/Shapes/SpeedometerShape.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SpeedometerShape.swift 3 | // SpeedometerSwiftUI 4 | // 5 | // Created by Lidor Fadida on 20/07/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SpeedometerShapeConfiguration { 11 | let angle: Double 12 | let length: CGFloat 13 | } 14 | 15 | struct SpeedometerShape: Shape { 16 | let configurations: [SpeedometerShapeConfiguration] 17 | 18 | func path(in rect: CGRect) -> Path { 19 | var path = Path() 20 | let centerX = rect.midX 21 | let centerY = rect.midY 22 | 23 | ///X^2 + Y^2 = R^2 24 | for config in configurations { 25 | let targetPosition = Calculator.position(in: rect, angle: config.angle) 26 | let dx = (centerX - targetPosition.x) 27 | let dy = (centerY - targetPosition.y) 28 | let distance = sqrt(dx * dx + dy * dy) 29 | 30 | let scale = config.length / distance 31 | let endPoint = CGPoint(x: targetPosition.x + dx * scale, y: targetPosition.y + dy * scale) 32 | 33 | path.move(to: targetPosition) 34 | path.addLine(to: endPoint) 35 | } 36 | 37 | return path 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/Content/Views/GaugeView/GaugeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GaugeView.swift 3 | // SpeedometerSwiftUI 4 | // 5 | // Created by Lidor Fadida on 18/07/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct GaugeView: View { 11 | public var animationDuration: TimeInterval 12 | public var progress: CGFloat 13 | public var numberOfSegments: Int 14 | public var step: Int 15 | public var fontSize: CGFloat 16 | 17 | @State private var meterAngle: Double 18 | @State private var indicatorsConfigurations: [IndicatorViewConfiguration] = [] 19 | 20 | public init(animationDuration: TimeInterval, progress: CGFloat = 0.0, numberOfSegments: Int = 100, step: Int = 10, fontSize: CGFloat = 16) { 21 | self.animationDuration = animationDuration 22 | self.progress = progress 23 | self.numberOfSegments = numberOfSegments 24 | self.step = step 25 | self.fontSize = fontSize 26 | self.meterAngle = Constants.startAngle 27 | } 28 | 29 | private struct Constants { 30 | static let startAngle: Double = 135.0 31 | static let endAngle: Double = 45.0 32 | } 33 | 34 | public var body: some View { 35 | SpeedometerView( 36 | startAngle: Constants.startAngle, 37 | endAngle: Constants.endAngle, 38 | meterAngle: meterAngle, 39 | progress: progress, 40 | numberOfSegments: numberOfSegments, 41 | step: step, 42 | fontSize: fontSize, 43 | indicatorsConfigurations: indicatorsConfigurations 44 | ) 45 | .task { 46 | indicatorsConfigurations = await Calculator.indicatorsConfigurations( 47 | startAngle: Constants.startAngle, 48 | endAngle: Constants.endAngle, 49 | numberOfSegments: numberOfSegments 50 | ) 51 | } 52 | .onChange(of: progress) { _ in 53 | animateMeter() 54 | } 55 | } 56 | 57 | //TODO: - Fix: When the meter is decelerating the animation looks and feels like there is a tiny delay. 58 | private func animateMeter() { 59 | let meterAngle = Calculator.angle(progress: progress, startAngle: Constants.startAngle, endAngle: Constants.endAngle) 60 | withAnimation(.bouncy(duration: animationDuration * 3.0)) { 61 | self.meterAngle = meterAngle 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sources/Content/Views/IndicatorsView/IndicatorsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IndicatorsView.swift 3 | // SpeedometerSwiftUI 4 | // 5 | // Created by Lidor Fadida on 19/07/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct IndicatorsView: View { 11 | let indicatorConfigurations: [IndicatorViewConfiguration] 12 | let step: Int 13 | let fontSize: CGFloat 14 | 15 | private struct Constants { 16 | static let stepLength: CGFloat = 30.0 17 | static let lengthReducer: CGFloat = 10.0 18 | } 19 | 20 | private var speedometerShapeConfigurations: [SpeedometerShapeConfiguration] { 21 | return zip(0..., indicatorConfigurations).compactMap { index, config in 22 | let length: CGFloat = if index.isMultiple(of: step) { 23 | Constants.stepLength 24 | } else if index.isMultiple(of: (step / 2)) { 25 | Constants.stepLength - Constants.lengthReducer 26 | } else { 27 | Constants.stepLength - (Constants.lengthReducer * 2) 28 | } 29 | return SpeedometerShapeConfiguration(angle: config.angle, 30 | length: length) 31 | } 32 | } 33 | 34 | private var filteredIndicatorConfigurations: [IndicatorViewConfiguration] { 35 | return indicatorConfigurations.filter { $0.index.isMultiple(of: step) } 36 | } 37 | 38 | private var speedIndicatorInset: CGFloat { 39 | return (Constants.stepLength + fontSize) 40 | } 41 | 42 | var body: some View { 43 | GeometryReader { proxy in 44 | let width = proxy.size.width 45 | let height = proxy.size.height 46 | SpeedometerShape(configurations: speedometerShapeConfigurations) 47 | .stroke(Color.gray, lineWidth: 1.0) 48 | .frame(width: width, height: height) 49 | ForEach(filteredIndicatorConfigurations) { configuration in 50 | SpeedIndicatorView( 51 | labelValue: configuration.index, 52 | rect: proxy.frame(in: .local).insetBy(dx: speedIndicatorInset, dy: speedIndicatorInset), 53 | angle: configuration.angle, 54 | radius: width / 2.0, 55 | fontSize: fontSize 56 | ) 57 | .frame(width: width, height: height) 58 | } 59 | } 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /Sources/Content/Calculator/Calculator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Calculator.swift 3 | // SpeedometerSwiftUI 4 | // 5 | // Created by Lidor Fadida on 19/07/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Calculator { 11 | 12 | private init() {} 13 | 14 | static func position(in rect: CGRect, angle: Double) -> CGPoint { 15 | let radius = rect.width / 2.0 16 | let angleInRadians = angle * .pi / 180.0 17 | 18 | let x = rect.midX + cos(angleInRadians) * radius 19 | let y = rect.midY + sin(angleInRadians) * radius 20 | return CGPoint(x: x, y: y) 21 | } 22 | 23 | static func angle(progress: CGFloat, startAngle: Double, endAngle: Double) -> Double { 24 | let normalizedEndAngle = (endAngle >= startAngle) ? endAngle : endAngle + 360.0 25 | let totalAngleRange = (normalizedEndAngle - startAngle) 26 | let currentAngle = startAngle + (totalAngleRange * progress) 27 | let adjustedAngle = currentAngle.truncatingRemainder(dividingBy: 360.0) 28 | let angle = if (adjustedAngle < startAngle) { 29 | adjustedAngle + 360.0 30 | } else { 31 | adjustedAngle 32 | } 33 | return angle 34 | } 35 | 36 | static func indicatorsConfigurations(startAngle: Double, endAngle: Double, numberOfSegments: Int) async -> [IndicatorViewConfiguration] { 37 | let angles = await angles(startAngle: startAngle, endAngle: endAngle, numberOfSegments: numberOfSegments) 38 | let configurations = zip(0..., angles).compactMap { index, angle in 39 | let angle = angles[index].truncatingRemainder(dividingBy: 360.0) 40 | return IndicatorViewConfiguration( 41 | index: index, 42 | angle: angle 43 | ) 44 | } 45 | return configurations 46 | } 47 | 48 | private static func angles(startAngle: Double, endAngle: Double, numberOfSegments: Int) async -> [Double] { 49 | var angles: [Double] = [] 50 | 51 | let totalAngle: Double = if startAngle > endAngle { 52 | (360.0 - startAngle) + endAngle 53 | } else { 54 | endAngle - startAngle 55 | } 56 | 57 | let step = totalAngle / Double(numberOfSegments) 58 | 59 | for i in 0...numberOfSegments { 60 | let angle = startAngle + (step * Double(i)) 61 | angles.append(angle) 62 | } 63 | 64 | return angles 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | -------------------------------------------------------------------------------- /Sources/Content/Views/SpeedometerView/SpeedometerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SpeedometerView.swift 3 | // SpeedometerSwiftUI 4 | // 5 | // Created by Lidor Fadida on 19/07/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SpeedometerView: View { 11 | let startAngle: Double 12 | let endAngle: Double 13 | let meterAngle: Double 14 | let progress: CGFloat 15 | let numberOfSegments: Int 16 | let step: Int 17 | let fontSize: CGFloat 18 | let indicatorsConfigurations: [IndicatorViewConfiguration] 19 | 20 | private struct Constants { 21 | static let arcsInset: CGFloat = 20.0 22 | static let meterStrokeStyle = StrokeStyle(lineWidth: 4.0, lineCap: .round) 23 | static let meterTrimStart: CGFloat = 0.3 24 | static let meterTrimEnd: CGFloat = 0.6 25 | static let progressArcLineWidth: CGFloat = 10.0 26 | } 27 | 28 | var body: some View { 29 | GeometryReader { proxy in 30 | ZStack(alignment: .center) { 31 | ArcShape( 32 | startAngle: .degrees(startAngle), 33 | endAngle: .degrees(endAngle), 34 | clockwise: false 35 | ) 36 | .stroke(Color.gray, lineWidth: 1.0) 37 | .frame(width: proxy.size.width, height: proxy.size.height) 38 | 39 | ArcLineShape(animatableData: meterAngle) 40 | .trim(from: Constants.meterTrimStart, to: Constants.meterTrimEnd) //TODO: - Should be calculated dynamically. 41 | .stroke(Color.strongRed, style: Constants.meterStrokeStyle) 42 | .frame(width: proxy.size.width - Constants.arcsInset, height: proxy.size.height - Constants.arcsInset) 43 | ZStack(alignment: .center) { 44 | ArcShape( 45 | startAngle: .degrees(startAngle), 46 | endAngle: .degrees(endAngle), 47 | clockwise: false 48 | ) 49 | .stroke(Color.gray.opacity(0.4), lineWidth: Constants.progressArcLineWidth) 50 | 51 | ArcShape( 52 | startAngle: .degrees(startAngle), 53 | endAngle: .degrees(endAngle), 54 | clockwise: false 55 | ) 56 | .trim(from: .zero, to: progress) 57 | .stroke(AngularGradient.speedometerAngularGradient, lineWidth: Constants.progressArcLineWidth) 58 | } 59 | .frame(width: proxy.size.width + Constants.arcsInset, height: proxy.size.height + Constants.arcsInset) 60 | 61 | IndicatorsView( 62 | indicatorConfigurations: indicatorsConfigurations, 63 | step: step, 64 | fontSize: fontSize 65 | ) 66 | .frame(width: proxy.size.width, height: proxy.size.height) 67 | 68 | SpeedDisplayView( 69 | progress: progress, 70 | numberOfSegments: numberOfSegments 71 | ) 72 | } 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SpeedometerSwiftUI 2 | 3 | `SpeedometerSwiftUI` is a SwiftUI library that provides customizable speedometer gauges for your iOS applications. It includes a variety of components and configurations to create an animated speedometer with indicator lines and labels. This project is part of my exercise with SwiftUI drawings and is not intended to be a production-ready component. It's also worth mentioning that it is not fully flexible. 4 | 5 | 6 | https://github.com/user-attachments/assets/b85d037f-5e62-40fe-a339-e8e248d00078 7 | 8 | 9 | ## Features 10 | 11 | - **Customizable speedometer gauge**: Easily adjust the number of segments, steps, and animation duration. 12 | - **Animated needle**: Smoothly animates the needle as the speed changes. 13 | - **Indicator lines**: Displays indicator lines and labels for each segment. 14 | - **Reusable shapes**: Modular design with reusable shapes for drawing arcs and lines. 15 | 16 | ## Customizing the Gauge 17 | 18 | You can customize the `GaugeView` with parameters such as animation duration, progress, number of segments, step interval for major indicator lines, and font size for the indicator labels. 19 | 20 | ## Indicator Configuration 21 | 22 | The library provides a convenient way to configure indicator lines and labels using the `Calculator` struct. The `Calculator.indicatorsConfigurations` function generates configurations based on the specified start and end angles and the number of segments. 23 | 24 | ## Drawing Shapes 25 | 26 | The library includes several reusable shapes for drawing arcs and lines: 27 | 28 | - `ArcShape`: Draws an arc from a start angle to an end angle. 29 | - `ArcLineShape`: Draws a line from the center of a rectangle to a calculated position based on an angle. 30 | - `SpeedometerShape`: Draws the indicator lines based on the provided configurations. 31 | 32 | 33 | ## Usage Example 34 | 35 | Below is an example of how to integrate and customize the `GaugeView` within your SwiftUI application. 36 | Remember, the `GaugeView` receives a `progress` parameter that can represent download progress, heart rate, or any other progress-oriented metric you can think of. 37 | 38 | ```swift 39 | import SwiftUI 40 | import SpeedometerSwiftUI 41 | 42 | struct Example: View { 43 | @State private var isPressing: Bool = false 44 | @State private var speed: TimeInterval = 0.01 45 | @State var progress: CGFloat = 0.0 46 | @State var numberOfSegments: Int = 10 47 | @State var step: Int = 5 48 | 49 | 50 | var body: some View { 51 | VStack { 52 | speedometer 53 | Button(action: {}, label: { 54 | Image(systemName: "pedal.accelerator") 55 | .resizable() 56 | .symbolVariant(.fill) 57 | .foregroundStyle(.white) 58 | }) 59 | .frame(width: 70.0, height: 100.0) 60 | .rotation3DEffect(isPressing ? .degrees(45) : .zero, axis: (x: 1, y: 0, z: 0)) 61 | .animation(.easeInOut(duration: 0.2), value: isPressing) 62 | .onLongPressGesture(minimumDuration: .infinity, maximumDistance: .infinity, perform: {}, onPressingChanged: { isPressing in 63 | self.isPressing = isPressing 64 | }) 65 | } 66 | } 67 | 68 | private var speedometer: some View { 69 | TimelineView(.animation(minimumInterval: speed)) { context in 70 | GaugeView( 71 | animationDuration: speed, 72 | progress: progress, 73 | numberOfSegments: numberOfSegments, 74 | step: step 75 | ) 76 | .onChange(of: context.date) { oldValue, newValue in 77 | let progress = if isPressing { 78 | min(1.0, progress + 0.01) 79 | } else { 80 | max(0.0, progress - 0.01) 81 | } 82 | 83 | guard self.progress != progress else { return } 84 | 85 | self.speed = if isPressing { 0.05 } else { 0.2 } 86 | withAnimation(.bouncy(duration: speed * 3.0)) { 87 | self.progress = progress 88 | } 89 | } 90 | .frame(width: 300.0, height: 300.0) 91 | } 92 | } 93 | } 94 | 95 | #Preview { 96 | Example(numberOfSegments: 100, step: 10) 97 | .preferredColorScheme(.dark) 98 | } 99 | ``` 100 | 101 | ## Contributing 102 | 103 | Contributions are welcome! Please feel free to submit a pull request or open an issue to discuss any changes or improvements. 104 | 105 | ## Contact 106 | 107 | If you have any questions or feedback, feel free to contact me on [LinkedIn](https://www.linkedin.com/in/lidor-fadida/). 108 | --------------------------------------------------------------------------------