├── .gitignore ├── Images └── TimerView_Config.png ├── Tests ├── LinuxMain.swift └── KSTimerViewTests │ ├── XCTestManifests.swift │ └── KSTimerViewTests.swift ├── .swiftpm └── xcode │ └── package.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── Sources └── KSTimerView │ ├── HapticHelper.swift │ ├── LocalNotificationHelper.swift │ └── KSTimerView.swift ├── LICENSE ├── Package.swift └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | -------------------------------------------------------------------------------- /Images/TimerView_Config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karthironald/KSTimerView/HEAD/Images/TimerView_Config.png -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import KSTimerViewTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += KSTimerViewTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Tests/KSTimerViewTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(KSTimerViewTests.allTests), 7 | ] 8 | } 9 | #endif 10 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Tests/KSTimerViewTests/KSTimerViewTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | //@testable import KSTimerView 3 | 4 | final class KSTimerViewTests: XCTestCase { 5 | func testExample() { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | // XCTAssertEqual(KSTimerView().text, "Hello, World!") 10 | } 11 | 12 | static var allTests = [ 13 | ("testExample", testExample), 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /Sources/KSTimerView/HapticHelper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HapticHelper.swift 3 | // KSTimerView 4 | // 5 | // Created by Karthick Selvaraj on 13/12/20. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | class HapticHelper: NSObject { 12 | 13 | static let shared = HapticHelper() 14 | var isEnabled = true // Set this to false if you don't want haptic feedback. 15 | 16 | // MARK: - Private init method 17 | 18 | private override init() { 19 | super.init() 20 | } 21 | 22 | // MARK: - Custom methods 23 | 24 | func hapticFeedback(style: UIImpactFeedbackGenerator.FeedbackStyle = .rigid) { 25 | if isEnabled { 26 | let generator = UIImpactFeedbackGenerator(style: style) 27 | generator.impactOccurred() 28 | } 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Karthick Selvaraj 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "KSTimerView", 8 | platforms: [ 9 | .iOS(.v14), 10 | ], 11 | products: [ 12 | // Products define the executables and libraries a package produces, and make them visible to other packages. 13 | .library( 14 | name: "KSTimerView", 15 | targets: ["KSTimerView"]), 16 | ], 17 | dependencies: [ 18 | // Dependencies declare other packages that this package depends on. 19 | // .package(url: /* package url */, from: "1.0.0"), 20 | ], 21 | targets: [ 22 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 23 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 24 | .target( 25 | name: "KSTimerView", 26 | dependencies: []), 27 | .testTarget( 28 | name: "KSTimerViewTests", 29 | dependencies: ["KSTimerView"]), 30 | ] 31 | ) 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # KSTimerView 2 | 3 | A simple `SwiftUI` timer view with **Background**, **LocalNotification** and **Haptic** support. 4 | 5 | ## Demo Video (YouTube) 6 | [![](http://img.youtube.com/vi/o3eHn7bowko/0.jpg)](http://www.youtube.com/watch?v=o3eHn7bowko "") 7 | 8 | ## Usage 9 | 10 | Initialise the KSTimerView with `TimeInterval` and present it using `.sheet` or `.fullScreenCover` modifier or use with `ZStack`. 11 | 12 | ``` 13 | .sheet(isPresented: $shouldPresentTimerView, content: { 14 | KSTimerView(timerInterval: $timeInterval) 15 | }) 16 | ``` 17 | 18 | ### Customisation 19 | 20 | You can customise the **KSTimerView** using `KSTimerView.Configuration` and initialise KSTimerView with your configuration. 21 | 22 | ``` 23 | let configuration = KSTimerView.Configuration(timerBgColor: .yellow, timerRingBgColor: .red, actionButtonsBgColor: .blue, foregroundColor: .white, stepperValue: 10, enableLocalNotification: true, enableHapticFeedback: true) 24 | 25 | KSTimerView(timerInterval: $timeInterval, configuration: configuration) 26 | 27 | ``` 28 | ![Image](https://github.com/karthironald/KSTimerView/blob/main/Images/TimerView_Config.png) 29 | 30 | ## Background 31 | Background to foreground will be handled by default. You no need to do anything. 32 | 33 | ## LocalNotification 34 | 35 | It is disabled by default. Enable it using `enableLocalNotification` in `KSTimerView.Configuration`. 36 | 37 | ``` 38 | let configuration = KSTimerView.Configuration(..., enableLocalNotification: true, ...) 39 | ``` 40 | 41 | > ⚠️ Note: You need to get permission from user to use `LocalNotification`. Get it before presenting the timer view to the user. 42 | 43 | ## Haptic Feedback 44 | It is disabled by default. Enable it using `enableHapticFeedback` in `KSTimerView.Configuration`. 45 | 46 | ``` 47 | let configuration = KSTimerView.Configuration(..., enableHapticFeedback: true) 48 | 49 | ``` 50 | 51 | ## Integration 52 | KSTimerView supports SPM (Swift Package Manager). You can integrate it using Xcode, `File -> Swift Packages -> Add Package Dependency...` 53 | 54 | Enter, **https://github.com/karthironald/KSTimerView** in repo URL. 55 | 56 | ## Contribution 57 | 1. Open an issue, if you need any improvements or if you face any issues. 58 | 59 | Thanks! 👨🏻‍💻 -------------------------------------------------------------------------------- /Sources/KSTimerView/LocalNotificationHelper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocalNotificationHelper.swift 3 | // KSTimerView 4 | // 5 | // Created by Karthick Selvaraj on 13/12/20. 6 | // 7 | 8 | import Foundation 9 | import UserNotifications 10 | 11 | private let kLocalTimerNotificationIdentifier = "kLocalTimerNotificationIdentifier" 12 | 13 | class LocalNotificationHelper: NSObject { 14 | 15 | static let shared = LocalNotificationHelper() 16 | var isEnabled = true // Set this to false if you don't want local notification to be triggered when timer completed. 17 | 18 | // MARK: - Private init method 19 | private override init() { 20 | super.init() 21 | UNUserNotificationCenter.current().delegate = self 22 | } 23 | 24 | // MARK: - Custom methods 25 | 26 | private func registerForPushNotification(interval: TimeInterval) { 27 | let userNotification = UNUserNotificationCenter.current() 28 | userNotification.requestAuthorization(options: [.sound, .badge, .alert]) { (status, error) in 29 | if status && error == nil { 30 | self.scheduleNotification(with: interval) 31 | } 32 | } 33 | } 34 | 35 | /**Resets scheduled local notification*/ 36 | func resetTimerNotification() { 37 | invalidateLocalNotification(with: [kLocalTimerNotificationIdentifier]) 38 | } 39 | 40 | /**Adds local notification at specified interval*/ 41 | func addLocalNoification(interval: TimeInterval) { 42 | if interval > 0 && isEnabled { 43 | let userNotification = UNUserNotificationCenter.current() 44 | userNotification.getNotificationSettings { (setting) in 45 | if setting.authorizationStatus == .authorized { 46 | self.scheduleNotification(with: interval) 47 | } else { 48 | self.registerForPushNotification(interval: interval) 49 | } 50 | } 51 | } 52 | } 53 | 54 | private func scheduleNotification(with interval: TimeInterval) { 55 | invalidateLocalNotification(with: [kLocalTimerNotificationIdentifier]) 56 | 57 | let centre = UNUserNotificationCenter.current() 58 | 59 | let notificationContent = UNMutableNotificationContent() 60 | notificationContent.title = "Timer completed!" 61 | 62 | notificationContent.sound = UNNotificationSound.default 63 | 64 | let triggerAt = UNTimeIntervalNotificationTrigger(timeInterval: interval, repeats: false) 65 | let request = UNNotificationRequest(identifier: kLocalTimerNotificationIdentifier, content: notificationContent, trigger: triggerAt) 66 | centre.add(request) { (error) in 67 | if error != nil { 68 | print(error ?? "") 69 | } 70 | } 71 | } 72 | 73 | /**Invalidate local notification which we configured in `configureLocalNotification()`*/ 74 | private func invalidateLocalNotification(with identifiers: [String]) { 75 | UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers) 76 | UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: identifiers) 77 | } 78 | 79 | } 80 | 81 | extension LocalNotificationHelper: UNUserNotificationCenterDelegate { 82 | 83 | func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { 84 | completionHandler([.sound, .badge, .banner, .list]) 85 | } 86 | 87 | } 88 | -------------------------------------------------------------------------------- /Sources/KSTimerView/KSTimerView.swift: -------------------------------------------------------------------------------- 1 | 2 | import SwiftUI 3 | 4 | public enum KSTimerStatus { 5 | case notStarted, running, paused 6 | } 7 | 8 | public struct KSTimerView: View { 9 | 10 | // MARK: - Private Properties 11 | @State private var offset: CGFloat = 70 12 | @State private var completedTime: TimeInterval = 0 13 | @State private var shouldShowMenus = true // Need to use this for future enhancement 14 | @State private var status: KSTimerStatus = .notStarted 15 | @State private var timer: Timer.TimerPublisher = Timer.publish(every: 1, on: .main, in: .common) 16 | @State private var backgroundAt = Date() 17 | 18 | private var progress: CGFloat { 19 | CGFloat((timerInterval - completedTime) / timerInterval) 20 | } 21 | 22 | // MARK: - Public Properties and Init 23 | @Binding var timerInterval: TimeInterval 24 | 25 | var configuration = KSTimerView.Configuration(timerBgColor: .green, timerRingBgColor: .green, actionButtonsBgColor: .blue, foregroundColor: .white, stepperValue: 5) 26 | 27 | 28 | public init(timerInterval: Binding, configuration: KSTimerView.Configuration = KSTimerView.Configuration(timerBgColor: .green, timerRingBgColor: .green, actionButtonsBgColor: .blue, foregroundColor: .white, stepperValue: 5)) { 29 | self._timerInterval = timerInterval 30 | self.configuration = configuration 31 | } 32 | 33 | // MARK: - Body 34 | public var body: some View { 35 | ZStack { 36 | Color.clear 37 | .onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { (_) in 38 | self.stopTimer() 39 | self.backgroundAt = Date() 40 | } 41 | Color.clear 42 | .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { (_) in 43 | if self.status == .running { 44 | let backgroundInterval = TimeInterval(Int(Date().timeIntervalSince(self.backgroundAt) + 1)) 45 | if (self.completedTime + backgroundInterval) >= (self.timerInterval - 1) { 46 | self.resetDetails() 47 | } else { 48 | self.completedTime = self.completedTime + backgroundInterval 49 | self.startTimer() 50 | } 51 | } 52 | } 53 | if shouldShowMenus { 54 | Color.white 55 | .opacity(0.7) 56 | .edgesIgnoringSafeArea(.all) 57 | } 58 | 59 | // Main timer view button 60 | ZStack { 61 | if status == .running || status == .paused { 62 | Circle() 63 | .trim(from: 0, to: progress) 64 | .stroke(configuration.timerRingBgColor, style: StrokeStyle(lineWidth: 7, lineCap: .round)) 65 | .animation((status == .running || status == .paused) ? Animation.linear(duration: 1) : nil) 66 | .rotationEffect(.degrees(-90)) 67 | .frame(width: shouldShowMenus ? 220 : 70, height: shouldShowMenus ? 220 : 70) 68 | } 69 | 70 | Text("\((Int(timerInterval - completedTime) == 0) ? Int(timerInterval) : Int(timerInterval - completedTime))s") 71 | .font(.largeTitle) 72 | .bold() 73 | .foregroundColor(.white) 74 | .animation(nil) 75 | .frame(width: shouldShowMenus ? 200 : 50, height: shouldShowMenus ? 200 : 50) 76 | .background(configuration.timerBgColor) 77 | .clipShape(Circle()) 78 | 79 | Button(action: { 80 | HapticHelper.shared.hapticFeedback() 81 | if self.status == .running { 82 | self.status = .paused 83 | } else if self.status == .paused || self.status == .notStarted { 84 | self.status = .running 85 | } 86 | configureTimerAndNotification() 87 | }) { 88 | Color.clear 89 | } 90 | .frame(width: shouldShowMenus ? 200 : 50, height: shouldShowMenus ? 200 : 50) 91 | .padding(10) 92 | .shadow(radius: shouldShowMenus ? 5 : 0) 93 | } 94 | .offset(y: shouldShowMenus ? -(offset) : 0) 95 | .animation(.spring()) 96 | 97 | // Minus button 98 | Button(action: { 99 | HapticHelper.shared.hapticFeedback(style: .soft) 100 | if self.timerInterval > configuration.stepperValue { 101 | self.timerInterval = self.timerInterval - configuration.stepperValue 102 | LocalNotificationHelper.shared.addLocalNoification(interval: TimeInterval(self.timerInterval - self.completedTime)) 103 | } 104 | }) { 105 | Text("-\(Int(configuration.stepperValue))s") 106 | .timerControlStyle(backgroundColor: configuration.actionButtonsBgColor) 107 | } 108 | .shadow(radius: shouldShowMenus ? 5 : 0) 109 | .offset(x: shouldShowMenus ? -offset : 0, y: shouldShowMenus ? (offset + 20) : 0) 110 | .animation(.spring()) 111 | 112 | // Plus button 113 | Button(action: { 114 | HapticHelper.shared.hapticFeedback(style: .soft) 115 | self.timerInterval = self.timerInterval + configuration.stepperValue 116 | LocalNotificationHelper.shared.addLocalNoification(interval: TimeInterval(self.timerInterval - self.completedTime)) 117 | }) { 118 | Text("+\(Int(configuration.stepperValue))s") 119 | .timerControlStyle(backgroundColor: configuration.actionButtonsBgColor) 120 | } 121 | .shadow(radius: shouldShowMenus ? 5 : 0) 122 | .offset(x: shouldShowMenus ? offset : 0, y: shouldShowMenus ? (offset + 20) : 0) 123 | .animation(.spring()) 124 | 125 | // Stop button 126 | if status == .running || status == .notStarted { 127 | Button(action: { 128 | HapticHelper.shared.hapticFeedback() 129 | if status == .running { 130 | self.resetDetails() 131 | LocalNotificationHelper.shared.resetTimerNotification() 132 | } else { 133 | status = .running 134 | configureTimerAndNotification() 135 | } 136 | }) { 137 | Image(systemName: status == .running ? "stop" : "play") 138 | .timerControlStyle(backgroundColor: status == .running ? Color.red : configuration.actionButtonsBgColor) 139 | } 140 | .shadow(radius: shouldShowMenus ? 5 : 0) 141 | .offset(y: shouldShowMenus ? (offset + 20) : 0) 142 | .zIndex(10) 143 | } 144 | } 145 | .padding(.leading, shouldShowMenus ? 0 : 10) 146 | .onReceive(timer, perform: { (_) in 147 | if self.status == .running { 148 | self.completedTime += 1 149 | if self.completedTime >= self.timerInterval - 1 { 150 | DispatchQueue.main.asyncAfter(deadline: .now() + 1) { 151 | self.resetDetails() 152 | } 153 | } 154 | } 155 | }) 156 | } 157 | 158 | // MARK: - Custom Methods 159 | /**Configures timer and local notification*/ 160 | private func configureTimerAndNotification() { 161 | if self.status == .running { 162 | self.startTimer() 163 | LocalNotificationHelper.shared.addLocalNoification(interval: TimeInterval(self.timerInterval - self.completedTime)) 164 | } else if self.status == .paused { 165 | self.stopTimer() 166 | LocalNotificationHelper.shared.resetTimerNotification() 167 | } 168 | } 169 | 170 | /**Resets local properties to default*/ 171 | private func resetDetails() { 172 | self.status = .notStarted 173 | self.stopTimer() 174 | self.completedTime = 0 175 | } 176 | 177 | private func stopTimer() { 178 | self.timer.connect().cancel() 179 | } 180 | 181 | private func startTimer() { 182 | self.timer = Timer.publish(every: 1, on: .main, in: .common) 183 | _ = timer.connect() 184 | } 185 | 186 | } 187 | 188 | public extension KSTimerView { 189 | 190 | struct Configuration { 191 | var timerBgColor: Color = .blue 192 | var timerRingBgColor: Color = .blue 193 | var actionButtonsBgColor: Color = .blue 194 | var foregroundColor: Color = .white 195 | var stepperValue: TimeInterval = 10 196 | var enableLocalNotification: Bool = true 197 | var enableHapticFeedback: Bool = true 198 | 199 | public init(timerBgColor: Color = .blue, timerRingBgColor: Color = .blue, actionButtonsBgColor: Color = .blue, foregroundColor: Color = .white, stepperValue: TimeInterval = 10, enableLocalNotification: Bool = true, enableHapticFeedback: Bool = true) { 200 | self.timerBgColor = timerBgColor 201 | self.timerRingBgColor = timerRingBgColor 202 | self.actionButtonsBgColor = actionButtonsBgColor 203 | self.foregroundColor = foregroundColor 204 | self.stepperValue = stepperValue 205 | LocalNotificationHelper.shared.isEnabled = enableLocalNotification 206 | HapticHelper.shared.isEnabled = enableHapticFeedback 207 | } 208 | } 209 | 210 | } 211 | 212 | // MARK: - Custom Modifier 213 | struct TimerControlStyle: ViewModifier { 214 | 215 | var backgroundColor: Color 216 | 217 | func body(content: Content) -> some View { 218 | content 219 | .font(.body) 220 | .frame(width: 60, height: 30) 221 | .background(backgroundColor) 222 | .foregroundColor(.white) 223 | .clipShape(RoundedRectangle(cornerRadius: 15)) 224 | } 225 | } 226 | 227 | extension View { 228 | 229 | func timerControlStyle(backgroundColor: Color) -> some View { 230 | self.modifier(TimerControlStyle(backgroundColor: backgroundColor)) 231 | } 232 | 233 | } 234 | --------------------------------------------------------------------------------