Animate listitem switch from one list to another and inside the list itself

Viewed 130

I'm trying to implement animated listitem switching between listview (scrollable) and another never scrollable listview/row. I've tried using local hero and flutter sidekick but with no success. I need the initial list widget to be scrollable, while local hero does not have support for it. Flutter Sidekick was great nevertheless it is used with gridview, which is impossible to customize the UI.

Heres the UI of which I'm trying to achieve: ui

2 Answers

Just to give you a start, I have coded a demo. I have used a Stack widget with positioned to animate a widget to its place. used Global Keys to find the Position of widgets on click and the final position.

import 'package:flutter/material.dart';

void main() async {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(home: Center(child: DemoClass()));
  }
}

class DemoClass extends StatefulWidget {
  DemoClass({Key? key}) : super(key: key);

  @override
  State<DemoClass> createState() => _DemoClassState();
}

class _DemoClassState extends State<DemoClass> {
  List<Widget> profiles = [];
  int selectedIndex = -1;
  double firstItemLeftPos = -200;
  double firstItemTopPos = 0;
  GlobalKey finalWidgetKey = GlobalKey();
  List<GlobalKey> profileKey = [];
  int animDuration = 0;
  @override
  void initState() {
    // TODO: implement initState
    super.initState();
    profiles = List.generate(20, (index) {
      GlobalKey itemKey = GlobalKey();

      profileKey.add(itemKey);
      return InkWell(
        key: profileKey[index],
        onTap: () {
          RenderBox box =
              profileKey[index].currentContext?.findRenderObject() as RenderBox;
          Offset position = box.localToGlobal(Offset.zero);
          selectedIndex = index;
          firstItemTopPos = position.dy;
          firstItemLeftPos = position.dx;
          animDuration = 0;
          setState(() {});
          Future.delayed(Duration(milliseconds: 10), () {
            RenderBox FinalRenderbox =
                finalWidgetKey.currentContext?.findRenderObject() as RenderBox;
            Offset finalPos = FinalRenderbox.localToGlobal(Offset.zero);
            firstItemLeftPos = finalPos.dx;
            firstItemTopPos = finalPos.dy;
            animDuration = 500;
            setState(() {});
          });
        },
        child: CircleAvatar(
          child: Text("$index"),
        ),
      );
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(
        children: [
          Expanded(
            child: Stack(
              children: [
                Positioned(
                  left: 20,
                  top: 30,
                  child: Container(
                    key: finalWidgetKey,
                    height: 40,
                    width: 40,
                    decoration: BoxDecoration(
                        shape: BoxShape.circle, color: Colors.red),
                  ),
                ),
                AnimatedPositioned(
                    left: firstItemLeftPos,
                    top: firstItemTopPos,
                    child: CircleAvatar(
                      child: Text("$selectedIndex"),
                    ),
                    duration: Duration(milliseconds: animDuration))
              ],
            ),
          ),
          SizedBox(
            height: 100,
            width: MediaQuery.of(context).size.width,
            child: ListView(
              scrollDirection: Axis.horizontal,
              children: profiles,
            ),
          )
        ],
      ),
    );
  }
}

preview

My concept about is this UI is everything can be placed on Stack. Every circle contains userData with its positional argument. While we need to place on two different point, we can use two bool check whether the place is empty or not. There will be two animation, AnimatedPositioned and AnimatedContainer(for sizing), you can choose different animation. And for the scroll Event, we can use Gesturedetector delta. Here you need to control the list arrangement on selection and scroll.

//https://gist.github.com/yeasin50/4e0bad0a1fd66dd2b1e5fd1b37d1fdc4
class AnimatedUI extends StatefulWidget {
  const AnimatedUI({Key? key}) : super(key: key);

  @override
  State<AnimatedUI> createState() => _AnimatedUIState();
}

enum PosOn { a, b, bottom }

class UserX {
  final Color color;
  final String name;
  PosOn posOn;
  UserX({
    required this.color,
    required this.name,
    this.posOn = PosOn.bottom,
  });

  UserX copyWith({
    Color? color,
    String? name,
    PosOn? posOn,
  }) {
    return UserX(
      posOn: posOn ?? this.posOn,
      color: color ?? this.color,
      name: name ?? this.name,
    );
  }
}

class _AnimatedUIState extends State<AnimatedUI> {
  final smallSize = 64.0;
  late final usersList = List.generate(
      24,
      (index) => UserX(
          color: index.isEven ? Colors.red : Colors.orange,
          name: "name $index"));

  bool aIsEmpty = true;
  bool bIsEmpty = true;

  double deltaX = 0.0;
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.deepPurple,
      body: LayoutBuilder(
        builder: (context, constraints) {
          final width = constraints.maxWidth;
          final height = constraints.maxHeight;

          final maxAvatarSize = constraints.maxWidth * .25;

          final destinationA = Offset(width * .3, height * .2);
          final destinationB =
              Offset(destinationA.dx + maxAvatarSize, height * .3);

          const Duration duration = Duration(milliseconds: 200);

          return Stack(
            children: [
              Positioned(
                left: destinationA.dx,
                top: destinationA.dy,
                child: borderRight(maxAvatarSize),
              ),
              Positioned(
                left: destinationB.dx,
                top: destinationB.dy,
                child: borderRight(maxAvatarSize),
              ),
              for (int i = 0; i < usersList.length; i++)
                AnimatedPositioned(
                  duration: duration,
                  top: () {
                    if (usersList[i].posOn == PosOn.a) {
                      return destinationA.dy;
                    } else if (usersList[i].posOn == PosOn.b) {
                      return destinationB.dy;
                    }

                    return height - smallSize - 16;
                  }(),
                  left: () {
                    if (usersList[i].posOn == PosOn.a) {
                      return destinationA.dx;
                    } else if (usersList[i].posOn == PosOn.b) {
                      return destinationB.dx;
                    }
                    return deltaX + (smallSize + 8) * i;
                  }(),
                  child: AnimatedContainer(
                    duration: duration,
                    width: usersList[i].posOn == PosOn.bottom
                        ? smallSize
                        : maxAvatarSize,
                    height: usersList[i].posOn == PosOn.bottom
                        ? smallSize
                        : maxAvatarSize,
                    child: Padding(
                      padding: const EdgeInsets.all(8.0),
                      child: Material(
                        color: usersList[i].color,
                        shape: const CircleBorder(),
                        child: InkWell(
                          customBorder: const CircleBorder(),
                          onTap: () {
                            final currentPos = usersList[i].posOn;
                            if (currentPos == PosOn.a) {
                              usersList[i] =
                                  usersList[i].copyWith(posOn: PosOn.bottom);
                              aIsEmpty = true;
                            } else if (currentPos == PosOn.b) {
                              usersList[i] =
                                  usersList[i].copyWith(posOn: PosOn.bottom);
                              bIsEmpty = true;
                            } else {
                              if (aIsEmpty) {
                                usersList[i] =
                                    usersList[i].copyWith(posOn: PosOn.a);
                                aIsEmpty = false;
                              } else if (bIsEmpty) {
                                usersList[i] =
                                    usersList[i].copyWith(posOn: PosOn.b);
                                bIsEmpty = false;
                              }
                            }
                            setState(() {});
                          },
                          child: SizedBox(
                            width: smallSize,
                            height: smallSize,
                          ),
                        ),
                      ),
                    ),
                  ),
                ),
              Align(
                alignment: Alignment.bottomCenter,
                child: SizedBox(
                  height: smallSize,
                  child: GestureDetector(
                    onHorizontalDragUpdate: (details) {
                      deltaX += details.delta.dx;

                      setState(() {});
                    },
                  ),
                ),
              )
            ],
          );
        },
      ),
    );
  }

  Container borderRight(double maxAvatarSize) {
    return Container(
      width: maxAvatarSize,
      height: maxAvatarSize,
      decoration: BoxDecoration(
        shape: BoxShape.circle,
        border: Border.all(
          color: Colors.cyanAccent,
          width: 4,
        ),
      ),
      alignment: Alignment.center,
      child: Text(
        "Add",
        style: TextStyle(color: Colors.white),
      ),
    );
  }
}

enter image description here

Related