Animating widget positions as the screen scrolls in flutter (GIF included)

7,760

You could use a Stack together with Positioned widgets to position the ShrinkableBoxes as you need. Since what controls the animation is the scroll offset, you don't need to use animated widgets or an animation controller or something like it. Here's a working example which calculates the positions by linearly interpolating the initial and final position of the boxes (you can get different animation paths by changing the Curves.linear to other curves):

import 'dart:math' as math;
import 'dart:ui';

import 'package:flutter/material.dart';

void main() {
  runApp(MaterialApp(home: Home()));
}

class Home extends StatefulWidget {
  @override
  State createState() => HomeState();
}

class HomeState extends State<Home> {
  static const double kExpandedHeight = 300.0;

  static const double kInitialSize = 75.0;

  static const double kFinalSize = 30.0;

  static const List<Color> kBoxColors = [
    Colors.red,
    Colors.green,
    Colors.yellow,
    Colors.purple,
    Colors.orange,
    Colors.grey,
  ];

  ScrollController _scrollController = new ScrollController();

  @override
  void initState() {
    _scrollController.addListener(() {
      setState(() { /* State being set is the Scroll Controller's offset */ });
    });
  }

  @override
  void dispose() {
    _scrollController.dispose();
  }

  Widget build(BuildContext context) {
    double size = !_scrollController.hasClients || _scrollController.offset == 0
        ? 75.0
        : 75 -
            math.min(45.0,
                (45 / kExpandedHeight * math.min(_scrollController.offset, kExpandedHeight) * 1.5));

    return Scaffold(
      body: CustomScrollView(
        controller: _scrollController,
        slivers: <Widget>[
          SliverAppBar(
            pinned: true,
            expandedHeight: kExpandedHeight,
            title: Text("Title!"),
            bottom: PreferredSize(
              preferredSize: Size.fromHeight(55),
              child: buildAppBarBottom(size),
            ),
          ),
          SliverFixedExtentList(
            itemExtent: 50.0,
            delegate: SliverChildBuilderDelegate(
              (BuildContext context, int index) {
                return ListTile(title: Text('Item $index'));
              },
            ),
          ),
        ],
      ),
    );
  }

  Widget buildAppBarBottom(double size) {
    double t = (size - kInitialSize) / (kFinalSize - kInitialSize);

    const double initialContainerHeight = 2 * kInitialSize;
    const double finalContainerHeight = kFinalSize;

    return Container(
      height: lerpDouble(initialContainerHeight, finalContainerHeight, t),
      child: LayoutBuilder(
        builder: (context, constraints) {
          List<Widget> stackChildren = [];
          for (int i = 0; i < 6; i++) {
            Offset offset = getInterpolatedOffset(i, constraints, t);
            stackChildren.add(Positioned(
              left: offset.dx,
              top: offset.dy,
              child: buildSizedBox(size, kBoxColors[i]),
            ));
          }

          return Stack(children: stackChildren);
        },
      ),
    );
  }

  Offset getInterpolatedOffset(int index, BoxConstraints constraints, double t) {
    Curve curve = Curves.linear;
    double curveT = curve.transform(t);

    Offset a = getOffset(index, constraints, kInitialSize, 3);
    Offset b = getOffset(index, constraints, kFinalSize, 6);

    return Offset(
      lerpDouble(a.dx, b.dx, curveT),
      lerpDouble(a.dy, b.dy, curveT),
    );
  }

  Offset getOffset(int index, BoxConstraints constraints, double size, int columns) {
    int x = index % columns;
    int y = index ~/ columns;
    double horizontalMargin = (constraints.maxWidth - size * columns) / 2;

    return Offset(horizontalMargin + x * size, y * size);
  }

  Widget buildSizedBox(double size, Color color) {
    return Container(
      height: size,
      width: size,
      color: color,
    );
  }
}
Share:
7,760
smbl
Author by

smbl

Updated on December 09, 2022

Comments

  • smbl
    smbl over 1 year

    I am trying to animate two Rows of widgets to collapse into 1 Row of these widgets as one scroll. I am trying to achieve this behavior inside a SliverAppBar.

    For clarification, I have included a GIF here for reference. I would like the behavior you see in the app bar, but instead of 1 row to 2, I would like 2 rows becoming 1.

    gif

    Here is a quick snippet of what I have so far. I wrapped 2 Row widgets that contain 3 shrinkableBox widgets each into a Wrap widget. I dynamically adjust the size of these boxes by hooking into _scrollController.offset and doing some calculations. The rows do move around dynamically but they don't animate and move abruptly instead.

      double kExpandedHeight = 300.0;
    
      Widget build(BuildContext context) {
        double size = !_scrollController.hasClients || _scrollController.offset == 0 ? 75.0 : 75 - math.min(45.0, (45 / kExpandedHeight * math.min(_scrollController.offset, kExpandedHeight) * 1.5));
        return Scaffold(
          body: CustomScrollView(
              controller: _scrollController,
              slivers: <Widget>[           
                SliverAppBar(
                  pinned: true,
                  expandedHeight: kExpandedHeight,
    
              title: new Text(
                "Title!",
              ),
              bottom: PreferredSize(child: Wrap(
                children: <Widget>[
                  Row(
                    mainAxisAlignment: MainAxisAlignment.center,
                    mainAxisSize: MainAxisSize.min,
                    children: <Widget>[
                      ShrinkableBox(
                        onClick: () {
                          print("tapped");
                        },
                        size: size,
                      ),
                      ShrinkableBox(
                        onClick: () {
                          print("tapped");
                        },
                        size: size,
                      ),                      
                      ShrinkableBox(
                        onClick: () {
                          print("tapped");
                        },
                        size: size,
                      ),
                    Row(
                    mainAxisAlignment: MainAxisAlignment.center,
                    mainAxisSize: MainAxisSize.min,
    
                    children: <Widget>[
                      ShrinkableBox(
                        onClick: () {
                          print("tapped");
                        },
                        size: size,
                      ),
                      ShrinkableBox(
                        onClick: () {
                          print("tapped");
                        },
                        size: size,
                      ),
                      ShrinkableBox(
                        onClick: () {
                          print("tapped");
                        },
                        size: size,
                      ),
                    ],
                  ),
                ],
              ), preferredSize: new Size.fromHeight(55),),
            )
       // ...
       // ...Other sliver list content here...
       // ...
    
  • smbl
    smbl over 5 years
    This is exactly what i wanted! How were you able to figure this out? This is a pretty complex piece of code. I would like to learn animations like this for myself as well.
  • Luis Fernando Trivelatto
    Luis Fernando Trivelatto over 5 years
    @smbl Thanks! I guess I've had a fair share of struggles with animations in Flutter hehe. Have you checked out the Animations section of the Flutter website? While it explains how to do more complex animations, you might also want to check out the implicitly animated widgets, which are still powerful and simpler to use, such as AnimatedContainer: flutter.dev/docs/development/ui/widgets/animation
  • devDeejay
    devDeejay about 4 years
    For ios with the bouncing scrolling effect, this will throw an exception.
  • Dennis Barzanoff
    Dennis Barzanoff over 3 years
    You can now use the new for syntax for the stack children