To avoid unneeded repetition you can move the tween logic to a custom widget.
You can also fuse List<Widget> children with List<double> gaps with a custom Gap widget.
Ultimately you can keep using ListView via separated constructor and use our custom Gap as separators.
Taking all of this into consideration, in the end your Gap widget is simply an AnimatedContainer with a custom height:
class Gap extends StatelessWidget {
final double gap;
const Gap(this.gap, {Key key})
: assert(gap >= .0),
super(key: key);
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
height: gap,
);
}
}
And you can then use it using the following:
ListView.separated(
itemCount: 42,
addAutomaticKeepAlives: true,
itemBuilder: (context, index) {
return RaisedButton(onPressed: null, child: Text("Foo $index"));
},
separatorBuilder: (context, index) {
return Gap(10.0);
},
),
The addAutomaticKeppAlives: true here is used to ensure that items leaving then reappearing don't have their animation reset. But it is not a necessity.
Here's a full example with dynamically changing gap size:

class Home extends StatefulWidget {
@override
HomeState createState() {
return new HomeState();
}
}
class HomeState extends State<Home> {
final rand = Random.secure();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Column(
children: <Widget>[
RaisedButton(
onPressed: () {
setState(() {});
},
child: Text("Reroll random gaps"),
),
Expanded(
child: ListView.separated(
addAutomaticKeepAlives: true,
itemCount: 42,
itemBuilder: (context, index) {
print("Bar");
return RaisedButton(onPressed: () {}, child: Text("Foo $index"));
},
separatorBuilder: (context, index) {
print("Foo $index");
return Gap(rand.nextDouble() * 10.0);
},
),
),
],
),
);
}
}