// Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'dart:ui' show lerpDouble; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'colors.dart'; import 'divider.dart'; import 'material.dart'; import 'theme.dart'; /// The base type for [MaterialSlice] and [MaterialGap]. /// /// All [MergeableMaterialItem] objects need a [LocalKey]. @immutable abstract class MergeableMaterialItem { /// Abstract const constructor. This constructor enables subclasses to provide /// const constructors so that they can be used in const expressions. /// /// The argument is the [key], which must not be null. const MergeableMaterialItem(this.key) : assert(key != null); /// The key for this item of the list. /// /// The key is used to match parts of the mergeable material from frame to /// frame so that state is maintained appropriately even as slices are added /// or removed. final LocalKey key; } /// A class that can be used as a child to [MergeableMaterial]. It is a slice /// of [Material] that animates merging with other slices. /// /// All [MaterialSlice] objects need a [LocalKey]. class MaterialSlice extends MergeableMaterialItem { /// Creates a slice of [Material] that's mergeable within a /// [MergeableMaterial]. const MaterialSlice({ required LocalKey key, required this.child, this.color, }) : assert(key != null), super(key); /// The contents of this slice. /// /// {@macro flutter.widgets.ProxyWidget.child} final Widget child; /// Defines the color for the slice. /// /// By default, the value of `color` is [ThemeData.cardColor]. final Color? color; @override String toString() { return 'MergeableSlice(key: $key, child: $child, color: $color)'; } } /// A class that represents a gap within [MergeableMaterial]. /// /// All [MaterialGap] objects need a [LocalKey]. class MaterialGap extends MergeableMaterialItem { /// Creates a Material gap with a given size. const MaterialGap({ required LocalKey key, this.size = 16.0, }) : assert(key != null), super(key); /// The main axis extent of this gap. For example, if the [MergeableMaterial] /// is vertical, then this is the height of the gap. final double size; @override String toString() { return 'MaterialGap(key: $key, child: $size)'; } } /// Displays a list of [MergeableMaterialItem] children. The list contains /// [MaterialSlice] items whose boundaries are either "merged" with adjacent /// items or separated by a [MaterialGap]. The [children] are distributed along /// the given [mainAxis] in the same way as the children of a [ListBody]. When /// the list of children changes, gaps are automatically animated open or closed /// as needed. /// /// To enable this widget to correlate its list of children with the previous /// one, each child must specify a key. /// /// When a new gap is added to the list of children the adjacent items are /// animated apart. Similarly when a gap is removed the adjacent items are /// brought back together. /// /// When a new slice is added or removed, the app is responsible for animating /// the transition of the slices, while the gaps will be animated automatically. /// /// See also: /// /// * [Card], a piece of material that does not support splitting and merging /// but otherwise looks the same. class MergeableMaterial extends StatefulWidget { /// Creates a mergeable Material list of items. const MergeableMaterial({ super.key, this.mainAxis = Axis.vertical, this.elevation = 2, this.hasDividers = false, this.children = const <MergeableMaterialItem>[], this.dividerColor, }); /// The children of the [MergeableMaterial]. final List<MergeableMaterialItem> children; /// The main layout axis. final Axis mainAxis; /// The z-coordinate at which to place all the [Material] slices. /// /// Defaults to 2, the appropriate elevation for cards. final double elevation; /// Whether connected pieces of [MaterialSlice] have dividers between them. final bool hasDividers; /// Defines color used for dividers if [hasDividers] is true. /// /// If `dividerColor` is null, then [DividerThemeData.color] is used. If that /// is null, then [ThemeData.dividerColor] is used. final Color? dividerColor; @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties.add(EnumProperty<Axis>('mainAxis', mainAxis)); properties.add(DoubleProperty('elevation', elevation)); } @override State<MergeableMaterial> createState() => _MergeableMaterialState(); } class _AnimationTuple { _AnimationTuple({ required this.controller, required this.startAnimation, required this.endAnimation, required this.gapAnimation, }); final AnimationController controller; final CurvedAnimation startAnimation; final CurvedAnimation endAnimation; final CurvedAnimation gapAnimation; double gapStart = 0.0; } class _MergeableMaterialState extends State<MergeableMaterial> with TickerProviderStateMixin { late List<MergeableMaterialItem> _children; final Map<LocalKey, _AnimationTuple?> _animationTuples = <LocalKey, _AnimationTuple?>{}; @override void initState() { super.initState(); _children = List<MergeableMaterialItem>.of(widget.children); for (int i = 0; i < _children.length; i += 1) { final MergeableMaterialItem child = _children[i]; if (child is MaterialGap) { _initGap(child); _animationTuples[child.key]!.controller.value = 1.0; // Gaps are initially full-sized. } } assert(_debugGapsAreValid(_children)); } void _initGap(MaterialGap gap) { final AnimationController controller = AnimationController( duration: kThemeAnimationDuration, vsync: this, ); final CurvedAnimation startAnimation = CurvedAnimation( parent: controller, curve: Curves.fastOutSlowIn, ); final CurvedAnimation endAnimation = CurvedAnimation( parent: controller, curve: Curves.fastOutSlowIn, ); final CurvedAnimation gapAnimation = CurvedAnimation( parent: controller, curve: Curves.fastOutSlowIn, ); controller.addListener(_handleTick); _animationTuples[gap.key] = _AnimationTuple( controller: controller, startAnimation: startAnimation, endAnimation: endAnimation, gapAnimation: gapAnimation, ); } @override void dispose() { for (final MergeableMaterialItem child in _children) { if (child is MaterialGap) { _animationTuples[child.key]!.controller.dispose(); } } super.dispose(); } void _handleTick() { setState(() { // The animation's state is our build state, and it changed already. }); } bool _debugHasConsecutiveGaps(List<MergeableMaterialItem> children) { for (int i = 0; i < widget.children.length - 1; i += 1) { if (widget.children[i] is MaterialGap && widget.children[i + 1] is MaterialGap) { return true; } } return false; } bool _debugGapsAreValid(List<MergeableMaterialItem> children) { // Check for consecutive gaps. if (_debugHasConsecutiveGaps(children)) { return false; } // First and last children must not be gaps. if (children.isNotEmpty) { if (children.first is MaterialGap || children.last is MaterialGap) { return false; } } return true; } void _insertChild(int index, MergeableMaterialItem child) { _children.insert(index, child); if (child is MaterialGap) { _initGap(child); } } void _removeChild(int index) { final MergeableMaterialItem child = _children.removeAt(index); if (child is MaterialGap) { _animationTuples[child.key] = null; } } bool _isClosingGap(int index) { if (index < _children.length - 1 && _children[index] is MaterialGap) { return _animationTuples[_children[index].key]!.controller.status == AnimationStatus.reverse; } return false; } void _removeEmptyGaps() { int j = 0; while (j < _children.length) { if ( _children[j] is MaterialGap && _animationTuples[_children[j].key]!.controller.status == AnimationStatus.dismissed ) { _removeChild(j); } else { j += 1; } } } @override void didUpdateWidget(MergeableMaterial oldWidget) { super.didUpdateWidget(oldWidget); final Set<LocalKey> oldKeys = oldWidget.children.map<LocalKey>( (MergeableMaterialItem child) => child.key, ).toSet(); final Set<LocalKey> newKeys = widget.children.map<LocalKey>( (MergeableMaterialItem child) => child.key, ).toSet(); final Set<LocalKey> newOnly = newKeys.difference(oldKeys); final Set<LocalKey> oldOnly = oldKeys.difference(newKeys); final List<MergeableMaterialItem> newChildren = widget.children; int i = 0; int j = 0; assert(_debugGapsAreValid(newChildren)); _removeEmptyGaps(); while (i < newChildren.length && j < _children.length) { if (newOnly.contains(newChildren[i].key) || oldOnly.contains(_children[j].key)) { final int startNew = i; final int startOld = j; // Skip new keys. while (newOnly.contains(newChildren[i].key)) { i += 1; } // Skip old keys. while (oldOnly.contains(_children[j].key) || _isClosingGap(j)) { j += 1; } final int newLength = i - startNew; final int oldLength = j - startOld; if (newLength > 0) { if (oldLength > 1 || oldLength == 1 && _children[startOld] is MaterialSlice) { if (newLength == 1 && newChildren[startNew] is MaterialGap) { // Shrink all gaps into the size of the new one. double gapSizeSum = 0.0; while (startOld < j) { final MergeableMaterialItem child = _children[startOld]; if (child is MaterialGap) { final MaterialGap gap = child; gapSizeSum += gap.size; } _removeChild(startOld); j -= 1; } _insertChild(startOld, newChildren[startNew]); _animationTuples[newChildren[startNew].key]! ..gapStart = gapSizeSum ..controller.forward(); j += 1; } else { // No animation if replaced items are more than one. for (int k = 0; k < oldLength; k += 1) { _removeChild(startOld); } for (int k = 0; k < newLength; k += 1) { _insertChild(startOld + k, newChildren[startNew + k]); } j += newLength - oldLength; } } else if (oldLength == 1) { if (newLength == 1 && newChildren[startNew] is MaterialGap && _children[startOld].key == newChildren[startNew].key) { /// Special case: gap added back. _animationTuples[newChildren[startNew].key]!.controller.forward(); } else { final double gapSize = _getGapSize(startOld); _removeChild(startOld); for (int k = 0; k < newLength; k += 1) { _insertChild(startOld + k, newChildren[startNew + k]); } j += newLength - 1; double gapSizeSum = 0.0; for (int k = startNew; k < i; k += 1) { final MergeableMaterialItem newChild = newChildren[k]; if (newChild is MaterialGap) { gapSizeSum += newChild.size; } } // All gaps get proportional sizes of the original gap and they will // animate to their actual size. for (int k = startNew; k < i; k += 1) { final MergeableMaterialItem newChild = newChildren[k]; if (newChild is MaterialGap) { _animationTuples[newChild.key]!.gapStart = gapSize * newChild.size / gapSizeSum; _animationTuples[newChild.key]!.controller ..value = 0.0 ..forward(); } } } } else { // Grow gaps. for (int k = 0; k < newLength; k += 1) { final MergeableMaterialItem newChild = newChildren[startNew + k]; _insertChild(startOld + k, newChild); if (newChild is MaterialGap) { _animationTuples[newChild.key]!.controller.forward(); } } j += newLength; } } else { // If more than a gap disappeared, just remove slices and shrink gaps. if (oldLength > 1 || oldLength == 1 && _children[startOld] is MaterialSlice) { double gapSizeSum = 0.0; while (startOld < j) { final MergeableMaterialItem child = _children[startOld]; if (child is MaterialGap) { gapSizeSum += child.size; } _removeChild(startOld); j -= 1; } if (gapSizeSum != 0.0) { final MaterialGap gap = MaterialGap( key: UniqueKey(), size: gapSizeSum, ); _insertChild(startOld, gap); _animationTuples[gap.key]!.gapStart = 0.0; _animationTuples[gap.key]!.controller ..value = 1.0 ..reverse(); j += 1; } } else if (oldLength == 1) { // Shrink gap. final MaterialGap gap = _children[startOld] as MaterialGap; _animationTuples[gap.key]!.gapStart = 0.0; _animationTuples[gap.key]!.controller.reverse(); } } } else { // Check whether the items are the same type. If they are, it means that // their places have been swapped. if ((_children[j] is MaterialGap) == (newChildren[i] is MaterialGap)) { _children[j] = newChildren[i]; i += 1; j += 1; } else { // This is a closing gap which we need to skip. assert(_children[j] is MaterialGap); j += 1; } } } // Handle remaining items. while (j < _children.length) { _removeChild(j); } while (i < newChildren.length) { _insertChild(j, newChildren[i]); i += 1; j += 1; } } BorderRadius _borderRadius(int index, bool start, bool end) { assert(kMaterialEdges[MaterialType.card]!.topLeft == kMaterialEdges[MaterialType.card]!.topRight); assert(kMaterialEdges[MaterialType.card]!.topLeft == kMaterialEdges[MaterialType.card]!.bottomLeft); assert(kMaterialEdges[MaterialType.card]!.topLeft == kMaterialEdges[MaterialType.card]!.bottomRight); final Radius cardRadius = kMaterialEdges[MaterialType.card]!.topLeft; Radius startRadius = Radius.zero; Radius endRadius = Radius.zero; if (index > 0 && _children[index - 1] is MaterialGap) { startRadius = Radius.lerp( Radius.zero, cardRadius, _animationTuples[_children[index - 1].key]!.startAnimation.value, )!; } if (index < _children.length - 2 && _children[index + 1] is MaterialGap) { endRadius = Radius.lerp( Radius.zero, cardRadius, _animationTuples[_children[index + 1].key]!.endAnimation.value, )!; } if (widget.mainAxis == Axis.vertical) { return BorderRadius.vertical( top: start ? cardRadius : startRadius, bottom: end ? cardRadius : endRadius, ); } else { return BorderRadius.horizontal( left: start ? cardRadius : startRadius, right: end ? cardRadius : endRadius, ); } } double _getGapSize(int index) { final MaterialGap gap = _children[index] as MaterialGap; return lerpDouble( _animationTuples[gap.key]!.gapStart, gap.size, _animationTuples[gap.key]!.gapAnimation.value, )!; } bool _willNeedDivider(int index) { if (index < 0) { return false; } if (index >= _children.length) { return false; } return _children[index] is MaterialSlice || _isClosingGap(index); } @override Widget build(BuildContext context) { _removeEmptyGaps(); final List<Widget> widgets = <Widget>[]; List<Widget> slices = <Widget>[]; int i; for (i = 0; i < _children.length; i += 1) { if (_children[i] is MaterialGap) { assert(slices.isNotEmpty); widgets.add( ListBody( mainAxis: widget.mainAxis, children: slices, ), ); slices = <Widget>[]; widgets.add( SizedBox( width: widget.mainAxis == Axis.horizontal ? _getGapSize(i) : null, height: widget.mainAxis == Axis.vertical ? _getGapSize(i) : null, ), ); } else { final MaterialSlice slice = _children[i] as MaterialSlice; Widget child = slice.child; if (widget.hasDividers) { final bool hasTopDivider = _willNeedDivider(i - 1); final bool hasBottomDivider = _willNeedDivider(i + 1); final BorderSide divider = Divider.createBorderSide( context, width: 0.5, // TODO(ianh): This probably looks terrible when the dpr isn't a power of two. color: widget.dividerColor, ); final Border border; if (i == 0) { border = Border( bottom: hasBottomDivider ? divider : BorderSide.none, ); } else if (i == _children.length - 1) { border = Border( top: hasTopDivider ? divider : BorderSide.none, ); } else { border = Border( top: hasTopDivider ? divider : BorderSide.none, bottom: hasBottomDivider ? divider : BorderSide.none, ); } child = AnimatedContainer( key: _MergeableMaterialSliceKey(_children[i].key), decoration: BoxDecoration(border: border), duration: kThemeAnimationDuration, curve: Curves.fastOutSlowIn, child: child, ); } slices.add( Container( decoration: BoxDecoration( color: (_children[i] as MaterialSlice).color ?? Theme.of(context).cardColor, borderRadius: _borderRadius(i, i == 0, i == _children.length - 1), ), child: Material( type: MaterialType.transparency, child: child, ), ), ); } } if (slices.isNotEmpty) { widgets.add( ListBody( mainAxis: widget.mainAxis, children: slices, ), ); slices = <Widget>[]; } return _MergeableMaterialListBody( mainAxis: widget.mainAxis, elevation: widget.elevation, items: _children, children: widgets, ); } } // The parent hierarchy can change and lead to the slice being // rebuilt. Using a global key solves the issue. class _MergeableMaterialSliceKey extends GlobalKey { const _MergeableMaterialSliceKey(this.value) : super.constructor(); final LocalKey value; @override bool operator ==(Object other) { return other is _MergeableMaterialSliceKey && other.value == value; } @override int get hashCode => value.hashCode; @override String toString() { return '_MergeableMaterialSliceKey($value)'; } } class _MergeableMaterialListBody extends ListBody { _MergeableMaterialListBody({ required super.children, super.mainAxis, required this.items, required this.elevation, }); final List<MergeableMaterialItem> items; final double elevation; AxisDirection _getDirection(BuildContext context) { return getAxisDirectionFromAxisReverseAndDirectionality(context, mainAxis, false); } @override RenderListBody createRenderObject(BuildContext context) { return _RenderMergeableMaterialListBody( axisDirection: _getDirection(context), elevation: elevation, ); } @override void updateRenderObject(BuildContext context, RenderListBody renderObject) { final _RenderMergeableMaterialListBody materialRenderListBody = renderObject as _RenderMergeableMaterialListBody; materialRenderListBody ..axisDirection = _getDirection(context) ..elevation = elevation; } } class _RenderMergeableMaterialListBody extends RenderListBody { _RenderMergeableMaterialListBody({ super.axisDirection, double elevation = 0.0, }) : _elevation = elevation; double get elevation => _elevation; double _elevation; set elevation(double value) { if (value == _elevation) { return; } _elevation = value; markNeedsPaint(); } void _paintShadows(Canvas canvas, Rect rect) { // TODO(ianh): We should interpolate the border radii of the shadows the same way we do those of the visible Material slices. if (elevation != 0) { canvas.drawShadow( Path()..addRRect(kMaterialEdges[MaterialType.card]!.toRRect(rect)), Colors.black, elevation, true, // occluding object is not (necessarily) opaque ); } } @override void paint(PaintingContext context, Offset offset) { RenderBox? child = firstChild; int index = 0; while (child != null) { final ListBodyParentData childParentData = child.parentData! as ListBodyParentData; final Rect rect = (childParentData.offset + offset) & child.size; if (index.isEven) { _paintShadows(context.canvas, rect); } child = childParentData.nextSibling; index += 1; } defaultPaint(context, offset); } }