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