Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions Sources/AccessibleLoading/AccessibleActivityIndicatorView.swift
Original file line number Diff line number Diff line change
@@ -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()

}
37 changes: 0 additions & 37 deletions Sources/AccessibleLoading/AccessibleLoading.swift

This file was deleted.

23 changes: 0 additions & 23 deletions Sources/AccessibleLoading/Config.swift

This file was deleted.

53 changes: 53 additions & 0 deletions Sources/AccessibleLoading/Haptic/AccessibilityFeedbackPlayer.swift
Original file line number Diff line number Diff line change
@@ -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?

}
83 changes: 83 additions & 0 deletions Sources/AccessibleLoading/Haptic/AccessibilityHapticEngine.swift
Original file line number Diff line number Diff line change
@@ -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
}

}
Original file line number Diff line number Diff line change
@@ -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..<numberOfEvents).map { index in
let time = TimeInterval(index) * interval
return makeActivityLoadingEvent(time: time)
}
}

private func makeActivityLoadingEvent(time: TimeInterval) -> 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
)
}

}
Original file line number Diff line number Diff line change
@@ -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

}
Loading