Mou
Mou

Reputation: 2137

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

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

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

Upvotes: 8

Views: 3952

Answers (2)

Simon Sot
Simon Sot

Reputation: 3136

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

Upvotes: 2

rickimaru
rickimaru

Reputation: 2490

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

Upvotes: 5

Related Questions