| 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 | /// [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 all resources used by the object are discarded. |
| 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( |
| 378 | child: Align(heightFactor: _heightFactor.value, child: child), |
| 379 | ); |
| 380 | return widget.expansibleBuilder(context, header, body, _animationController); |
| 381 | }, |
| 382 | child: shouldRemoveBody ? null : result, |
| 383 | ); |
| 384 | } |
| 385 | } |
| 386 | |