feat: federnde Übergänge und Animationen, Entwürfe je Raum
- Handy: Chat gleitet federnd über die Chatliste (Liste bleibt aufgebaut, Scrollposition erhalten), beim Zurück wieder hinaus; Liste schon während des Hinausgleitens bedienbar. Mitgliederleiste gleitet herein. - Neue/gesendete Nachrichten ploppen weich auf (nur live, nicht beim Öffnen oder Nachladen), Reaktionen ploppen auf und hüpfen beim Hochzählen, Ungelesen-Zähler hüpft, Scroll-nach-unten-Knopf federt (Handy größer), Antwort-Leiste klappt federnd auf/zu, Senden-Pfeil ploppt, Vollansicht zoomt auf. Alles skaliert mit Einstellung „Animationen“ (0 % = aus). - Fix: Beim Raumwechsel blieben Antwort-Ziel, Auswahl und getippter Text am neuen Raum hängen (Antwort auf Raum A konnte in Raum B rausgehen). Jetzt eigenes Eingabefeld je Raum mit Entwurf im Speicher. - Antwort-Leiste: größeres Schließen-X auf dem Handy. - Tests: test/spring_motion_test.dart, UI-Durchlauf mit Übergängen. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1d0ccfde8b
commit
ae6f651f4c
@@ -69,3 +69,138 @@ Duration motionDuration(BuildContext context, int milliseconds) {
|
||||
final motion = PyramidTheme.of(context).motion;
|
||||
return Duration(milliseconds: (milliseconds * motion).round());
|
||||
}
|
||||
|
||||
/// Einmaliges federndes Einblenden (z. B. eine gerade angekommene Nachricht):
|
||||
/// kommt etwas kleiner und tiefer herein und schwingt leicht über.
|
||||
///
|
||||
/// Ob gespielt wird, entscheidet sich nur beim ersten Aufbau ([play]); danach
|
||||
/// bleibt das Kind unverändert, auch wenn [play] sich ändert. Mit
|
||||
/// „Animationen: 0 %“ wird nie gespielt.
|
||||
class PopIn extends StatefulWidget {
|
||||
const PopIn({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.play = true,
|
||||
this.alignment = Alignment.bottomCenter,
|
||||
this.offsetY = 18,
|
||||
this.fromScale = 0.86,
|
||||
this.milliseconds = 460,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final bool play;
|
||||
final Alignment alignment;
|
||||
final double offsetY;
|
||||
final double fromScale;
|
||||
final int milliseconds;
|
||||
|
||||
@override
|
||||
State<PopIn> createState() => _PopInState();
|
||||
}
|
||||
|
||||
class _PopInState extends State<PopIn> with SingleTickerProviderStateMixin {
|
||||
AnimationController? _ctrl;
|
||||
bool _decided = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_decided) return;
|
||||
_decided = true;
|
||||
final motion = PyramidTheme.of(context).motion;
|
||||
if (!widget.play || motion < 0.05) return;
|
||||
_ctrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: Duration(milliseconds: (widget.milliseconds * motion).round()),
|
||||
)..forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ctrl = _ctrl;
|
||||
if (ctrl == null) return widget.child;
|
||||
return AnimatedBuilder(
|
||||
animation: ctrl,
|
||||
child: widget.child,
|
||||
builder: (context, child) {
|
||||
final t = ctrl.value;
|
||||
final move = Curves.easeOutBack.transform(t);
|
||||
final fade = Curves.easeOut.transform((t / 0.4).clamp(0.0, 1.0));
|
||||
return Opacity(
|
||||
opacity: fade,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, (1 - move) * widget.offsetY),
|
||||
child: Transform.scale(
|
||||
scale: widget.fromScale + (1 - widget.fromScale) * move,
|
||||
alignment: widget.alignment,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Federndes „Aufploppen“, sobald [value] steigt (z. B. Ungelesen-Zähler,
|
||||
/// Anzahl einer Reaktion). Beim ersten Aufbau passiert nichts – so ploppt
|
||||
/// beim Scrollen durch eine Liste nichts grundlos auf.
|
||||
class SpringBump extends StatefulWidget {
|
||||
const SpringBump({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.child,
|
||||
this.alignment = Alignment.center,
|
||||
});
|
||||
|
||||
final num value;
|
||||
final Widget child;
|
||||
final Alignment alignment;
|
||||
|
||||
@override
|
||||
State<SpringBump> createState() => _SpringBumpState();
|
||||
}
|
||||
|
||||
class _SpringBumpState extends State<SpringBump>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _ctrl =
|
||||
AnimationController(vsync: this, value: 1);
|
||||
double _from = 1;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(SpringBump oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.value <= oldWidget.value) return;
|
||||
final motion = PyramidTheme.of(context).motion;
|
||||
if (motion < 0.05) return;
|
||||
// Aus dem Nichts (0 → 1) größer aufploppen als beim Hochzählen.
|
||||
_from = oldWidget.value <= 0 ? 0.3 : 0.7;
|
||||
_ctrl.duration = Duration(milliseconds: (650 * motion).round());
|
||||
_ctrl.forward(from: 0);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _ctrl,
|
||||
child: widget.child,
|
||||
builder: (context, child) => Transform.scale(
|
||||
scale: _from + (1 - _from) * Curves.elasticOut.transform(_ctrl.value),
|
||||
alignment: widget.alignment,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user