Files
Felitendo f3eab89dac feat: give the install path something to feel and something to watch
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.
2026-08-17 11:33:08 +02:00

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