How to animate the position of the items in a SliverAppBar to move them around the title when closed

1,254

Solution 1

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;
}

Solution 2

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.

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"),
          ),
        ),
      ),
    );
  }
}

Share:
1,254
Mou
Author by

Mou

Updated on December 01, 2022

Comments

  • Mou
    Mou over 1 year

    I have these requirements for an Appbar and I don't find a way to solve them.

    • When stretched, AppBar has to show the two images one above the other and the title has to be hidden.
    • When closed, AppBar has to show the title and two images have to be scaled down when scrolling and moved to both sides of the title. The title becomes visible when scrolling.

    I created a couple of mock-ups to help with the result needed.

    This is the Appbar when stretched:

    enter image description here

    This is the Appbar when closed:

    enter image description here

  • Mou
    Mou about 3 years
    Good one! It's close enough what I want, but I want the boxes, when appbar is stretched, just close to the title, not in the corners. No matter the length the title has.
  • rickimaru
    rickimaru about 3 years
    Sorry, I don't quite understand. "when appbar is stretched" means during when the title is visible? "just close to the title" means shrinkedLeftPos and shrinkedRightPos should be calculated?
  • Mou
    Mou about 3 years
    Looks nice! But the title can have variable length and AnimatedContainer must not be used, as elements have to be interpolated by the scroll itself, not by an "external" animation.
  • Mou
    Mou about 3 years
    Sorry, my fault, I was writing fast in the mobile. I meant "shrinked", not "stretched". What I wanted to say is that the final layout, when AppBar is in minExtent, has to be kind of (pseudocode) Row(align: center, children: [box, little padding, title, little padding, box]). But in your solution, boxes are going to be placed in the corners of the appbar.
  • Simon Sot
    Simon Sot about 3 years
    @Mou but elements are interpolated by the scroll, calculated on the go
  • rickimaru
    rickimaru about 3 years
    OK2. Understood. I'll modify the answer later. (dinner first :D)
  • Mou
    Mou about 3 years
    Yeah, you are right, but we can avoid that AnimatedContainer, as interpolation is already generated by scroll. Also, you still have a fix title length :)
  • rickimaru
    rickimaru about 3 years
    @Mou Answer updated. Please refer to shrinkedHorizontalPos.
  • Simon Sot
    Simon Sot about 3 years
    Yea you were right about containers, fixed it, about title left as is. May be someone will need it like that. That requirement was not in the question.
  • Simon Sot
    Simon Sot about 3 years
    @rickimaru Nice texts size function! You have my vote!
  • rickimaru
    rickimaru about 3 years
    @SimonSot Credits to this guy: stackoverflow.com/a/60065737/9455325
  • Mou
    Mou about 3 years
    Really good one, that behaves as I wanted. Thanks!
  • rickimaru
    rickimaru about 3 years
    @Mou Nice! Good luck!