blob: 1ff2982d794e7d77f47f791b1d6fccf154086cd5 [file] [log] [blame]
// 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);
/// 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,
}) : 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,
}) : 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) {
final MergeableMaterialItem newChild = newChildren[i];
_insertChild(j, newChild);
if (newChild is MaterialGap) {
_animationTuples[newChild.key]!.controller.forward();
}
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 {
const _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);
}
}