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:
@@ -5,6 +5,34 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### ✨ Staggered List-Enter-Animation: Listen faden beim ersten Erscheinen gestaffelt ein (2026-08-29)
|
||||
|
||||
- ✅ **Teil 1 einer Animations-Serie** (inspiriert von Xiaomis HyperOS-
|
||||
Design-Sprache — fließende statt abrupte Bewegung): neues Widget
|
||||
`EinblendItem` (`lib/shared/einblend_item.dart`) blendet ein Listenelement
|
||||
beim ersten Erscheinen mit Opacity 0→1 und leichtem Versatz (`MeloSpace.sm`
|
||||
→ 0) ein, Dauer `MeloMotion.normal` (220ms), Kurve `MeloMotion.curve`.
|
||||
Jedes Element startet um `min(index, 12) × 25ms` versetzt — ab Index 12
|
||||
ohne zusätzliche Verzögerung, damit lange Listen nicht träge wirken. Läuft
|
||||
genau einmal je Element-Lebenszeit (nicht bei jedem Rebuild) und
|
||||
respektiert Reduce-Motion (`MeloMotion.ruhig`): dann erscheint das Element
|
||||
sofort im Endzustand, ohne Verzögerung oder Bewegung.
|
||||
- 🔧 Eingebunden in `SongList` (`lib/library/song_list.dart`) und in beide
|
||||
Listen von `artist_list.dart` (Künstler-Übersicht sowie die Zeilen der
|
||||
Künstler-Detailseite — Alben, Titel und Zwischenüberschriften). Die
|
||||
wiederverwendete `SongZeile` selbst bleibt unverändert, damit Suche und
|
||||
Playlist-Detail (die den Wrapper nicht bekommen sollen) unangetastet
|
||||
bleiben. `my_music_screen.dart` rendert keine eigene Liste (nur
|
||||
eingebettete `SortableSongList`/`ArtistListScreen`/`CategoryListScreen`)
|
||||
— dort war nichts einzubinden.
|
||||
- ✅ **Getestet (RED→GREEN):** neuer Widget-Test
|
||||
`test/shared/einblend_item_test.dart` prüft Start bei Opacity 0, Ziel bei
|
||||
1,0 nach `pumpAndSettle()`, sofortigen Endzustand bei
|
||||
`disableAnimations: true` sowie den Deckel bei Index 12 (Index 50 startet
|
||||
nicht später als Index 12). Bestehende Tests zu `song_list.dart` liefen
|
||||
bereits mit `pumpAndSettle()` und blieben unverändert grün. Volle Suite
|
||||
(604 Tests) grün, `flutter analyze` ohne Befund.
|
||||
|
||||
### 🔑 Manuelle Baka-Anmeldung übernahm das neue Passwort nie dauerhaft; Auto-Login-Fehler wurde verworfen (2026-08-29)
|
||||
|
||||
- 🐛 **Wer sich nach einer fehlgeschlagenen Auto-Anmeldung manuell im
|
||||
|
||||
@@ -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());
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:melo/shared/einblend_item.dart';
|
||||
|
||||
void main() {
|
||||
Widget wrap(Widget child, {bool ruhig = false}) => MaterialApp(
|
||||
home: MediaQuery(
|
||||
data: MediaQueryData(disableAnimations: ruhig),
|
||||
child: Scaffold(body: child),
|
||||
),
|
||||
);
|
||||
|
||||
double opacityVon(WidgetTester tester, Finder einblendItem) => tester
|
||||
.widget<Opacity>(
|
||||
find.descendant(of: einblendItem, matching: find.byType(Opacity)))
|
||||
.opacity;
|
||||
|
||||
double versatzYVon(WidgetTester tester, Finder einblendItem) => tester
|
||||
.widget<Transform>(
|
||||
find.descendant(of: einblendItem, matching: find.byType(Transform)))
|
||||
.transform
|
||||
.getTranslation()
|
||||
.y;
|
||||
|
||||
testWidgets('Opacity startet bei 0 und erreicht 1,0 nach dem Einblenden',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(wrap(
|
||||
const EinblendItem(index: 0, child: Text('Titel')),
|
||||
));
|
||||
final item = find.byType(EinblendItem);
|
||||
|
||||
expect(opacityVon(tester, item), 0);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(opacityVon(tester, item), 1);
|
||||
expect(versatzYVon(tester, item), 0);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'bei abgeschalteten Systemanimationen erscheint das Element sofort',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(wrap(
|
||||
const EinblendItem(index: 3, child: Text('Titel')),
|
||||
ruhig: true,
|
||||
));
|
||||
final item = find.byType(EinblendItem);
|
||||
|
||||
// Bewusst kein weiterer pump() davor: die Reduce-Motion-Einstellung
|
||||
// darf nicht erst nach einer Zeitverzögerung greifen.
|
||||
expect(opacityVon(tester, item), 1);
|
||||
expect(versatzYVon(tester, item), 0);
|
||||
});
|
||||
|
||||
testWidgets('der Deckel bremst die Verzögerung ab Index 12',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(wrap(
|
||||
const Column(
|
||||
children: [
|
||||
EinblendItem(index: 12, child: Text('Zwölf')),
|
||||
EinblendItem(index: 50, child: Text('Fünfzig')),
|
||||
],
|
||||
),
|
||||
));
|
||||
|
||||
// Deckel: min(index, 12) * 25ms → beide starten spätestens nach 300ms.
|
||||
// Ohne Deckel bräuchte Index 50 1250ms Verzögerung und wäre hier noch
|
||||
// bei Opacity 0.
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.pump(const Duration(milliseconds: 220));
|
||||
|
||||
for (final item in find.byType(EinblendItem).evaluate()) {
|
||||
expect(opacityVon(tester, find.byWidget(item.widget)), 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user