Gestaffelte Enter-Animation für Songlisten (EinblendItem)

Items in song_list.dart und artist_list.dart faden/rutschen beim ersten
Erscheinen gestaffelt statt abrupt ein — angelehnt an HyperOS' fließende
Motion-Sprache. Läuft einmal pro Element, respektiert Reduce-Motion und
deckelt die Verzögerung ab Index 12.
This commit is contained in:
Hermes (Server)
2026-08-29 12:51:04 +02:00
parent 196303145a
commit e2304b66cd
5 changed files with 235 additions and 26 deletions
+28 -18
View File
@@ -5,6 +5,7 @@ import '../settings/app_settings.dart';
import '../shared/auswahl.dart';
import '../shared/auswahl_leiste.dart';
import '../shared/cover.dart';
import '../shared/einblend_item.dart';
import '../shared/theme.dart';
import '../shared/titel_listen_screen.dart';
import 'category_service.dart';
@@ -44,7 +45,10 @@ class _ArtistListScreenState extends State<ArtistListScreen> {
itemCount: artists.length,
itemBuilder: (context, i) {
final artist = artists[i];
return _KuenstlerZeile(name: artist, songs: grouped[artist]!);
return EinblendItem(
index: i,
child: _KuenstlerZeile(name: artist, songs: grouped[artist]!),
);
},
);
},
@@ -167,25 +171,31 @@ class _KuenstlerScreenState extends State<KuenstlerScreen> {
body: ListView.builder(
itemCount: vorspann + widget.songs.length,
itemBuilder: (context, i) {
if (zeigeAlben) {
if (i == 0) return const _Kopf('Alben');
if (i <= albumListe.length) {
final eintrag = albumListe[i - 1];
return _AlbumZeile(name: eintrag.key, songs: eintrag.value);
Widget zeile() {
if (zeigeAlben) {
if (i == 0) return const _Kopf('Alben');
if (i <= albumListe.length) {
final eintrag = albumListe[i - 1];
return _AlbumZeile(name: eintrag.key, songs: eintrag.value);
}
if (i == albumListe.length + 1) {
return const Divider(height: 1);
}
if (i == albumListe.length + 2) return const _Kopf('Alle Titel');
}
if (i == albumListe.length + 1) return const Divider(height: 1);
if (i == albumListe.length + 2) return const _Kopf('Alle Titel');
final index = i - vorspann;
final song = widget.songs[index];
return SongZeile(
song: song,
warteschlange: widget.songs,
index: index,
auswahlModus: _waehltAus,
ausgewaehlt: _auswahl.contains(song.id),
onAuswahlWechsel: () => _wechsleAuswahl(song.id),
);
}
final index = i - vorspann;
final song = widget.songs[index];
return SongZeile(
song: song,
warteschlange: widget.songs,
index: index,
auswahlModus: _waehltAus,
ausgewaehlt: _auswahl.contains(song.id),
onAuswahlWechsel: () => _wechsleAuswahl(song.id),
);
return EinblendItem(index: i, child: zeile());
},
),
),
+12 -8
View File
@@ -7,6 +7,7 @@ import '../player/now_playing_screen.dart';
import '../playlists/create_playlist_dialog.dart';
import '../settings/app_settings.dart';
import '../shared/cover.dart';
import '../shared/einblend_item.dart';
import '../shared/favorite_button.dart';
import '../shared/lauf_balken.dart';
import 'category_service.dart';
@@ -94,15 +95,18 @@ class SongList extends StatelessWidget {
controller: controller,
itemExtent: festeHoehe ? songZeilenHoeheFuer(context) : null,
itemCount: songs.length,
itemBuilder: (context, i) => SongZeile(
song: songs[i],
warteschlange: songs,
itemBuilder: (context, i) => EinblendItem(
index: i,
auswahlModus: gewaehlt != null && gewaehlt.isNotEmpty,
ausgewaehlt: gewaehlt?.contains(songs[i].id) ?? false,
onAuswahlWechsel: onAuswahlWechsel == null
? null
: () => onAuswahlWechsel!(songs[i].id),
child: SongZeile(
song: songs[i],
warteschlange: songs,
index: i,
auswahlModus: gewaehlt != null && gewaehlt.isNotEmpty,
ausgewaehlt: gewaehlt?.contains(songs[i].id) ?? false,
onAuswahlWechsel: onAuswahlWechsel == null
? null
: () => onAuswahlWechsel!(songs[i].id),
),
),
);
}
+91
View File
@@ -0,0 +1,91 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'theme.dart';
/// Blendet [child] beim ersten Erscheinen leicht gestaffelt ein — statt
/// abrupt aufzupoppen, faded und rutscht es ein kleines Stück von unten
/// heran. Je weiter hinten in der Liste, desto später beginnt es, damit die
/// Liste als Ganzes fließt statt gleichzeitig aufzublitzen.
///
/// Läuft genau einmal je Element-Lebenszeit: ein Rebuild (etwa weil sich der
/// Song-Titel ändert) startet die Animation nicht erneut.
class EinblendItem extends StatefulWidget {
const EinblendItem({super.key, required this.child, required this.index});
final Widget child;
/// Position in der Liste — bestimmt die Verzögerung vor dem Start.
final int index;
@override
State<EinblendItem> createState() => _EinblendItemState();
}
class _EinblendItemState extends State<EinblendItem>
with SingleTickerProviderStateMixin {
/// Ab Index 12 keine zusätzliche Verzögerung mehr — sonst wirkte eine
/// lange Liste beim ersten Erscheinen träge, weil die letzten Zeilen erst
/// spät anfingen.
static const _deckel = 12;
static const _versatzProItem = Duration(milliseconds: 25);
late final AnimationController _controller = AnimationController(
vsync: this,
duration: MeloMotion.normal,
);
late final Animation<double> _kurve = CurvedAnimation(
parent: _controller,
curve: MeloMotion.curve,
);
bool _gestartet = false;
// Kein Start in initState: die Entscheidung hängt an MediaQuery
// (Reduce-Motion), und darauf darf man dort noch nicht zugreifen — siehe
// LaufBalken. didChangeDependencies läuft unmittelbar danach und ein
// _gestartet-Merker verhindert, dass ein späterer Aufruf (z. B. durch
// eine geänderte Textgröße) die Animation erneut anstößt.
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_gestartet) return;
_gestartet = true;
if (MeloMotion.ruhig(context, MeloMotion.normal) == Duration.zero) {
_controller.value = 1;
return;
}
final verzoegerung = _versatzProItem * math.min(widget.index, _deckel);
if (verzoegerung == Duration.zero) {
_controller.forward();
} else {
Future.delayed(verzoegerung, () {
if (mounted) _controller.forward();
});
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _kurve,
builder: (context, child) => Opacity(
opacity: _kurve.value,
child: Transform.translate(
offset: Offset(0, MeloSpace.sm * (1 - _kurve.value)),
child: child,
),
),
child: widget.child,
);
}
}