Tactile feedback was scattered ad-hoc generators in a dozen files, and the install path had none of it. There is one helper now, and it distinguishes the three kinds iOS actually distinguishes: a press, a discrete step, and an outcome. Choosing between them is the whole point. An impact where a notification belongs throws away the only part a person can feel. Where it fires, and why each one is that kind: - Install, Update and Open give a press. The tap lives in the shared button rather than at the four call sites, so a fifth cannot be added without it. - Signing ending gives a firmer press: it is the moment the phone puts a sheet in front of you, after the longest silent stretch of the flow. - The install finishing gives an outcome, either way. That is the end of the journey and the only moment that says which way it went. - Stopping everything gives a warning, because it is the one control that throws away work and cannot be undone. - The floating circle taps when picked up, ticks when it settles against a side, and presses when opened. Settling is a selection, not an impact: it is not hitting anything, it is choosing between two positions. - An import with nothing after it reports success. A store install deliberately does not, because signing comes next and the sheet will say so; two "done"s for one install is worse than one in the wrong place. - A press that would be ignored stays silent. Past three quarters the progress ring no longer cancels, so it no longer pretends to. The circle now grows out of its corner and shrinks back into it instead of appearing at full size, the count badge pops out of the ring's edge when a second download joins, and rows in the sheet scale in and leave toward the trailing edge. Held downloads cross-fade to the warning tint rather than switching, and the progress glyph cross-fades when it reaches the archive stage.
69 lines
2.4 KiB
Swift
69 lines
2.4 KiB
Swift
//
|
|
// NBHaptic.swift
|
|
// NimbleKit
|
|
//
|
|
// One call site for tactile feedback, so the whole app taps the same way.
|
|
//
|
|
|
|
#if os(iOS)
|
|
import UIKit
|
|
|
|
/// Tactile feedback, in the three kinds iOS actually distinguishes.
|
|
///
|
|
/// Picking between them is not decoration. `tap` is "you pressed a thing",
|
|
/// `selection` is "the thing you were scrubbing moved a notch", and `notify` is
|
|
/// "it worked" or "it didn't". Using an impact where a notification belongs
|
|
/// throws away the only part a person can feel the difference in.
|
|
///
|
|
/// Deliberately stateless: no cached generators. A generator held as static
|
|
/// mutable state is shared concurrency-checked state for the sake of a
|
|
/// `prepare()` whose lead time is measured in the milliseconds between creating
|
|
/// it and firing it, which is to say none. Cheap to make, so make one.
|
|
public enum NBHaptic {
|
|
/// A press. The default weight is for ordinary primary actions (install,
|
|
/// update, open); reach for a heavier style only when the action is heavier.
|
|
public static func tap(_ style: UIImpactFeedbackGenerator.FeedbackStyle = .light) {
|
|
_onMain {
|
|
let generator = UIImpactFeedbackGenerator(style: style)
|
|
generator.prepare()
|
|
generator.impactOccurred()
|
|
}
|
|
}
|
|
|
|
/// An outcome: success, warning, failure. Never for a button press.
|
|
public static func notify(_ type: UINotificationFeedbackGenerator.FeedbackType) {
|
|
_onMain {
|
|
let generator = UINotificationFeedbackGenerator()
|
|
generator.prepare()
|
|
generator.notificationOccurred(type)
|
|
}
|
|
}
|
|
|
|
/// A discrete step: a picker moving on, a segment changing, a control
|
|
/// snapping to a position.
|
|
public static func selection() {
|
|
_onMain {
|
|
let generator = UISelectionFeedbackGenerator()
|
|
generator.prepare()
|
|
generator.selectionChanged()
|
|
}
|
|
}
|
|
|
|
/// `UIFeedbackGenerator` is main-thread only, and plenty of the places worth
|
|
/// giving feedback for are completion handlers on somebody else's queue.
|
|
/// Hopping here rather than at each call site means no caller has to know.
|
|
///
|
|
/// `@Sendable` because this package builds under a newer language mode than
|
|
/// the app does, where `DispatchQueue.async(execute:)` requires it. Every
|
|
/// closure passed in captures nothing but a feedback style, so it costs
|
|
/// nothing to promise.
|
|
private static func _onMain(_ body: @escaping @Sendable () -> Void) {
|
|
if Thread.isMainThread {
|
|
body()
|
|
} else {
|
|
DispatchQueue.main.async(execute: body)
|
|
}
|
|
}
|
|
}
|
|
#endif
|