1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5/// @docImport 'package:flutter/material.dart';
6library;
7
8import 'basic.dart';
9import 'framework.dart';
10import 'page_storage.dart';
11import 'ticker_provider.dart';
12import 'transitions.dart';
13
14/// The type of the callback that returns the header or body of an [Expansible].
15///
16/// The `animation` property exposes the underlying expanding or collapsing
17/// animation, which has a value of 0 when the [Expansible] is completely
18/// collapsed and 1 when it is completely expanded. This can be used to drive
19/// animations that sync up with the expanding or collapsing animation, such as
20/// rotating an icon.
21///
22/// See also:
23///
24/// * [Expansible.headerBuilder], which is of this type.
25/// * [Expansible.bodyBuilder], which is also of this type.
26typedef ExpansibleComponentBuilder =
27 Widget Function(BuildContext context, Animation<double> animation);
28
29/// The type of the callback that uses the header and body of an [Expansible]
30/// widget to build the widget.
31///
32/// The `header` property is the header returned by [Expansible.headerBuilder].
33/// The `body` property is the body returned by [Expansible.bodyBuilder] wrapped
34/// in an [Offstage] to hide the body when the [Expansible] is collapsed.
35///
36/// The `animation` property exposes the underlying expanding or collapsing
37/// animation, which has a value of 0 when the [Expansible] is completely
38/// collapsed and 1 when it is completely expanded. This can be used to drive
39/// animations that sync up with the expanding or collapsing animation, such as
40/// rotating an icon.
41///
42/// See also:
43///
44/// * [Expansible.expansibleBuilder], which is of this type.
45typedef ExpansibleBuilder =
46 Widget Function(BuildContext context, Widget header, Widget body, Animation<double> animation);
47
48/// A controller for managing the expansion state of an [Expansible].
49///
50/// This class is a [ChangeNotifier] that notifies its listeners if the value of
51/// [isExpanded] changes.
52///
53/// This controller provides methods to programmatically expand or collapse the
54/// widget, and it allows external components to query the current expansion
55/// state.
56///
57/// The controller's [expand] and [collapse] methods cause the
58/// the [Expansible] to rebuild, so they may not be called from
59/// a build method.
60///
61/// Remember to [dispose] of the [ExpansibleController] when it is no longer
62/// needed. This will ensure we discard any resources used by the object.
63class ExpansibleController extends ChangeNotifier {
64 /// Creates a controller to be used with [Expansible.controller].
65 ExpansibleController();
66
67 bool _isExpanded = false;
68
69 void _setExpansionState(bool newValue) {
70 if (newValue != _isExpanded) {
71 _isExpanded = newValue;
72 notifyListeners();
73 }
74 }
75
76 /// Whether the expansible widget built with this controller is in expanded
77 /// state.
78 ///
79 /// This property doesn't take the animation into account. It reports `true`
80 /// even if the expansion animation is not completed.
81 ///
82 /// To be notified when this property changes, add a listener to the
83 /// controller using [ExpansibleController.addListener].
84 ///
85 /// See also:
86 ///
87 /// * [expand], which expands the expansible widget.
88 /// * [collapse], which collapses the expansible widget.
89 bool get isExpanded => _isExpanded;
90
91 /// Expands the [Expansible] that was built with this controller.
92 ///
93 /// If the widget is already in the expanded state (see [isExpanded]), calling
94 /// this method has no effect.
95 ///
96 /// Calling this method may cause the [Expansible] to rebuild, so it may
97 /// not be called from a build method.
98 ///
99 /// Calling this method will notify registered listeners of this controller
100 /// that the expansion state has changed.
101 ///
102 /// See also:
103 ///
104 /// * [collapse], which collapses the expansible widget.
105 /// * [isExpanded] to check whether the expansible widget is expanded.
106 void expand() {
107 _setExpansionState(true);
108 }
109
110 /// Collapses the [Expansible] that was built with this controller.
111 ///
112 /// If the widget is already in the collapsed state (see [isExpanded]),
113 /// calling this method has no effect.
114 ///
115 /// Calling this method may cause the [Expansible] to rebuild, so it may not
116 /// be called from a build method.
117 ///
118 /// Calling this method will notify registered listeners of this controller
119 /// that the expansion state has changed.
120 ///
121 /// See also:
122 ///
123 /// * [expand], which expands the [Expansible].
124 /// * [isExpanded] to check whether the [Expansible] is expanded.
125 void collapse() {
126 _setExpansionState(false);
127 }
128
129 /// Finds the [ExpansibleController] for the closest [Expansible] instance
130 /// that encloses the given context.
131 ///
132 /// If no [Expansible] encloses the given context, calling this
133 /// method will cause an assert in debug mode, and throw an
134 /// exception in release mode.
135 ///
136 /// To return null if there is no [Expansible] use [maybeOf] instead.
137 ///
138 /// Typical usage of the [ExpansibleController.of] function is to call it from
139 /// within the `build` method of a descendant of an [Expansible].
140 static ExpansibleController of(BuildContext context) {
141 final _ExpansibleState? result = context.findAncestorStateOfType<_ExpansibleState>();
142 assert(() {
143 if (result == null) {
144 throw FlutterError.fromParts(<DiagnosticsNode>[
145 ErrorSummary(
146 'ExpansibleController.of() called with a context that does not contain a Expansible.',
147 ),
148 ErrorDescription(
149 'No Expansible ancestor could be found starting from the context that was passed to ExpansibleController.of(). '
150 'This usually happens when the context provided is from the same StatefulWidget as that '
151 'whose build function actually creates the Expansible widget being sought.',
152 ),
153 ErrorHint(
154 'There are several ways to avoid this problem. The simplest is to use a Builder to get a '
155 'context that is "under" the Expansible. ',
156 ),
157 ErrorHint(
158 'A more efficient solution is to split your build function into several widgets. This '
159 'introduces a new context from which you can obtain the Expansible. In this solution, '
160 'you would have an outer widget that creates the Expansible populated by instances of '
161 'your new inner widgets, and then in these inner widgets you would use ExpansibleController.of().\n'
162 'An other solution is assign a GlobalKey to the Expansible, '
163 'then use the key.currentState property to obtain the Expansible rather than '
164 'using the ExpansibleController.of() function.',
165 ),
166 context.describeElement('The context used was'),
167 ]);
168 }
169 return true;
170 }());
171 return result!.widget.controller;
172 }
173
174 /// Finds the [Expansible] from the closest instance of this class that
175 /// encloses the given context and returns its [ExpansibleController].
176 ///
177 /// If no [Expansible] encloses the given context then return null.
178 /// To throw an exception instead, use [of] instead of this function.
179 ///
180 /// See also:
181 ///
182 /// * [of], a similar function to this one that throws if no [Expansible]
183 /// encloses the given context.
184 static ExpansibleController? maybeOf(BuildContext context) {
185 return context.findAncestorStateOfType<_ExpansibleState>()?.widget.controller;
186 }
187}
188
189/// A [StatefulWidget] that expands and collapses.
190///
191/// An [Expansible] consists of a header, which is always shown, and a
192/// body, which is hidden in its collapsed state and shown in its expanded
193/// state.
194///
195/// The [Expansible] is expanded or collapsed with an animation driven by an
196/// [AnimationController]. When the widget is expanded, the height of its body
197/// animates from 0 to its fully expanded height.
198///
199/// This widget is typically used with [ListView] to create an "expand /
200/// collapse" list entry. When used with scrolling widgets like [ListView], a
201/// unique [PageStorageKey] must be specified as the [key], to enable the
202/// [Expansible] to save and restore its expanded state when it is scrolled
203/// in and out of view.
204///
205/// Provide [headerBuilder] and [bodyBuilder] callbacks to
206/// build the header and body widgets. An additional [expansibleBuilder]
207/// callback can be provided to further customize the layout of the widget.
208///
209/// The [Expansible] does not inherently toggle the expansion state. To toggle
210/// the expansion state, call [ExpansibleController.expand] and
211/// [ExpansibleController.collapse] as needed, most typically when the header
212/// returned in [headerBuilder] is tapped.
213///
214/// See also:
215///
216/// * [ExpansionTile], a Material-styled widget that expands and collapses.
217class Expansible extends StatefulWidget {
218 /// Creates an instance of [Expansible].
219 const Expansible({
220 super.key,
221 required this.headerBuilder,
222 required this.bodyBuilder,
223 required this.controller,
224 this.expansibleBuilder = _defaultExpansibleBuilder,
225 this.duration = const Duration(milliseconds: 200),
226 this.curve = Curves.ease,
227 this.reverseCurve,
228 this.maintainState = true,
229 });
230
231 /// Expands and collapses the widget.
232 ///
233 /// The controller manages the expansion state and toggles the expansion.
234 final ExpansibleController controller;
235
236 /// Builds the always-displayed header.
237 ///
238 /// Many use cases involve toggling the expansion state when this header is
239 /// tapped. To toggle the expansion state, call [ExpansibleController.expand]
240 /// or [ExpansibleController.collapse].
241 final ExpansibleComponentBuilder headerBuilder;
242
243 /// Builds the collapsible body.
244 ///
245 /// When this widget is expanded, the height of its body animates from 0 to
246 /// its fully extended height.
247 final ExpansibleComponentBuilder bodyBuilder;
248
249 /// The duration of the expansion animation.
250 ///
251 /// Defaults to a duration of 200ms.
252 final Duration duration;
253
254 /// The curve of the expansion animation.
255 ///
256 /// Defaults to [Curves.ease].
257 final Curve curve;
258
259 /// The reverse curve of the expansion animation.
260 ///
261 /// If null, uses [curve] in both directions.
262 final Curve? reverseCurve;
263
264 /// Whether the state of the body is maintained when the widget expands or
265 /// collapses.
266 ///
267 /// If true, the body is kept in the tree while the widget is
268 /// collapsed. Otherwise, the body is removed from the tree when the
269 /// widget is collapsed and recreated upon expansion.
270 ///
271 /// Defaults to false.
272 final bool maintainState;
273
274 /// Builds the widget with the results of [headerBuilder] and [bodyBuilder].
275 ///
276 /// Defaults to placing the header and body in a [Column].
277 final ExpansibleBuilder expansibleBuilder;
278
279 static Widget _defaultExpansibleBuilder(
280 BuildContext context,
281 Widget header,
282 Widget body,
283 Animation<double> animation,
284 ) {
285 return Column(mainAxisSize: MainAxisSize.min, children: <Widget>[header, body]);
286 }
287
288 @override
289 State<StatefulWidget> createState() => _ExpansibleState();
290}
291
292class _ExpansibleState extends State<Expansible> with SingleTickerProviderStateMixin {
293 late AnimationController _animationController;
294 late CurvedAnimation _heightFactor;
295
296 @override
297 void initState() {
298 super.initState();
299 _animationController = AnimationController(duration: widget.duration, vsync: this);
300 final bool initiallyExpanded =
301 PageStorage.maybeOf(context)?.readState(context) as bool? ?? widget.controller.isExpanded;
302 if (initiallyExpanded) {
303 _animationController.value = 1.0;
304 widget.controller.expand();
305 } else {
306 widget.controller.collapse();
307 }
308 final Tween<double> heightFactorTween = Tween<double>(begin: 0.0, end: 1.0);
309 _heightFactor = CurvedAnimation(
310 parent: _animationController.drive(heightFactorTween),
311 curve: widget.curve,
312 reverseCurve: widget.reverseCurve,
313 );
314 widget.controller.addListener(_toggleExpansion);
315 }
316
317 @override
318 void didUpdateWidget(covariant Expansible oldWidget) {
319 super.didUpdateWidget(oldWidget);
320 if (widget.curve != oldWidget.curve) {
321 _heightFactor.curve = widget.curve;
322 }
323 if (widget.reverseCurve != oldWidget.reverseCurve) {
324 _heightFactor.reverseCurve = widget.reverseCurve;
325 }
326 if (widget.duration != oldWidget.duration) {
327 _animationController.duration = widget.duration;
328 }
329 if (widget.controller != oldWidget.controller) {
330 oldWidget.controller.removeListener(_toggleExpansion);
331 widget.controller.addListener(_toggleExpansion);
332 }
333 }
334
335 @override
336 void dispose() {
337 widget.controller.removeListener(_toggleExpansion);
338 _animationController.dispose();
339 _heightFactor.dispose();
340 super.dispose();
341 }
342
343 void _toggleExpansion() {
344 setState(() {
345 // Rebuild with the header and the animating body.
346 if (widget.controller.isExpanded) {
347 _animationController.forward();
348 } else {
349 _animationController.reverse().then<void>((void value) {
350 if (!mounted) {
351 return;
352 }
353 setState(() {
354 // Rebuild without the body.
355 });
356 });
357 }
358 PageStorage.maybeOf(context)?.writeState(context, widget.controller.isExpanded);
359 });
360 }
361
362 @override
363 Widget build(BuildContext context) {
364 assert(!_animationController.isDismissed || !widget.controller.isExpanded);
365 final bool closed = !widget.controller.isExpanded && _animationController.isDismissed;
366 final bool shouldRemoveBody = closed && !widget.maintainState;
367
368 final Widget result = Offstage(
369 offstage: closed,
370 child: TickerMode(enabled: !closed, child: widget.bodyBuilder(context, _animationController)),
371 );
372
373 return AnimatedBuilder(
374 animation: _animationController.view,
375 builder: (BuildContext context, Widget? child) {
376 final Widget header = widget.headerBuilder(context, _animationController);
377 final Widget body = ClipRect(child: Align(heightFactor: _heightFactor.value, child: child));
378 return widget.expansibleBuilder(context, header, body, _animationController);
379 },
380 child: shouldRemoveBody ? null : result,
381 );
382 }
383}
384

Provided by KDAB

Privacy Policy
Learn more about Flutter for embedded and desktop on industrialflutter.com