feat(album): zoom artwork when pulling down the Mornye header

This commit is contained in:
zarzet
2026-09-27 15:44:21 +07:00
parent 3449822212
commit 7d43153b07
2 changed files with 140 additions and 2 deletions
+40 -2
View File
@@ -201,10 +201,10 @@ class AlbumDetailHeader extends StatelessWidget {
alignment: Alignment.topCenter,
child: SizedBox.square(
dimension: artworkSize,
child: artwork,
child: _AlbumStretchArtwork(child: artwork),
),
)
: artwork,
: _AlbumStretchArtwork(child: artwork),
),
Column(
children: [
@@ -458,6 +458,44 @@ class AlbumDetailHeader extends StatelessWidget {
}
}
class _AlbumStretchArtwork extends StatelessWidget {
const _AlbumStretchArtwork({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
if (MediaQuery.disableAnimationsOf(context)) return child;
final position = Scrollable.of(context).position;
return LayoutBuilder(
builder: (context, constraints) => AnimatedBuilder(
animation: position,
child: RepaintBoundary(child: child),
builder: (context, artwork) {
final overscroll = position.hasContentDimensions
? (position.minScrollExtent - position.pixels).clamp(
0.0,
double.infinity,
)
: 0.0;
final height = constraints.maxHeight;
final scale = height > 0 ? 1 + overscroll / height : 1.0;
// The viewport moves the details down during a pull. Keep the image
// at the top and enlarge it without changing the scrollable layout.
return Transform.translate(
offset: Offset(0, -overscroll),
child: Transform.scale(
scale: scale,
alignment: Alignment.topCenter,
child: artwork,
),
);
},
),
);
}
}
/// Keeps the pinned toolbar above the artwork without shifting the artwork or
/// metadata down by the toolbar's safe-area height.
class _AlbumOverlayToolbar extends SingleChildRenderObjectWidget {
+100
View File
@@ -355,6 +355,106 @@ void main() {
expect(tester.takeException(), isNull);
});
for (final squareArtwork in [true, false]) {
for (final reduceMotion in [false, true]) {
testWidgets(
'album pull preserves controls (square: $squareArtwork, reduced motion: $reduceMotion)',
(tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
tester.view.padding = FakeViewPadding(top: 59, bottom: 34);
addTearDown(tester.view.reset);
final controller = ScrollController();
addTearDown(controller.dispose);
const artworkKey = ValueKey('stretch-album-artwork');
var plays = 0;
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
theme: MornyeTheme.build(Brightness.dark),
scrollBehavior: const MaterialScrollBehavior().copyWith(
physics: const BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics(),
),
),
builder: (context, child) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(disableAnimations: reduceMotion),
child: child!,
),
home: CollectionScaffold(
scrollController: controller,
isSelectionMode: false,
onExitSelectionMode: () {},
bottomInset: 0,
appBar: AlbumDetailHeader(
immersive: true,
squareArtwork: squareArtwork,
title: 'Album',
expandedHeight: 400,
showTitleInAppBar: false,
background: const ColoredBox(
key: artworkKey,
color: Colors.orange,
),
actions: AlbumPlayActions(
playLabel: 'Play',
shuffleTooltip: 'Shuffle',
onPlay: () => plays++,
onShuffle: () {},
),
),
slivers: const [
SliverToBoxAdapter(child: Text('Track list')),
SliverToBoxAdapter(child: SizedBox(height: 2000)),
],
),
),
),
);
await tester.pumpAndSettle();
final artwork = find.byKey(artworkKey);
final play = find.text('Play');
final tracks = find.text('Track list');
final originalArt = tester.getRect(artwork);
final originalPlay = tester.getRect(play);
final originalTracks = tester.getRect(tracks);
final originalBack = tester.getRect(find.byTooltip('Back'));
final element = tester.element(artwork);
final gesture = await tester.startGesture(const Offset(195, 250));
await gesture.moveBy(const Offset(0, 150));
await tester.pump();
final stretchedArt = tester.getRect(artwork);
expect(controller.offset, lessThan(0));
if (reduceMotion) {
expect(stretchedArt.size, originalArt.size);
} else {
expect(stretchedArt.width, greaterThan(originalArt.width));
expect(
stretchedArt.width / originalArt.width,
closeTo(stretchedArt.height / originalArt.height, 0.001),
);
expect(stretchedArt.top, closeTo(originalArt.top, 0.01));
}
expect(tester.getSize(play), originalPlay.size);
expect(tester.getRect(play).top, greaterThan(originalPlay.top));
expect(tester.getRect(tracks).top, greaterThan(originalTracks.top));
expect(tester.getRect(find.byTooltip('Back')), originalBack);
expect(play.hitTestable(), findsOneWidget);
expect(tester.element(artwork), same(element));
await gesture.up();
await tester.pumpAndSettle();
expect(tester.getRect(artwork), originalArt);
expect(tester.getRect(play), originalPlay);
await tester.tap(play);
expect(plays, 1);
expect(tester.takeException(), isNull);
},
);
}
}
for (final brightness in Brightness.values) {
for (final scale in [1.0, 2.0]) {
testWidgets(