Unverified Commit 28dc0074 authored by jslavitz's avatar jslavitz Committed by GitHub

Added single open panel functionality (#19624)

* Just commiting two files now

* Fixed one tab

* Fixed latest changes

* Changed the data structure from a map to a single radio panel object

* A few more changes

* Fixed change from expansion radio to regular list

* Fixed change from expansion radio to regular list2

* Changed the radio constructor

* Last fixes

* Final commit

* Actual final commit

* Last change
parent d098dc34
......@@ -84,6 +84,28 @@ class ExpansionPanel {
///
/// Defaults to false.
final bool isExpanded;
}
/// An expansion panel that allows for radio-like functionality.
///
/// A unique identifier [value] must be assigned to each panel.
class ExpansionPanelRadio extends ExpansionPanel {
/// An expansion panel that allows for radio functionality.
///
/// A unique [value] must be passed into the constructor. The
/// [headerBuilder], [body], [value] must not be null.
ExpansionPanelRadio({
@required this.value,
@required ExpansionPanelHeaderBuilder headerBuilder,
@required Widget body,
}) : assert(value != null),
super(body: body, headerBuilder: headerBuilder);
/// The value that uniquely identifies a radio panel so that the currently
/// selected radio panel can be identified.
final Object value;
}
/// A material expansion panel list that lays out its children and animates
......@@ -93,16 +115,39 @@ class ExpansionPanel {
///
/// * [ExpansionPanel]
/// * <https://material.google.com/components/expansion-panels.html>
class ExpansionPanelList extends StatelessWidget {
class ExpansionPanelList extends StatefulWidget {
/// Creates an expansion panel list widget. The [expansionCallback] is
/// triggered when an expansion panel expand/collapse button is pushed.
///
/// The [children] and [animationDuration] arguments must not be null.
const ExpansionPanelList({
Key key,
this.children = const <ExpansionPanel>[],
this.expansionCallback,
this.animationDuration = kThemeAnimationDuration
this.animationDuration = kThemeAnimationDuration,
}) : assert(children != null),
assert(animationDuration != null),
_allowOnlyOnePanelOpen = false,
this.initialOpenPanelValue = null,
super(key: key);
/// Creates a radio expansion panel list widget.
///
/// This widget allows for at most one panel in the list to be open.
/// The expansion panel callback is triggered when an expansion panel
/// expand/collapse button is pushed. The [children] and [animationDuration]
/// arguments must not be null. The [children] objects must be instances
/// of [ExpansionPanelRadio].
const ExpansionPanelList.radio({
Key key,
List<ExpansionPanelRadio> children = const <ExpansionPanelRadio>[],
this.expansionCallback,
this.animationDuration = kThemeAnimationDuration,
this.initialOpenPanelValue,
}) : children = children, //ignore:prefer_initializing_formals
assert(children != null),
assert(animationDuration != null),
_allowOnlyOnePanelOpen = true,
super(key: key);
/// The children of the expansion panel list. They are laid out in a similar
......@@ -121,8 +166,82 @@ class ExpansionPanelList extends StatelessWidget {
/// The duration of the expansion animation.
final Duration animationDuration;
// Whether multiple panels can be open simultaneously
final bool _allowOnlyOnePanelOpen;
/// The value of the panel that initially begins open. (This value is
/// only used when initializing with the [ExpansionPanelList.radio]
/// constructor.)
final Object initialOpenPanelValue;
@override
State<StatefulWidget> createState() => new _ExpansionPanelListState();
}
class _ExpansionPanelListState extends State<ExpansionPanelList> {
ExpansionPanelRadio _currentOpenPanel;
@override
void initState() {
super.initState();
if (widget._allowOnlyOnePanelOpen) {
assert(_allIdentifiersUnique(), 'All object identifiers are not unique!');
for (ExpansionPanelRadio child in widget.children) {
if (widget.initialOpenPanelValue != null &&
child.value == widget.initialOpenPanelValue)
_currentOpenPanel = child;
}
}
}
@override
void didUpdateWidget(ExpansionPanelList oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget._allowOnlyOnePanelOpen) {
assert(_allIdentifiersUnique(), 'All object identifiers are not unique!');
for (ExpansionPanelRadio newChild in widget.children) {
if (widget.initialOpenPanelValue != null &&
newChild.value == widget.initialOpenPanelValue)
_currentOpenPanel = newChild;
}
} else if(oldWidget._allowOnlyOnePanelOpen) {
_currentOpenPanel = null;
}
}
bool _allIdentifiersUnique() {
final Map<Object, bool> identifierMap = <Object, bool>{};
for (ExpansionPanelRadio child in widget.children) {
identifierMap[child.value] = true;
}
return identifierMap.length == widget.children.length;
}
bool _isChildExpanded(int index) {
return children[index].isExpanded;
if (widget._allowOnlyOnePanelOpen) {
final ExpansionPanelRadio radioWidget = widget.children[index];
return _currentOpenPanel?.value == radioWidget.value;
}
return widget.children[index].isExpanded;
}
void _handlePressed(bool isExpanded, int index) {
if (widget.expansionCallback != null)
widget.expansionCallback(index, isExpanded);
if (widget._allowOnlyOnePanelOpen) {
final ExpansionPanelRadio pressedChild = widget.children[index];
for (int childIndex = 0; childIndex < widget.children.length; childIndex += 1) {
final ExpansionPanelRadio child = widget.children[childIndex];
if (widget.expansionCallback != null &&
childIndex != index &&
child.value == _currentOpenPanel?.value)
widget.expansionCallback(childIndex, false);
}
_currentOpenPanel = isExpanded ? null : pressedChild;
}
setState((){});
}
@override
......@@ -132,22 +251,23 @@ class ExpansionPanelList extends StatelessWidget {
vertical: _kPanelHeaderExpandedHeight - _kPanelHeaderCollapsedHeight
);
for (int index = 0; index < children.length; index += 1) {
for (int index = 0; index < widget.children.length; index += 1) {
if (_isChildExpanded(index) && index != 0 && !_isChildExpanded(index - 1))
items.add(new MaterialGap(key: new _SaltedKey<BuildContext, int>(context, index * 2 - 1)));
final ExpansionPanel child = widget.children[index];
final Row header = new Row(
children: <Widget>[
new Expanded(
child: new AnimatedContainer(
duration: animationDuration,
duration: widget.animationDuration,
curve: Curves.fastOutSlowIn,
margin: _isChildExpanded(index) ? kExpandedEdgeInsets : EdgeInsets.zero,
child: new ConstrainedBox(
constraints: const BoxConstraints(minHeight: _kPanelHeaderCollapsedHeight),
child: children[index].headerBuilder(
child: child.headerBuilder(
context,
children[index].isExpanded,
_isChildExpanded(index),
),
),
),
......@@ -157,10 +277,7 @@ class ExpansionPanelList extends StatelessWidget {
child: new ExpandIcon(
isExpanded: _isChildExpanded(index),
padding: const EdgeInsets.all(16.0),
onPressed: (bool isExpanded) {
if (expansionCallback != null)
expansionCallback(index, isExpanded);
},
onPressed: (bool isExpanded) => _handlePressed(isExpanded, index),
),
),
],
......@@ -174,19 +291,19 @@ class ExpansionPanelList extends StatelessWidget {
header,
new AnimatedCrossFade(
firstChild: new Container(height: 0.0),
secondChild: children[index].body,
secondChild: child.body,
firstCurve: const Interval(0.0, 0.6, curve: Curves.fastOutSlowIn),
secondCurve: const Interval(0.4, 1.0, curve: Curves.fastOutSlowIn),
sizeCurve: Curves.fastOutSlowIn,
crossFadeState: _isChildExpanded(index) ? CrossFadeState.showSecond : CrossFadeState.showFirst,
duration: animationDuration,
duration: widget.animationDuration,
),
],
),
),
);
if (_isChildExpanded(index) && index != children.length - 1)
if (_isChildExpanded(index) && index != widget.children.length - 1)
items.add(new MaterialGap(key: new _SaltedKey<BuildContext, int>(context, index * 2 + 1)));
}
......
......@@ -204,4 +204,147 @@ void main() {
expect(tester.getRect(find.byType(AnimatedSize).at(1)), new Rect.fromLTWH(0.0, 56.0 + 1.0 + 56.0, 800.0, 0.0));
expect(tester.getRect(find.byType(AnimatedSize).at(2)), new Rect.fromLTWH(0.0, 56.0 + 1.0 + 56.0 + 16.0 + 16.0 + 48.0 + 16.0, 800.0, 100.0));
});
testWidgets('Single Panel Open Test', (WidgetTester tester) async {
final List<ExpansionPanel> _demoItemsRadio = <ExpansionPanelRadio>[
new ExpansionPanelRadio(
headerBuilder: (BuildContext context, bool isExpanded) {
return new Text(isExpanded ? 'B' : 'A');
},
body: const SizedBox(height: 100.0),
value: 0,
),
new ExpansionPanelRadio(
headerBuilder: (BuildContext context, bool isExpanded) {
return new Text(isExpanded ? 'D' : 'C');
},
body: const SizedBox(height: 100.0),
value: 1,
),
new ExpansionPanelRadio(
headerBuilder: (BuildContext context, bool isExpanded) {
return new Text(isExpanded ? 'F' : 'E');
},
body: const SizedBox(height: 100.0),
value: 2,
),
];
final ExpansionPanelList _expansionListRadio = ExpansionPanelList.radio(
children: _demoItemsRadio,
);
await tester.pumpWidget(
new MaterialApp(
home: new SingleChildScrollView(
child: _expansionListRadio,
),
),
);
// Initializes with all panels closed
expect(find.text('A'), findsOneWidget);
expect(find.text('B'), findsNothing);
expect(find.text('C'), findsOneWidget);
expect(find.text('D'), findsNothing);
expect(find.text('E'), findsOneWidget);
expect(find.text('F'), findsNothing);
RenderBox box = tester.renderObject(find.byType(ExpansionPanelList));
double oldHeight = box.size.height;
expect(find.byType(ExpandIcon), findsNWidgets(3));
await tester.tap(find.byType(ExpandIcon).at(0));
box = tester.renderObject(find.byType(ExpansionPanelList));
expect(box.size.height, equals(oldHeight));
await tester.pump(const Duration(milliseconds: 200));
await tester.pumpAndSettle();
// Now the first panel is open
expect(find.text('A'), findsNothing);
expect(find.text('B'), findsOneWidget);
expect(find.text('C'), findsOneWidget);
expect(find.text('D'), findsNothing);
expect(find.text('E'), findsOneWidget);
expect(find.text('F'), findsNothing);
box = tester.renderObject(find.byType(ExpansionPanelList));
expect(box.size.height - oldHeight, greaterThanOrEqualTo(100.0)); // 100 + some margin
await tester.tap(find.byType(ExpandIcon).at(1));
box = tester.renderObject(find.byType(ExpansionPanelList));
oldHeight = box.size.height;
await tester.pump(const Duration(milliseconds: 200));
// Now the first panel is closed and the second should be opened
expect(find.text('A'), findsOneWidget);
expect(find.text('B'), findsNothing);
expect(find.text('C'), findsNothing);
expect(find.text('D'), findsOneWidget);
expect(find.text('E'), findsOneWidget);
expect(find.text('F'), findsNothing);
expect(box.size.height, greaterThanOrEqualTo(oldHeight));
_demoItemsRadio.removeAt(0);
await tester.pumpAndSettle();
// Now the first panel should be opened
expect(find.text('C'), findsNothing);
expect(find.text('D'), findsOneWidget);
expect(find.text('E'), findsOneWidget);
expect(find.text('F'), findsNothing);
final List<ExpansionPanel> _demoItems = <ExpansionPanel>[
new ExpansionPanel(
headerBuilder: (BuildContext context, bool isExpanded) {
return new Text(isExpanded ? 'B' : 'A');
},
body: const SizedBox(height: 100.0),
isExpanded: false,
),
new ExpansionPanel(
headerBuilder: (BuildContext context, bool isExpanded) {
return new Text(isExpanded ? 'D' : 'C');
},
body: const SizedBox(height: 100.0),
isExpanded: false,
),
new ExpansionPanel(
headerBuilder: (BuildContext context, bool isExpanded) {
return new Text(isExpanded ? 'F' : 'E');
},
body: const SizedBox(height: 100.0),
isExpanded: false,
),
];
final ExpansionPanelList _expansionList = ExpansionPanelList(
children: _demoItems,
);
await tester.pumpWidget(
new MaterialApp(
home: new SingleChildScrollView(
child: _expansionList,
),
),
);
// We've reinitialized with a regular expansion panel so they should all be closed again
expect(find.text('A'), findsOneWidget);
expect(find.text('B'), findsNothing);
expect(find.text('C'), findsOneWidget);
expect(find.text('D'), findsNothing);
expect(find.text('E'), findsOneWidget);
expect(find.text('F'), findsNothing);
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment