[DECKS-689] Make HapticsService reusable across projects (#1)
Work on xiiagency/Decks#689 Work on xiiagency/Xray#142
This commit is contained in:
parent
e1029141bb
commit
e998a94b92
|
@ -14,11 +14,19 @@ let package =
|
||||||
targets: ["SwiftUIHaptics"]
|
targets: ["SwiftUIHaptics"]
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
dependencies: [],
|
dependencies: [
|
||||||
|
.package(
|
||||||
|
name: "SwiftFoundationExtensions",
|
||||||
|
url: "https://github.com/xiiagency/SwiftFoundationExtensions",
|
||||||
|
.branchItem("main")
|
||||||
|
),
|
||||||
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(
|
.target(
|
||||||
name: "SwiftUIHaptics",
|
name: "SwiftUIHaptics",
|
||||||
dependencies: []
|
dependencies: [
|
||||||
|
"SwiftFoundationExtensions"
|
||||||
|
]
|
||||||
),
|
),
|
||||||
// NOTE: Re-enable when tests are added.
|
// NOTE: Re-enable when tests are added.
|
||||||
// .testTarget(
|
// .testTarget(
|
||||||
|
|
|
@ -0,0 +1,109 @@
|
||||||
|
import CoreHaptics
|
||||||
|
|
||||||
|
/**
|
||||||
|
A struct containing a series of `CHHapticEvent`s to be played via the `HapticsService`.
|
||||||
|
*/
|
||||||
|
public struct HapticEvent {
|
||||||
|
/**
|
||||||
|
The underlying events to be played.
|
||||||
|
*/
|
||||||
|
public let underlyingEvents: [CHHapticEvent]
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension HapticEvent {
|
||||||
|
/**
|
||||||
|
Creates a single `HapticEvent`, with the ability to specify all the parameters and defaulting others.
|
||||||
|
*/
|
||||||
|
static func single(
|
||||||
|
intensity: Float? = nil,
|
||||||
|
sharpness: Float? = nil,
|
||||||
|
attack: Float? = nil,
|
||||||
|
decay: Float? = nil,
|
||||||
|
release: Float? = nil,
|
||||||
|
sustained: Float? = nil,
|
||||||
|
relativeTime: TimeInterval = 0.0,
|
||||||
|
duration: TimeInterval = 0.0
|
||||||
|
) -> HapticEvent {
|
||||||
|
var hapticsParameters: [CHHapticEventParameter] = []
|
||||||
|
if let intensity = intensity {
|
||||||
|
hapticsParameters.append(
|
||||||
|
CHHapticEventParameter(parameterID: .hapticIntensity, value: intensity)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let sharpness = sharpness {
|
||||||
|
hapticsParameters.append(
|
||||||
|
CHHapticEventParameter(parameterID: .hapticSharpness, value: sharpness)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let attack = attack {
|
||||||
|
hapticsParameters.append(
|
||||||
|
CHHapticEventParameter(parameterID: .attackTime, value: attack)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let decay = decay {
|
||||||
|
hapticsParameters.append(
|
||||||
|
CHHapticEventParameter(parameterID: .decayTime, value: decay)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let release = release {
|
||||||
|
hapticsParameters.append(
|
||||||
|
CHHapticEventParameter(parameterID: .releaseTime, value: release)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let sustained = sustained {
|
||||||
|
hapticsParameters.append(
|
||||||
|
CHHapticEventParameter(parameterID: .sustained, value: sustained)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return HapticEvent(
|
||||||
|
underlyingEvents: [
|
||||||
|
CHHapticEvent(
|
||||||
|
eventType: .hapticTransient,
|
||||||
|
parameters: hapticsParameters,
|
||||||
|
relativeTime: relativeTime,
|
||||||
|
duration: duration
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Creates a pattern of haptic events by combining all underlying events for the provided `HapticEvent` instances.
|
||||||
|
*/
|
||||||
|
static func pattern(events: [HapticEvent]) -> HapticEvent {
|
||||||
|
let combinedUnderlyingEvents: [CHHapticEvent] = events.flatMap { event in
|
||||||
|
event.underlyingEvents
|
||||||
|
}
|
||||||
|
|
||||||
|
return HapticEvent(underlyingEvents: combinedUnderlyingEvents)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Creates a pattern of haptic events by combining all underlying events for the provided `HapticEvent` instances.
|
||||||
|
*/
|
||||||
|
static func pattern(_ events: HapticEvent...) -> HapticEvent {
|
||||||
|
pattern(events: events)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Returns a time shifted `HapticEvent` with the provided relative time and play duration specified.
|
||||||
|
*/
|
||||||
|
func timeShifted(relativeTime: TimeInterval, duration: TimeInterval) -> HapticEvent {
|
||||||
|
let shiftedUnderlyingEvents: [CHHapticEvent] = underlyingEvents.map { event in
|
||||||
|
CHHapticEvent(
|
||||||
|
eventType: .hapticTransient,
|
||||||
|
parameters: event.eventParameters,
|
||||||
|
relativeTime: relativeTime,
|
||||||
|
duration: duration
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return HapticEvent(underlyingEvents: shiftedUnderlyingEvents)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,172 @@
|
||||||
|
import CoreHaptics
|
||||||
|
import SwiftFoundationExtensions
|
||||||
|
import SwiftUI
|
||||||
|
import os
|
||||||
|
|
||||||
|
/**
|
||||||
|
A service that integrates to the device's haptics engine, if it is available.
|
||||||
|
*/
|
||||||
|
public class HapticsService : ObservableObject {
|
||||||
|
private static let logger: Logger = .loggerFor(HapticsService.self)
|
||||||
|
|
||||||
|
/**
|
||||||
|
True if the device supports haptic feedback, false otherwise.
|
||||||
|
*/
|
||||||
|
public private(set) var isAvailable: Bool = CHHapticEngine.capabilitiesForHardware()
|
||||||
|
.supportsHaptics
|
||||||
|
|
||||||
|
/**
|
||||||
|
The currently initialized haptics engine.
|
||||||
|
*/
|
||||||
|
private var currentEngine: CHHapticEngine? = nil
|
||||||
|
|
||||||
|
public init() {
|
||||||
|
initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
deinitialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Called when the service is initialized, subscribes to foreground/background notifications and initializes the haptics engine.
|
||||||
|
|
||||||
|
NOTE: Implemented as a separate fileprivate func in order to allow for the mock of this service to override the initialization process.
|
||||||
|
*/
|
||||||
|
fileprivate func initialize() {
|
||||||
|
startReceivingAppStatusNotifications()
|
||||||
|
setupEngine()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Called when the service is de-initialized, unsubscribes to foreground/background notifications.
|
||||||
|
|
||||||
|
NOTE: Implemented as a separate fileprivate func in order to allow for the mock of this service to override the de-initialization process.
|
||||||
|
*/
|
||||||
|
fileprivate func deinitialize() {
|
||||||
|
stopReceivingAppStatusNotifications()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Called to subscribe to notifications of the app entering/exiting foreground so that we can start/shutdown the haptics engine to match.
|
||||||
|
*/
|
||||||
|
private func startReceivingAppStatusNotifications() {
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(setupEngine),
|
||||||
|
name: UIApplication.willEnterForegroundNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(shutdownEngine),
|
||||||
|
name: UIApplication.willResignActiveNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Called when de-initialized to unsubscribe from foreground/background notifications.
|
||||||
|
*/
|
||||||
|
private func stopReceivingAppStatusNotifications() {
|
||||||
|
NotificationCenter.default.removeObserver(
|
||||||
|
self,
|
||||||
|
name: UIApplication.willEnterForegroundNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
NotificationCenter.default.removeObserver(
|
||||||
|
self,
|
||||||
|
name: UIApplication.willResignActiveNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Sets up the device's haptic engine. Called when this service is initialized or whenever the app returns to foreground.
|
||||||
|
*/
|
||||||
|
@objc
|
||||||
|
private func setupEngine() {
|
||||||
|
shutdownEngine()
|
||||||
|
|
||||||
|
do {
|
||||||
|
let engine = try CHHapticEngine()
|
||||||
|
engine.start(
|
||||||
|
completionHandler: { [self] error in
|
||||||
|
if let error = error {
|
||||||
|
Self.logger.error("Error starting haptics engine: \(error.description, privacy: .public)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentEngine = engine
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
Self.logger.error("Error creating haptics engine: \(error.description, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Shuts down the haptic engine. Called whenever the app enters background.
|
||||||
|
*/
|
||||||
|
@objc
|
||||||
|
private func shutdownEngine() {
|
||||||
|
guard let engine = currentEngine else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.stop(
|
||||||
|
completionHandler: { [self] error in
|
||||||
|
if let error = error {
|
||||||
|
Self.logger.error("Error occurred shutting down haptics engine: \(error.description, privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
|
currentEngine = nil
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Plays a pattern of haptic events using the current haptics engine, if it is defined and haptics are supported.
|
||||||
|
*/
|
||||||
|
public func play(event: HapticEvent) {
|
||||||
|
guard let engine = currentEngine else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let pattern = try CHHapticPattern(events: event.underlyingEvents, parameters: [])
|
||||||
|
let player = try engine.makePlayer(with: pattern)
|
||||||
|
try player.start(atTime: 0)
|
||||||
|
} catch {
|
||||||
|
Self.logger.error("Error attempting to play haptics pattern: \(error.description, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
/**
|
||||||
|
Provides the ability to create a "mock" `HapticsService` to be used in preview code.
|
||||||
|
*/
|
||||||
|
public class MockHapticsService : HapticsService {
|
||||||
|
override func initialize() {
|
||||||
|
// no-op, blocks default initializer.
|
||||||
|
}
|
||||||
|
|
||||||
|
override func deinitialize() {
|
||||||
|
// no-op, blocks default shutdown.
|
||||||
|
}
|
||||||
|
|
||||||
|
public var isAvailableCallback: () -> Bool = { true }
|
||||||
|
public var playCallback: (HapticEvent) -> Void = { _ in Void() }
|
||||||
|
|
||||||
|
public override var isAvailable: Bool {
|
||||||
|
isAvailableCallback()
|
||||||
|
}
|
||||||
|
|
||||||
|
public override func play(event: HapticEvent) {
|
||||||
|
playCallback(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
Loading…
Reference in New Issue