diff --git a/Sources/AccessibleLoading/AccessibleActivityIndicatorView.swift b/Sources/AccessibleLoading/AccessibleActivityIndicatorView.swift new file mode 100644 index 0000000..f1066b5 --- /dev/null +++ b/Sources/AccessibleLoading/AccessibleActivityIndicatorView.swift @@ -0,0 +1,23 @@ +import UIKit +import Foundation + +@available(iOS 13.0, *) +public final class AccessibleActivityIndicatorView: UIActivityIndicatorView { + + // MARK: - Public Methods + + public override func startAnimating() { + super.startAnimating() + accessibilityHapticEngine?.startActivity() + } + + public override func stopAnimating() { + super.stopAnimating() + accessibilityHapticEngine?.stopActivity() + } + + // MARK: - Private Properties + + private let accessibilityHapticEngine = AccessibilityHapticEngine() + +} diff --git a/Sources/AccessibleLoading/AccessibleLoading.swift b/Sources/AccessibleLoading/AccessibleLoading.swift deleted file mode 100644 index cb997bb..0000000 --- a/Sources/AccessibleLoading/AccessibleLoading.swift +++ /dev/null @@ -1,37 +0,0 @@ -import Foundation - -@available(iOS 13.0, *) -public class AccessibleLoading { - private let player = HapticPlayer() - - public init() {} - - public func start(duration: TimeInterval) { - player.play(pattern: PatternFabric().pattern(duration: duration)) - } - - public func stop() { - player.play(pattern: PatternFabric().finalPattern()) - } -} - -import UIKit -@available(iOS 13.0, *) -open class AccessibleActivityIndicatorView: UIActivityIndicatorView { - - let duration: TimeInterval = 20 - - let haptic = AccessibleLoading() - - open override func startAnimating() { - super.startAnimating() - - haptic.start(duration: duration) - } - - open override func stopAnimating() { - super.stopAnimating() - haptic.stop() - } -} - diff --git a/Sources/AccessibleLoading/Config.swift b/Sources/AccessibleLoading/Config.swift deleted file mode 100644 index f66db7e..0000000 --- a/Sources/AccessibleLoading/Config.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// Config.swift -// -// -// Created by Mikhail Rubanov on 04.04.2021. -// - -import Foundation - -struct Config { - static let countsPerSec = 1 - - static let regular = Event(intensity: 0.4, sharpness: 0.5) - - static let prelast = Event(intensity: 0.5, sharpness: 0.5) - static let delayBetweenLast: TimeInterval = 0.2 - static let last = Event(intensity: 0.75, sharpness: 0) -} - -struct Event { - let intensity: Float - let sharpness: Float -} diff --git a/Sources/AccessibleLoading/Haptic/AccessibilityFeedbackPlayer.swift b/Sources/AccessibleLoading/Haptic/AccessibilityFeedbackPlayer.swift new file mode 100644 index 0000000..11ccec4 --- /dev/null +++ b/Sources/AccessibleLoading/Haptic/AccessibilityFeedbackPlayer.swift @@ -0,0 +1,53 @@ +import Foundation +import CoreHaptics + +@available(iOS 13.0, *) +final class AccessibilityFeedbackPlayer { + + // MARK: - Internal Init + + init?() { + do { + engine = try CHHapticEngine() + } catch { + return nil + } + } + + // MARK: - Internal Methods + + func playLoop(pattern: CHHapticPattern) { + do { + try engine.start() + player = try engine.makeAdvancedPlayer(with: pattern) + player?.loopEnabled = true + try player?.start(atTime: CHHapticTimeImmediate) + } catch { + assertionFailure("Accessibility feedback player unable to start pattern") + return + } + } + + func playOnce(pattern: CHHapticPattern) { + do { + try engine.start() + player = try engine.makeAdvancedPlayer(with: pattern) + try player?.start(atTime: CHHapticTimeImmediate) + } catch { + assertionFailure("Accessibility feedback player unable to start pattern") + return + } + } + + func stop(afterStop: (() -> Void)?) { + try? player?.stop(atTime: CHHapticTimeImmediate) + player = nil + afterStop?() + } + + // MARK: - Private Properties + + private var engine: CHHapticEngine + private var player: CHHapticAdvancedPatternPlayer? + +} diff --git a/Sources/AccessibleLoading/Haptic/AccessibilityHapticEngine.swift b/Sources/AccessibleLoading/Haptic/AccessibilityHapticEngine.swift new file mode 100644 index 0000000..e3af9a3 --- /dev/null +++ b/Sources/AccessibleLoading/Haptic/AccessibilityHapticEngine.swift @@ -0,0 +1,83 @@ +import CoreHaptics +import Foundation +import UIKit + +/// AccessibilityHapticEngine is a class designed to provide haptic feedback for accessibility purposes. +/// It utilizes the Core Haptics framework to create and manage haptic patterns that can be used to indicate loading states or +/// other activities. +/// +/// Usage: +/// ``` +/// let hapticEngine = AccessibilityHapticEngine() +/// hapticEngine?.startActivity() +/// // Some time later... +/// hapticEngine?.stopActivity() +/// ``` +@available(iOS 13.0, *) +public final class AccessibilityHapticEngine { + + // MARK: - Public Init + + /// Initializes the haptic engine. + /// Returns nil if the device does not support haptic feedback. + public init?() { + guard supportsHapticFeedback else { + return nil + } + } + + // MARK: - Public Methods + + /// Starts the haptic feedback loading activity. + /// If there is an existing activity, this method does nothing. + public func startActivity() { + guard activityDelayedWorkItem == nil else { + return + } + activityDelayedWorkItem = DelayedWorkItemExecutor(delay: Constants.delay) { [weak self] in + guard let self else { + return + } + if let pattern = patternFactory.makeOnStartActivityPattern(duration: Constants.duration) { + feedbackPlayer?.playLoop(pattern: pattern) + } + } + } + + /// Stops the ongoing haptic feedback activity and plays a stop activity pattern. + public func stopActivity() { + guard activityDelayedWorkItem != nil else { + return + } + activityDelayedWorkItem = nil + feedbackPlayer?.stop { [weak self] in + guard let self else { + return + } + if let pattern = patternFactory.makeOnStopActivityPattern() { + feedbackPlayer?.playOnce(pattern: pattern) + } + } + } + + // MARK: - Private Types + + private enum Constants { + + static var duration: TimeInterval { TimeInterval(60) } + static var delay: Double { 0.5 } + + } + + // MARK: - Private Properties + + private lazy var patternFactory = AccessibilityHapticPatternFactory() + private lazy var feedbackPlayer = AccessibilityFeedbackPlayer() + + private var activityDelayedWorkItem: DelayedWorkItemExecutor? + + private var supportsHapticFeedback: Bool { + return CHHapticEngine.capabilitiesForHardware().supportsHaptics + } + +} diff --git a/Sources/AccessibleLoading/Haptic/AccessibilityHapticPatternFactory.swift b/Sources/AccessibleLoading/Haptic/AccessibilityHapticPatternFactory.swift new file mode 100644 index 0000000..c699c69 --- /dev/null +++ b/Sources/AccessibleLoading/Haptic/AccessibilityHapticPatternFactory.swift @@ -0,0 +1,76 @@ +import CoreHaptics +import Foundation +import UIKit + +@available(iOS 13.0, *) +final class AccessibilityHapticPatternFactory { + + // MARK: - Internal Methods + + func makeOnStartActivityPattern(duration: TimeInterval) -> CHHapticPattern? { + guard isVoiceOverRunning else { + return nil + } + let events = makeActivityEvents(duration: duration) + return try? CHHapticPattern(events: events, parameterCurves: []) + } + + func makeOnStopActivityPattern() -> CHHapticPattern? { + guard isVoiceOverRunning else { + return nil + } + let events = makeActivityStopEvent() + return try? CHHapticPattern(events: events, parameterCurves: []) + } + + // MARK: - Private Types + + private struct Event { + let intensity: Float + let sharpness: Float + } + + private enum Constants { + static let eventsPerSecond = 1 + static let standardEvent = Event(intensity: 0.4, sharpness: 0.5) + static let penultimateEvent = Event(intensity: 0.5, sharpness: 0.5) + static let finalEvent = Event(intensity: 0.75, sharpness: 0) + static let delayToFinalEvent: TimeInterval = 0.2 + } + + // MARK: - Private Properties + + private var isVoiceOverRunning: Bool { UIAccessibility.isVoiceOverRunning } + + // MARK: - Private Methods + + private func makeActivityEvents(duration: TimeInterval) -> [CHHapticEvent] { + let interval = 1 / TimeInterval(Constants.eventsPerSecond) + let numberOfEvents = Int(duration) * Constants.eventsPerSecond + return (0.. CHHapticEvent { + return makeActivityEvent(with: Constants.standardEvent, time: time) + } + + private func makeActivityStopEvent() -> [CHHapticEvent] { + let beforeFinalEvent = makeActivityEvent(with: Constants.penultimateEvent, time: 0) + let finalEvent = makeActivityEvent(with: Constants.finalEvent, time: Constants.delayToFinalEvent) + return [beforeFinalEvent, finalEvent] + } + + private func makeActivityEvent(with event: Event, time: TimeInterval) -> CHHapticEvent { + let intensityParameter = CHHapticEventParameter(parameterID: .hapticIntensity, value: event.intensity) + let sharpnessParameter = CHHapticEventParameter(parameterID: .hapticSharpness, value: event.sharpness) + return CHHapticEvent( + eventType: .hapticTransient, + parameters: [intensityParameter, sharpnessParameter], + relativeTime: time + ) + } + +} diff --git a/Sources/AccessibleLoading/Haptic/Helpers/DelayedWorkItemExecutor.swift b/Sources/AccessibleLoading/Haptic/Helpers/DelayedWorkItemExecutor.swift new file mode 100644 index 0000000..812f115 --- /dev/null +++ b/Sources/AccessibleLoading/Haptic/Helpers/DelayedWorkItemExecutor.swift @@ -0,0 +1,27 @@ +import Foundation +import Combine + +final class DelayedWorkItemExecutor: Cancellable { + + // MARK: - Internal Init + + init(delay: TimeInterval, queue: DispatchQueue = .main, _ block: @escaping () -> Void) { + self.workItem = DispatchWorkItem(block: block) + queue.asyncAfter(deadline: .now() + delay, execute: workItem) + } + + deinit { + cancel() + } + + // MARK: - Internal Methods + + func cancel() { + workItem.cancel() + } + + // MARK: - Private Properties + + private let workItem: DispatchWorkItem + +} diff --git a/Sources/AccessibleLoading/HapticPlayer.swift b/Sources/AccessibleLoading/HapticPlayer.swift deleted file mode 100644 index d72c875..0000000 --- a/Sources/AccessibleLoading/HapticPlayer.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// HapticPlayer.swift -// -// -// Created by Mikhail Rubanov on 04.04.2021. -// - -import CoreHaptics -import os - -@available(iOS 13.0, *) -public class HapticPlayer { - public init() { - do { - engine = try CHHapticEngine() - try engine?.start() - engine?.stoppedHandler = { [weak self] stoppedReason in - guard let self = self else { return } - os_log(.error, log: self.logger, "Engine stopped by reason %d", stoppedReason.rawValue) - - self.needResetOnNextPlay = true - } - - engine?.resetHandler = { [weak self] in - guard let self = self else { return } - os_log(.error, log: self.logger, "Reset engine") - - do { - try self.engine?.start() - } catch { - os_log(.error, log: self.logger, "Can't create engine on reset", error.localizedDescription) - } - } - } catch let error { - os_log(.error, log: logger, "Can't create engine or start", error.localizedDescription) - } - } - - private var needResetOnNextPlay = false - - public func play(pattern: CHHapticPattern) { - resetIfNeeded() - - do { - player = try engine?.makePlayer(with: pattern) - - try player?.start(atTime: 0) - } catch let error { - os_log(.error, log: logger, "Can't play event", error.localizedDescription) - } - } - - private func resetIfNeeded() { - try? player?.cancel() - - if needResetOnNextPlay { - do { - try self.engine?.start() - needResetOnNextPlay = false - } catch { - os_log(.error, log: self.logger, "Can't reset engine on next play") - } - } - } - - private let logger = OSLog(subsystem: "com.akaDuality.HapticComposer", category: "player") - private var engine: CHHapticEngine? = nil - private var player: CHHapticPatternPlayer? -} - - diff --git a/Sources/AccessibleLoading/PatternFabric.swift b/Sources/AccessibleLoading/PatternFabric.swift deleted file mode 100644 index a201c63..0000000 --- a/Sources/AccessibleLoading/PatternFabric.swift +++ /dev/null @@ -1,79 +0,0 @@ -// -// PatternFabric.swift -// -// -// Created by Mikhail Rubanov on 04.04.2021. -// - -import CoreHaptics - -@available(iOS 13.0, *) -class PatternFabric { - - func pattern(duration: TimeInterval) -> CHHapticPattern { - var events = self.events(countPerSec: Config.countsPerSec, duration: duration) -// events.append(contentsOf: finalEvent(time: duration)) - - let pattern = try! CHHapticPattern( - events: events, - parameterCurves: []) - - return pattern - } - - func finalPattern() -> CHHapticPattern { - let pattern = try! CHHapticPattern( - events: finalEvent(time: 0), - parameterCurves: []) - - return pattern - } - - func events(countPerSec: Int, duration: TimeInterval) -> [CHHapticEvent] { - let interval = 1 / TimeInterval(countPerSec) - var events = [CHHapticEvent]() - - let times = Int(duration) * countPerSec - for index in 0.. [CHHapticEvent] { - [CHHapticEvent( - eventType: .hapticTransient, - parameters: [ - CHHapticEventParameter(parameterID: .hapticIntensity, - value: Config.prelast.intensity), - CHHapticEventParameter(parameterID: .hapticSharpness, - value: Config.prelast.sharpness), - ], - relativeTime: time), - CHHapticEvent( - eventType: .hapticTransient, - parameters: [ - CHHapticEventParameter(parameterID: .hapticIntensity, - value: Config.last.intensity), - CHHapticEventParameter(parameterID: .hapticSharpness, - value: Config.last.sharpness), - ], - relativeTime: time + Config.delayBetweenLast)] - } - - func eventWith(time: TimeInterval) -> CHHapticEvent { - let event = CHHapticEvent( - eventType: .hapticTransient, - parameters: [ - CHHapticEventParameter(parameterID: .hapticIntensity, - value: Config.regular.intensity), - CHHapticEventParameter(parameterID: .hapticSharpness, - value: Config.regular.sharpness)], - relativeTime: time) - return event - } -} -