diff --git a/lib/widgets/mornye_artist_header.dart b/lib/widgets/mornye_artist_header.dart index 489a24cd..754f627a 100644 --- a/lib/widgets/mornye_artist_header.dart +++ b/lib/widgets/mornye_artist_header.dart @@ -157,9 +157,10 @@ class MornyeArtistHeader extends StatelessWidget { final String? logoUrl; final Widget? badge; + // A SliverMainAxisGroup clamps negative overlap, preventing this header from + // receiving the overscroll used for its stretch animation. @override - Widget build(BuildContext context) => - SliverMainAxisGroup(slivers: buildSlivers(context)); + Widget build(BuildContext context) => buildSlivers(context).single; List buildSlivers(BuildContext context) { final surface = Theme.of(context).colorScheme.surface; @@ -206,6 +207,7 @@ class MornyeArtistHeader extends StatelessWidget { 32; return SliverAppBar( pinned: true, + stretch: !MediaQuery.disableAnimationsOf(context), expandedHeight: expandedHeight - MediaQuery.paddingOf(context).top, backgroundColor: surface, surfaceTintColor: Colors.transparent, @@ -233,6 +235,7 @@ class MornyeArtistHeader extends StatelessWidget { ), flexibleSpace: FlexibleSpaceBar( collapseMode: CollapseMode.pin, + stretchModes: const [StretchMode.zoomBackground], background: Stack( fit: StackFit.expand, children: [ @@ -366,7 +369,30 @@ class _ArtistCollapsingArtwork extends ConsumerWidget { sigmaY: blur, tileMode: TileMode.clamp, ), - child: RepaintBoundary(child: child), + child: LayoutBuilder( + builder: (context, constraints) { + final height = settings?.maxExtent ?? constraints.maxHeight; + final zoom = height > 0 + ? (constraints.maxHeight / height).clamp( + 1.0, + double.infinity, + ) + : 1.0; + // Stretch the photograph uniformly around its top edge. + // Identity and actions remain outside this transform, moving + // down with the sliver without growing along with the image. + return OverflowBox( + alignment: Alignment.topCenter, + minHeight: height, + maxHeight: height, + child: Transform.scale( + scale: zoom, + alignment: Alignment.topCenter, + child: RepaintBoundary(child: child), + ), + ); + }, + ), ), ), if (blurEnabled && fade < 1) diff --git a/test/mornye_detail_headers_test.dart b/test/mornye_detail_headers_test.dart index 119243a5..e22c6689 100644 --- a/test/mornye_detail_headers_test.dart +++ b/test/mornye_detail_headers_test.dart @@ -222,6 +222,67 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('pulling the artist header zooms only the artwork', ( + 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); + const artworkKey = ValueKey('stretch-artwork'); + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: MornyeTheme.build(Brightness.dark), + home: Scaffold( + body: CustomScrollView( + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + slivers: const [ + MornyeArtistHeader( + name: 'Artist', + artwork: ColoredBox(key: artworkKey, color: Colors.orange), + actions: [Text('Artist action')], + showTitle: false, + ), + SliverToBoxAdapter(child: Text('Album list')), + SliverToBoxAdapter(child: SizedBox(height: 2000)), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final artwork = find.byKey(artworkKey); + final action = find.text('Artist action'); + final albums = find.text('Album list'); + final originalArt = tester.getRect(artwork); + final originalAction = tester.getRect(action); + final originalAlbums = tester.getRect(albums); + final element = tester.element(artwork); + final gesture = await tester.startGesture(const Offset(195, 350)); + await gesture.moveBy(const Offset(0, 150)); + await tester.pump(); + final stretchedArt = tester.getRect(artwork); + 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(action), originalAction.size); + expect(tester.getRect(action).top, greaterThan(originalAction.top)); + expect(tester.getRect(albums).top, greaterThan(originalAlbums.top)); + expect(tester.element(artwork), same(element)); + await gesture.up(); + await tester.pumpAndSettle(); + expect(tester.getRect(artwork), originalArt); + expect(tester.getRect(action), originalAction); + expect(tester.takeException(), isNull); + }); + for (final brightness in Brightness.values) { for (final scale in [1.0, 2.0]) { testWidgets(