How to animate the position of the items in a SliverAppBar to move them around the title when closed
You can create your own SliverAppBar
by extending SliverPersistentHeaderDelegate
.
The translate, scaling, and opacity changes will be done in the build(...)
method because this will be called during extent changes (via scrolling), minExtent <-> maxExtent
.
Here's a sample code.
import 'dart:math';import 'package:flutter/material.dart';void main() { runApp(MyApp());}class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( theme: ThemeData( primaryColor: Colors.blue, ), home: HomePage(), ); }}class HomePage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( body: CustomScrollView( slivers: <Widget>[ SliverPersistentHeader( delegate: MySliverAppBar( title: 'Sample', minWidth: 50, minHeight: 25, leftMaxWidth: 200, leftMaxHeight: 100, rightMaxWidth: 100, rightMaxHeight: 50, shrinkedTopPos: 10, ), pinned: true, ), SliverList( delegate: SliverChildBuilderDelegate( (_, int i) => Container( height: 50, color: Color.fromARGB( 255, Random().nextInt(255), Random().nextInt(255), Random().nextInt(255), ), ), childCount: 50, ), ), ], ), ); }}class MySliverAppBar extends SliverPersistentHeaderDelegate { MySliverAppBar({ required this.title, required this.minWidth, required this.minHeight, required this.leftMaxWidth, required this.leftMaxHeight, required this.rightMaxWidth, required this.rightMaxHeight, this.titleStyle = const TextStyle(fontSize: 26), this.shrinkedTopPos = 0, }); final String title; final TextStyle titleStyle; final double minWidth; final double minHeight; final double leftMaxWidth; final double leftMaxHeight; final double rightMaxWidth; final double rightMaxHeight; final double shrinkedTopPos; final GlobalKey _titleKey = GlobalKey(); double? _topPadding; double? _centerX; Size? _titleSize; double get _shrinkedTopPos => _topPadding! + shrinkedTopPos; @override Widget build( BuildContext context, double shrinkOffset, bool overlapsContent, ) { if (_topPadding == null) { _topPadding = MediaQuery.of(context).padding.top; } if (_centerX == null) { _centerX = MediaQuery.of(context).size.width / 2; } if (_titleSize == null) { _titleSize = _calculateTitleSize(title, titleStyle); } double percent = shrinkOffset / (maxExtent - minExtent); percent = percent > 1 ? 1 : percent; return Container( color: Colors.red, child: Stack( children: <Widget>[ _buildTitle(shrinkOffset), _buildLeftImage(percent), _buildRightImage(percent), ], ), ); } Size _calculateTitleSize(String text, TextStyle style) { final TextPainter textPainter = TextPainter( text: TextSpan(text: text, style: style), maxLines: 1, textDirection: TextDirection.ltr) ..layout(minWidth: 0, maxWidth: double.infinity); return textPainter.size; } Widget _buildTitle(double shrinkOffset) => Align( alignment: Alignment.topCenter, child: Padding( padding: EdgeInsets.only(top: _topPadding!), child: Opacity( opacity: shrinkOffset / maxExtent, child: Text(title, key: _titleKey, style: titleStyle), ), ), ); double getScaledWidth(double width, double percent) => width - ((width - minWidth) * percent); double getScaledHeight(double height, double percent) => height - ((height - minHeight) * percent); /// 20 is the padding between the image and the title double get shrinkedHorizontalPos => (_centerX! - (_titleSize!.width / 2)) - minWidth - 20; Widget _buildLeftImage(double percent) { final double topMargin = minExtent; final double rangeLeft = (_centerX! - (leftMaxWidth / 2)) - shrinkedHorizontalPos; final double rangeTop = topMargin - _shrinkedTopPos; final double top = topMargin - (rangeTop * percent); final double left = (_centerX! - (leftMaxWidth / 2)) - (rangeLeft * percent); return Positioned( left: left, top: top, child: Container( width: getScaledWidth(leftMaxWidth, percent), height: getScaledHeight(leftMaxHeight, percent), color: Colors.black, ), ); } Widget _buildRightImage(double percent) { final double topMargin = minExtent + (rightMaxHeight / 2); final double rangeRight = (_centerX! - (rightMaxWidth / 2)) - shrinkedHorizontalPos; final double rangeTop = topMargin - _shrinkedTopPos; final double top = topMargin - (rangeTop * percent); final double right = (_centerX! - (rightMaxWidth / 2)) - (rangeRight * percent); return Positioned( right: right, top: top, child: Container( width: getScaledWidth(rightMaxWidth, percent), height: getScaledHeight(rightMaxHeight, percent), color: Colors.white, ), ); } @override double get maxExtent => 300; @override double get minExtent => _topPadding! + 50; @override bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) => false;}
Its a bit messy in formulas, but here is how you can do all the calculations about animation:
UPD: added to code variable to make Y axis offset for images when extended.
![](https://i.stack.imgur.com/KyLqc.gif)
Full code to reproduce:
import 'package:flutter/material.dart';void main() => runApp(MyApp());class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Material App', home: Body(), ); }}class Body extends StatefulWidget { const Body({ Key key, }) : super(key: key); @override _BodyState createState() => _BodyState();}class _BodyState extends State<Body> { double _collapsedHeight = 60; double _expandedHeight = 200; double extentRatio; // Value to control SliverAppBar widget sizes, based on BoxConstraints and double minH1 = 40; // Minimum height of the first image. double minW1 = 30; // Minimum width of the first image. double minH2 = 20; // Minimum height of second image. double minW2 = 25; // Minimum width of second image. double maxH1 = 60; // Maximum height of the first image. double maxW1 = 60; // Maximum width of the first image. double maxH2 = 40; // Maximum height of second image. double maxW2 = 50; // Maximum width of second image. double textWidth = 70; // Width of a given title text. double extYAxisOff = 10.0; // Offset on Y axis for both images when sliver is extended. @override Widget build(BuildContext context) { return Scaffold( body: SafeArea( child: NestedScrollView( headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { return <Widget>[ SliverAppBar( collapsedHeight: _collapsedHeight, expandedHeight: _expandedHeight, floating: true, pinned: true, flexibleSpace: LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { extentRatio = (constraints.biggest.height - _collapsedHeight) / (_expandedHeight - _collapsedHeight); double xAxisOffset1 = (-(minW1 - minW2) - textWidth + (textWidth + maxW1) * extentRatio) / 2; double xAxisOffset2 = (-(minW1 - minW2) + textWidth + (-textWidth - maxW2) * extentRatio) / 2; double yAxisOffset2 = (-(minH1 - minH2) - (maxH1 - maxH2 - (minH1 - minH2)) * extentRatio) / 2 - extYAxisOff * extentRatio; double yAxisOffset1 = -extYAxisOff * extentRatio; print(extYAxisOff); // debugPrint('constraints=' + constraints.toString()); // debugPrint('Scale ratio is $extentRatio'); return FlexibleSpaceBar( titlePadding: EdgeInsets.all(0), // centerTitle: true, title: Stack( children: [ Align( alignment: Alignment.topCenter, child: AnimatedOpacity( duration: Duration(milliseconds: 300), opacity: extentRatio < 1 ? 1 : 0, child: Padding( padding: const EdgeInsets.only(top: 30.0), child: Container( color: Colors.indigo, width: textWidth, alignment: Alignment.center, height: 20, child: Text( "TITLE TEXT", style: TextStyle( color: Colors.white, fontSize: 12.0, ), ), ), ), ), ), Align( alignment: Alignment.bottomCenter, child: Row( crossAxisAlignment: CrossAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.center, children: [ Container( transform: Matrix4( 1,0,0,0, 0,1,0,0, 0,0,1,0, xAxisOffset1,yAxisOffset1,0,1), width: minW1 + (maxW1 - minW1) * extentRatio, height: minH1 + (maxH1 - minH1) * extentRatio, color: Colors.red, ), Container( transform: Matrix4( 1,0,0,0, 0,1,0,0, 0,0,1,0, xAxisOffset2,yAxisOffset2,0,1), width: minW2 + (maxW2 - minW2) * extentRatio, height: minH2 + (maxH2 - minH2) * extentRatio, color: Colors.purple, ), ], ), ), ], ), ); }, )), ]; }, body: Center( child: Text("Sample Text"), ), ), ), ); }}