Polish — Haptic feedback e press-scale per CustomButton
Commit:
polish(button): add haptic feedback and pressed-scale animation· Branch:edit/mobile-audit-ux-polish· Audit: 2026-04-20 UX polish (finding M1)
Contesto
Dopo il refactor refactor(button) che ha allineato CustomButton ai token brand, restava un gap percettivo: i pulsanti sembravano giusti visivamente, ma morti al tocco. Nessun haptic, nessun micro-movimento al press — esperienza da web, non da app nativa. L’audit UX polish (M1) l’aveva marcato come “feedback tattile assente → percezione di sluggishness”.
Questo è un pass percettivo, non funzionale. Nessun API change, nessun nuovo flusso — solo due micro-interazioni che firmano il tocco.
Cosa abbiamo cambiato
1. Haptic selection-click su ogni tap
Introdotto un helper privato _flowTapHaptic() in cima a custom_button.dart che emette HapticFeedback.selectionClick(). È il variante standard-industry per “button pressed”:
- iOS:
UIImpactFeedbackGenerator(style: .light)equivalente - Android:
HapticFeedbackConstants.CONTEXT_CLICK(API 23+) - Device senza haptic hardware: no-op silenzioso (fire-and-forget)
Un secondo helper _withHaptic(VoidCallback?) wrappa il callback dell’utente, restituendo null se l’originale era null (preserva la semantica di “disabled”). Applicato a:
CustomButton(primary, gradient, secondary, outlined)CustomIconButtonCustomFloatingActionButton
NON applicato a ButtonType.text perché i link testuali sono azioni “leggere” (dismiss, cancel, toggle) dove un click aptic sarebbe eccessivo.
void _flowTapHaptic() {
HapticFeedback.selectionClick();
}
VoidCallback? _withHaptic(VoidCallback? onPressed) {
if (onPressed == null) return null;
return () {
_flowTapHaptic();
onPressed();
};
}Uso nel build:
final tapHandler = isDisabled ? null : _withHaptic(onPressed);
// …passato a ElevatedButton/OutlinedButton/TextButton/InkWell2. Press-scale animation (scale 1.0 → 0.96)
Aggiunto _PressableScale, un wrapper StatefulWidget che:
- Ascolta
ListenerperonPointerDown/Up/Cancel(nonGestureDetector— quest’ultimo consuma gli eventi prima che arrivino al childElevatedButton). - Applica
AnimatedScalecon scale 0.96 mentre premuto, 1.0 a riposo, curveCurves.easeOut, durata 120ms. - Consulta
MediaQuery.of(context).disableAnimations(il signal Flutter di reduce-motion, letto da iOS “Reduce Motion” e Android “Remove Animations”).- Se l’utente ha reduce-motion attivo → skip dello scale, ma l’haptic rimane.
class _PressableScale extends StatefulWidget {
final Widget child;
const _PressableScale({required this.child});
// …
}
class _PressableScaleState extends State<_PressableScale> {
bool _pressed = false;
void _setPressed(bool value) { /* setState guard */ }
@override
Widget build(BuildContext context) {
final reduceMotion = MediaQuery.of(context).disableAnimations;
final targetScale = _pressed && !reduceMotion ? 0.96 : 1.0;
return Listener(
onPointerDown: (_) => _setPressed(true),
onPointerUp: (_) => _setPressed(false),
onPointerCancel: (_) => _setPressed(false),
child: AnimatedScale(
scale: targetScale,
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
child: widget.child,
),
);
}
}3. Architettura: wrapper, non StatefulWidget refactor
Potevamo convertire CustomButton da StatelessWidget a StatefulWidget e gestire _pressed inline. Scelta diversa: _PressableScale come wrapper StatefulWidget applicato al termine del build(). Perché:
CustomButtonrestaStatelessWidget— consumatori non cambiano, no rebuild-churn quando la pagina ricostruisce (il wrapper è isolato).- Tutte e tre le factory (
CustomButton,CustomIconButton,CustomFloatingActionButton) possono riusare lo stesso wrapper. - Il wrapper è escluso per
ButtonType.texteisDisabled=truesenza bisogno di if-branch dentro uno state.
Il trade-off: un Listener extra nell’albero widget. Trascurabile — il Listener non consuma eventi (non è un gesture arena participant, è solo un observer).
4. Perché Listener e non GestureDetector?
GestureDetector partecipa alla gesture arena di Flutter: il primo detector a vincere un gesto (es. long-press) consuma gli eventi e impedisce ai figli di vederli. Se avessimo messo GestureDetector(onTapDown/Up) attorno a ElevatedButton, il bottone avrebbe perso il proprio ripple + tap (è anch’esso un participant).
Listener invece è un observer passivo: vede tutti gli eventi pointer senza claim-arle. Il ElevatedButton interno continua a ricevere tap normalmente, e noi in parallelo sappiamo quando il dito è giù.
Diff riassuntivo
lib/shared/widgets/custom_button.dart
import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
import '../../core/theme/app_theme.dart';
import '../../l10n/app_localizations.dart';
+void _flowTapHaptic() {
+ HapticFeedback.selectionClick();
+}
+
+VoidCallback? _withHaptic(VoidCallback? onPressed) {
+ if (onPressed == null) return null;
+ return () {
+ _flowTapHaptic();
+ onPressed();
+ };
+} Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDisabled = isLoading || !isEnabled || onPressed == null;
+ final tapHandler = isDisabled ? null : _withHaptic(onPressed);
+ Widget core;
switch (type) {
case ButtonType.primary:
- return ElevatedButton(
+ core = ElevatedButton(
- onPressed: isDisabled ? null : onPressed,
+ onPressed: tapHandler,
…
);
+ break;
// …altri rami analoghi
}
+ if (type == ButtonType.text || isDisabled) {
+ return core;
+ }
+ return _PressableScale(child: core);
}Perché solo selectionClick e non lightImpact
Flutter espone 5 impact types: selection, light, medium, heavy, vibrate. Abbiamo scelto selectionClick:
lightImpact: simula un piccolo urto → adatto per “apertura di qualcosa” (menu, sheet). Un CTA che invia una form sembra un micro-urto fuori contesto.selectionClick: simula lo scatto meccanico di un toggle/picker → semanticamente = “ho registrato il tuo click”. Perfetto per CTA.mediumImpact/heavyImpact: riservati per conferme forti (submit di acquisto, delete irreversibile). In un prossimo pass si potrà upgradarli selettivamente (es. il CTA “Acquista biglietto” →mediumImpact).
Decisioni deferite
- Haptic upgrade contestuale: i CTA di acquisto, conferma e azioni distruttive meriterebbero
mediumImpactinvece diselectionClick. Non in scope qui — richiede un’API expansion (CustomButton(hapticStrength: HapticStrength.light|medium|strong)) e un audit di quale CTA è quale. Task futuro. - Disable totale dell’haptic tramite setting utente: molte app offrono “Haptics: on/off” nelle Settings. Flow ancora no. Quando lo faremo,
_flowTapHaptic()consulterà il setting invece di chiamare sempreHapticFeedback. - Ripple personalizzato sul press: il ripple Material è ancora quello di default (
ElevatedButtonstock). Potremmo sostituirlo con un glow coral/violet per firmare ulteriormente il tocco. Rimandato: il rischio è di over-design.
Verifica
flutter analyze lib/shared/widgets/custom_button.dart
# → No issues found!Check manuale (su device, NON emulatore — gli emulatori non simulano haptic):
- Tap su CTA primary/gradient/secondary/outlined → feedback tattile + leggero scale down
- Tap + drag-off-before-release → feedback solo all’initial press, scale torna a 1.0 su cancel
- Tap con Settings → Accessibility → Reduce Motion ON → solo haptic, nessuno scale
- Disabled button → nessun haptic, nessuno scale (il wrapper non viene applicato)
-
ButtonType.text→ nessun haptic, nessuno scale (comportamento “link”)
Lessons
- Feedback tattile è firma di brand. Un brand “energetico” come Flow non può sembrare un SSG statico al tocco. L’haptic è il primo segno che l’app è “viva” quando il dito si appoggia.
ListenervsGestureDetector: partecipazione vs osservazione. Confusione comune: si mette unGestureDetectorovunque pensando che “solo ascolti”. Non è vero — partecipa all’arena e può rubare gesti ai figli. Regola flow: se vuoi sapere del gesto ma non consumarlo, usaListener.- Reduce-motion è già risolto da Flutter.
MediaQuery.disableAnimationsè il signal unificato: iOSUIAccessibility.isReduceMotionEnabled, AndroidACCESSIBILITY_REDUCED_ANIMATIONS. Leggerlo costa zero, ignorarlo costa agli utenti con disturbi vestibolari. Non è “optional”. - Wrapper > inheritance per polish incrementale. Convertire
CustomButtoninStatefulWidgetsignifica toccare ogni consumer + preoccuparsi di rebuild-propagation. Un wrapper isolato è diff-small, rollback-safe, e compone con qualsiasi child. Per aggiunte trasversali (haptic, scale, long-press menu), wrapper > extend. - Text buttons non sono bottoni. Un
ButtonType.textè semanticamente un link. Trattarlo uguale al primary (con haptic + scale) crea rumore: il flusso “Cancel” non deve celebrare il tap come fa “Submit”. Differenziare tocchi per peso semantico è parte della grammatica motion.