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'; |
6 | library; |
7 | |
8 | import 'basic.dart'; |
9 | import 'framework.dart'; |
10 | import 'page_storage.dart'; |
11 | import 'ticker_provider.dart'; |
12 | import '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. |
26 | typedef 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. |
45 | typedef 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. |
63 | class 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. |
217 | class 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 | |
292 | class _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 | |