TL;DR here is the GitHub project with the implementation and a demo.
Why?
Showing information in a list is a very common pattern and often enough the list item will only show the most prominent details to save screen real-estate. Most apps will then show any extra details in a new screen. This decision has mostly been influenced by the difficulty to implement another approach, which is to show extra details still in context with the list. Flutter enables these animations in a way that is far easier to implement.
What?
This is what it will look like
How?
Well first we need to break it all down to the main components. The way I see it we have our ExpandListTile widget which will need to receive the widget the is shown when it's collapsed and the widget that's shown when it's expanded. So we will be switching between those two on a tap on ExpandListTile. Now to add the animation we have the size animation where the container grows from collapsed to expanded and shrinks when reversed. The last animation to take care of would be cross-fading between the collapsed and the expanded widgets.
So to sum it up:
switch between collapsed and expanded on tap
animate the size between collapsed and expanded
animate the cross-fade between collapsed and expanded
While there are different way to implement this effect, I'll demonstrate the simpler way using implicit animations, using AnimatedContainer and AnimatedSwitcher.
switch between collapsed and expanded on tap
Making our ExpandListTile statefull where each tap sets a new state, expanded or collapsed.
animate the size between collapsed and expanded
AnimatedContainer's height parameter which is also tied to our state will trigger the container's animation
animate the cross-fade between collapsed and expanded
AnimatedSwitcher will animate new children
ExpandListTile
import 'package:flutter/material.dart';
class ExpandListTile extends StatefulWidget {
const ExpandListTile(
{Key key,
this.expandedChild,
this.collapsedChild,
this.expandedHeight = 300.0,
this.collapsedHeight = 70.0,
this.duration = const Duration(milliseconds: 200),
this.curve = Curves.ease})
: super(key: key);
final Widget expandedChild;
final Widget collapsedChild;
final double expandedHeight;
final double collapsedHeight;
final Duration duration;
final Curve curve;
@override
_ExpandListTileState createState() => _ExpandListTileState();
}
class _ExpandListTileState extends State<ExpandListTile> {
bool _isExpanded = false;
@override
Widget build(BuildContext context) {
final child = _isExpanded ?
widget.expandedChild :
widget.collapsedChild;
return GestureDetector(
//IMPORTANT: allows "empty" spaces to respond to events
behavior: HitTestBehavior.translucent,
onTap: (() => setState(() => _isExpanded = !_isExpanded)),
child: AnimatedContainer(
curve: widget.curve,
height: _isExpanded ?
widget.expandedHeight :
widget.collapsedHeight,
duration: widget.duration,
child: AnimatedSwitcher(
duration: widget.duration,
child: OverflowBox(
key: ValueKey(_isExpanded),
alignment: Alignment.topLeft,
maxHeight:
_isExpanded ?
widget.expandedHeight :
widget.collapsedHeight,
child: child,
),
),
),
);
}
}
FAQ
Why not just use ExpansionPanelList
While it is definitely one way to go about it, I don't like the fact that the title is always visible and that there is a dropdown icon.
Why GestureDetector has behavior: HitTestBehavior.translucent?
This ensures that when tapping on an empty space will still be intercepted.
Why is there an OverflowBox?
while changing the size of the container we want the height of the expanded and collapsed children to be their final size, to avoid widgets changing layout. I find this have a more intuitive look.
If you have a different way or any thoughts on this implementation, leave me a comment, I'd like to hear what you think.
Comments