diff --git a/.gitignore b/.gitignore index 330d167..66a2892 100644 --- a/.gitignore +++ b/.gitignore @@ -44,7 +44,7 @@ playground.xcworkspace # # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata # hence it is not needed unless you have added a package configuration file to your project -# .swiftpm +.swiftpm/ .build/ diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..c0ad9fd --- /dev/null +++ b/Package.swift @@ -0,0 +1,27 @@ +// swift-tools-version:5.3 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "TITimer", + platforms: [ + .iOS(.v13) + ], + products: [ + .library( + name: "TITimer", + targets: ["TITimer"]), + ], + targets: [ + .target( + name: "TITimer", + dependencies: []), + .testTarget( + name: "TITimerTests", + dependencies: ["TITimer"]), + ], + swiftLanguageVersions: [ + .v5 + ] +) diff --git a/README.md b/README.md index 268c7f1..6d8b365 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,37 @@ # TITimer -Pretty timer ⏰ +## Pretty timer ⏰ + +The library allows you to create a timer that works either with [RunLoop](#Runloop) or with [GCD](#GCD). + +### Features + +- Track the time even while the application is in the background. For more, see - [TimerRunMode](/Sources/TITimer/Enums/TimerRunMode.swift) +- Create a timer on a personal queue or on a special Runloop mode. For more, see - [TimerType](Sources/TITimer/Enums/TimerType.swift) +- The code is covered by tests 🙂 + +### Examples + +#### RunLoop + +```swift +timer = TITimer(type: .runloopTimer(runloop: .current, mode: .default), mode: .activeAndBackground) + +timer.eventHandler = { + // handle elapsed time +} + +timer.start() +timer.invalidate() +``` +#### GCD + +```swift +timer = TITimer(type: .dispatchSourceTimer(queue: .main), mode: .activeAndBackground) + +timer.eventHandler = { + // handle elapsed time +} + +timer.start() +timer.invalidate() +``` diff --git a/Sources/TITimer/Enums/TimerRunMode.swift b/Sources/TITimer/Enums/TimerRunMode.swift new file mode 100644 index 0000000..0237187 --- /dev/null +++ b/Sources/TITimer/Enums/TimerRunMode.swift @@ -0,0 +1,15 @@ +// +// TimerRunMode.swift +// +// +// Created by Vlad Suhomlinov on 15.08.2021. +// + +public enum TimerRunMode { + + // Время таймера изменяется только при активной работе приложения + case onlyActive + + // Время таймера изменяется и при активном и свернутом состоянии приложения + case activeAndBackground +} diff --git a/Sources/TITimer/Enums/TimerType.swift b/Sources/TITimer/Enums/TimerType.swift new file mode 100644 index 0000000..9cb7e17 --- /dev/null +++ b/Sources/TITimer/Enums/TimerType.swift @@ -0,0 +1,20 @@ +// +// TimerType.swift +// +// +// Created by Vlad Suhomlinov on 15.08.2021. +// + +import UIKit + +public enum TimerType { + + // Запуск таймера на определенной GCD очереди + case dispatchSourceTimer(queue: DispatchQueue) + + // Запуск таймера на определенном режиме Runloop + case runloopTimer(runloop: RunLoop = .main, mode: RunLoop.Mode) + + // Собственная реализация таймера + case custom(ITimer) +} diff --git a/Sources/TITimer/Protocols/IInvalidatable.swift b/Sources/TITimer/Protocols/IInvalidatable.swift new file mode 100644 index 0000000..a18ccaa --- /dev/null +++ b/Sources/TITimer/Protocols/IInvalidatable.swift @@ -0,0 +1,29 @@ +// +// IInvalidatable.swift +// +// +// Created by Vlad Suhomlinov on 15.08.2021. +// + +import UIKit + +public protocol IInvalidatable: AnyObject { + + // Уничтожить объект + func invalidate() +} + +// MARK: - IInvalidatable + +extension Timer: IInvalidatable { } + +extension DispatchSource: IInvalidatable { + + public func invalidate() { + setEventHandler(handler: nil) + + if !isCancelled { + cancel() + } + } +} diff --git a/Sources/TITimer/Protocols/ITimer.swift b/Sources/TITimer/Protocols/ITimer.swift new file mode 100644 index 0000000..c486ea6 --- /dev/null +++ b/Sources/TITimer/Protocols/ITimer.swift @@ -0,0 +1,23 @@ +// +// ITimer.swift +// +// +// Created by Vlad Suhomlinov on 15.08.2021. +// + +import Foundation + +public protocol ITimer: IInvalidatable { + + // Прошедшее время + var elapsedTime: TimeInterval { get } + + // Запущен таймер или нет + var isRunning: Bool { get } + + // Подписка на изменение прошедшего времени + var eventHandler: ((TimeInterval) -> Void)? { get set } + + // Запустить работу таймера + func start(with interval: TimeInterval) +} diff --git a/Sources/TITimer/TITimer.swift b/Sources/TITimer/TITimer.swift new file mode 100644 index 0000000..7c2be3f --- /dev/null +++ b/Sources/TITimer/TITimer.swift @@ -0,0 +1,157 @@ +import UIKit + +public final class TITimer: ITimer { + + private let mode: TimerRunMode + private let type: TimerType + + private var sourceTimer: IInvalidatable? + + private var enterBackgroundDate: Date? + private var interval: TimeInterval = 0 + + public private(set) var elapsedTime: TimeInterval = 0 + + public var isRunning: Bool { + sourceTimer != nil + } + + public var eventHandler: ((TimeInterval) -> Void)? + + // MARK: - Initialization + + public init(type: TimerType, mode: TimerRunMode) { + self.mode = mode + self.type = type + + if mode == .activeAndBackground { + addObserver() + } + } + + deinit { + if mode == .activeAndBackground { + removeObserver() + } + + invalidate() + } + + // MARK: - Public + + public func start(with interval: TimeInterval = 1) { + invalidate() + + self.interval = interval + + switch type { + case let .dispatchSourceTimer(queue): + sourceTimer = startDispatchSourceTimer(interval: interval, queue: queue) + + case let .runloopTimer(runloop, mode): + sourceTimer = startTimer(interval: interval, runloop: runloop, mode: mode) + + case let .custom(timer): + sourceTimer = timer + + timer.start(with: interval) + } + + eventHandler?(elapsedTime) + } + + public func invalidate() { + elapsedTime = 0 + sourceTimer?.invalidate() + sourceTimer = nil + } + + // MARK: - Private + + @objc private func handleSourceUpdate() { + guard enterBackgroundDate == nil else { + return + } + + elapsedTime += interval + + eventHandler?(elapsedTime) + } +} + +// MARK: - Factory + +extension TITimer { + + public static var `default`: TITimer { + .init(type: .runloopTimer(runloop: .main, mode: .default), mode: .activeAndBackground) + } +} + +// MARK: - NotificationCenter + +private extension TITimer { + + func addObserver() { + NotificationCenter.default.addObserver(self, + selector: #selector(willEnterForegroundNotification), + name: UIApplication.willEnterForegroundNotification, + object: nil) + + NotificationCenter.default.addObserver(self, + selector: #selector(didEnterBackgroundNotification), + name: UIApplication.didEnterBackgroundNotification, + object: nil) + } + + func removeObserver() { + NotificationCenter.default.removeObserver(self) + NotificationCenter.default.removeObserver(self) + } + + @objc func willEnterForegroundNotification() { + guard let unwrappedEnterBackgroundDate = enterBackgroundDate else { + return + } + + let timeInBackground = -unwrappedEnterBackgroundDate.timeIntervalSinceNow.rounded() + + enterBackgroundDate = nil + elapsedTime += timeInBackground + eventHandler?(elapsedTime) + } + + @objc func didEnterBackgroundNotification() { + enterBackgroundDate = Date() + } +} + +// MARK: - DispatchSourceTimer + +private extension TITimer { + + func startDispatchSourceTimer(interval: TimeInterval, queue: DispatchQueue) -> IInvalidatable? { + let timer = DispatchSource.makeTimerSource(flags: [], queue: queue) + timer.schedule(deadline: .now() + interval, repeating: interval) + timer.setEventHandler(handler: handleSourceUpdate) + + timer.resume() + + return timer as? DispatchSource + } +} + +// MARK: - Timer + +private extension TITimer { + + func startTimer(interval: TimeInterval, runloop: RunLoop, mode: RunLoop.Mode) -> IInvalidatable { + let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in + self?.handleSourceUpdate() + } + + runloop.add(timer, forMode: mode) + + return timer + } +} diff --git a/Tests/TITimerTests/TITimerTests.swift b/Tests/TITimerTests/TITimerTests.swift new file mode 100644 index 0000000..6d58dda --- /dev/null +++ b/Tests/TITimerTests/TITimerTests.swift @@ -0,0 +1,110 @@ +import XCTest +@testable import TITimer + +final class TITimerTests: XCTestCase { + + // MARK: - Invalidation + + func test_thanDispatchSourceTimerIsInvalidated_whenIsStopped() { + // given + let expectation = expectation(description: "DispatchSourceTimer is invalidated") + let timer = TITimer(type: .dispatchSourceTimer(queue: .main), mode: .onlyActive) + + // when + timer.start() + + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + timer.invalidate() + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5) + + // then + XCTAssertFalse(timer.isRunning) + } + + func test_RunloopTimerIsInvalidated_whenIsStopped() { + // given + let expectation = expectation(description: "RunloopTimer is invalidated") + let timer = TITimer(type: .runloopTimer(runloop: .main, mode: .default), mode: .onlyActive) + + // when + timer.start() + + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + timer.invalidate() + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5) + + // then + XCTAssertFalse(timer.isRunning) + + } + + // MARK: - Scheduling + + func test_thanIntervalIsCorrect_whenDispatchSourceTimerIsStopped() { + // given + let expectation = expectation(description: "DispatchSourceTimer is invalidated") + let timer = TITimer(type: .dispatchSourceTimer(queue: .main), mode: .onlyActive) + + var elapsedTimes: [TimeInterval] = [] + + timer.eventHandler = { [weak timer] in + elapsedTimes.append($0) + + if $0 == 2 { + timer?.invalidate() + expectation.fulfill() + } + } + + let start = Date() + + // when + + timer.start() + + wait(for: [expectation], timeout: 5) + + // then + let end = Date() + + XCTAssertEqual(2, end.timeIntervalSince(start), accuracy: 0.5) + XCTAssertEqual(elapsedTimes, [0, 1, 2]) + } + + func test_thanIntervalIsCorrect_whenRunloopTimerIsStopped() { + // given + let expectation = expectation(description: "RunloopTimer is invalidated") + let timer = TITimer(type: .runloopTimer(runloop: .current, mode: .default), mode: .onlyActive) + + var elapsedTimes: [TimeInterval] = [] + + timer.eventHandler = { [weak timer] in + elapsedTimes.append($0) + + if $0 == 2 { + timer?.invalidate() + expectation.fulfill() + } + } + + let start = Date() + + // when + + timer.start() + + wait(for: [expectation], timeout: 5) + + // then + let end = Date() + + XCTAssertEqual(2, end.timeIntervalSince(start), accuracy: 0.5) + XCTAssertEqual(elapsedTimes, [0, 1, 2]) + } +}