
Because parent structure changes when slices gets separated and merged, children widgets can be rebuilt redundantly. This commit adds a global key to each child so that the framework always knows its children apart.
632 lines
18 KiB
Dart
632 lines
18 KiB
Dart
// Copyright 2015 The Chromium 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 'package:flutter/material.dart';
|
|
import 'package:flutter/rendering.dart';
|
|
import 'package:meta/meta.dart';
|
|
|
|
import 'dart:ui' show lerpDouble;
|
|
|
|
/// The base type for [MaterialSlice] and [MaterialGap].
|
|
///
|
|
/// All [MergeableMaterialItem] objects need a [LocalKey].
|
|
abstract class MergeableMaterialItem {
|
|
MergeableMaterialItem(this.key) {
|
|
assert(key != null);
|
|
}
|
|
|
|
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].
|
|
MaterialSlice({
|
|
@required LocalKey key,
|
|
this.child
|
|
}) : super(key);
|
|
|
|
/// The contents of this slice.
|
|
final Widget child;
|
|
|
|
@override
|
|
String toString() {
|
|
return 'MergeableSlice(key: $key, child: $child)';
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
MaterialGap({
|
|
@required LocalKey key,
|
|
this.size: 16.0
|
|
}) : super(key);
|
|
|
|
/// The main axis extent of this gap. For example, if the [MergableMaterial]
|
|
/// 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 [BlockBody]. 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.
|
|
class MergeableMaterial extends StatefulWidget {
|
|
/// Creates a mergeable Material list of items.
|
|
MergeableMaterial({
|
|
Key key,
|
|
this.mainAxis: Axis.vertical,
|
|
this.elevation: 2,
|
|
this.children: const <MergeableMaterialItem>[]
|
|
}) : super(key: key);
|
|
|
|
/// The children of the [MergeableMaterial].
|
|
final List<MergeableMaterialItem> children;
|
|
|
|
/// The main layout axis.
|
|
final Axis mainAxis;
|
|
|
|
/// The elevation of all the [Material] slices.
|
|
final int elevation;
|
|
|
|
@override
|
|
String toString() {
|
|
return 'MergeableMaterial('
|
|
'key: $key, mainAxis: $mainAxis, elevation: $elevation'
|
|
')';
|
|
}
|
|
|
|
@override
|
|
_MergeableMaterialState createState() => new _MergeableMaterialState();
|
|
}
|
|
|
|
class _AnimationTuple {
|
|
_AnimationTuple({
|
|
this.controller,
|
|
this.startAnimation,
|
|
this.endAnimation,
|
|
this.gapAnimation,
|
|
this.gapStart: 0.0
|
|
});
|
|
|
|
final AnimationController controller;
|
|
final CurvedAnimation startAnimation;
|
|
final CurvedAnimation endAnimation;
|
|
final CurvedAnimation gapAnimation;
|
|
double gapStart;
|
|
}
|
|
|
|
class _MergeableMaterialState extends State<MergeableMaterial> {
|
|
List<MergeableMaterialItem> _children;
|
|
final Map<LocalKey, _AnimationTuple> _animationTuples =
|
|
new Map<LocalKey, _AnimationTuple>();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_children = new List<MergeableMaterialItem>.from(config.children);
|
|
|
|
for (int i = 0; i < _children.length; i += 1) {
|
|
if (_children[i] is MaterialGap) {
|
|
_initGap(_children[i]);
|
|
_animationTuples[_children[i].key].controller.value = 1.0; // Gaps are initially full-sized.
|
|
}
|
|
}
|
|
assert(_debugGapsAreValid(_children));
|
|
}
|
|
|
|
void _initGap(MaterialGap gap) {
|
|
final AnimationController controller = new AnimationController(
|
|
duration: kThemeAnimationDuration
|
|
);
|
|
|
|
final CurvedAnimation startAnimation = new CurvedAnimation(
|
|
parent: controller,
|
|
curve: Curves.ease
|
|
);
|
|
final CurvedAnimation endAnimation = new CurvedAnimation(
|
|
parent: controller,
|
|
curve: Curves.ease
|
|
);
|
|
|
|
startAnimation.addListener(_handleTick);
|
|
endAnimation.addListener(_handleTick);
|
|
|
|
final CurvedAnimation gapAnimation = new CurvedAnimation(
|
|
parent: controller,
|
|
curve: Curves.ease,
|
|
reverseCurve: Curves.ease
|
|
);
|
|
|
|
gapAnimation.addListener(_handleTick);
|
|
|
|
_animationTuples[gap.key] = new _AnimationTuple(
|
|
controller: controller,
|
|
startAnimation: startAnimation,
|
|
endAnimation: endAnimation,
|
|
gapAnimation: gapAnimation
|
|
);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
for (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 < config.children.length - 1; i += 1) {
|
|
if (config.children[i] is MaterialGap &&
|
|
config.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) {
|
|
MergeableMaterialItem child = _children.removeAt(index);
|
|
|
|
if (child is MaterialGap)
|
|
_animationTuples[child.key] = null;
|
|
}
|
|
|
|
bool _closingGap(int index) {
|
|
if (index < _children.length - 1 && _children[index] is MaterialGap) {
|
|
return _animationTuples[_children[index].key].controller.status ==
|
|
AnimationStatus.reverse;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
@override
|
|
void didUpdateConfig(MergeableMaterial oldConfig) {
|
|
super.didUpdateConfig(oldConfig);
|
|
|
|
final Set<LocalKey> oldKeys = oldConfig.children.map(
|
|
(MergeableMaterialItem child) => child.key
|
|
).toSet();
|
|
final Set<LocalKey> newKeys = config.children.map(
|
|
(MergeableMaterialItem child) => child.key
|
|
).toSet();
|
|
final Set<LocalKey> newOnly = newKeys.difference(oldKeys);
|
|
final Set<LocalKey> oldOnly = oldKeys.difference(newKeys);
|
|
|
|
final List<MergeableMaterialItem> newChildren = config.children;
|
|
int i = 0;
|
|
int j = 0;
|
|
|
|
assert(_debugGapsAreValid(newChildren));
|
|
|
|
while (j < _children.length) {
|
|
if (_children[j] is MaterialGap &&
|
|
_animationTuples[_children[j].key].controller.status
|
|
== AnimationStatus.dismissed) {
|
|
_removeChild(j);
|
|
} else {
|
|
j += 1;
|
|
}
|
|
}
|
|
|
|
j = 0;
|
|
|
|
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) || _closingGap(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) {
|
|
if (_children[startOld] is MaterialGap) {
|
|
MaterialGap gap = _children[startOld];
|
|
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) {
|
|
if (newChildren[k] is MaterialGap) {
|
|
MaterialGap gap = newChildren[k];
|
|
gapSizeSum += gap.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) {
|
|
if (newChildren[k] is MaterialGap) {
|
|
MaterialGap gap = newChildren[k];
|
|
|
|
_animationTuples[gap.key].gapStart = gapSize * gap.size /
|
|
gapSizeSum;
|
|
_animationTuples[gap.key].controller
|
|
..value = 0.0
|
|
..forward();
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Grow gaps.
|
|
for (int k = 0; k < newLength; k += 1) {
|
|
_insertChild(startOld + k, newChildren[startNew + k]);
|
|
|
|
if (newChildren[startNew + k] is MaterialGap) {
|
|
MaterialGap gap = newChildren[startNew + k];
|
|
_animationTuples[gap.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) {
|
|
if (_children[startOld] is MaterialGap) {
|
|
MaterialGap gap = _children[startOld];
|
|
gapSizeSum += gap.size;
|
|
}
|
|
|
|
_removeChild(startOld);
|
|
j -= 1;
|
|
}
|
|
|
|
if (gapSizeSum != 0.0) {
|
|
MaterialGap gap = new MaterialGap(
|
|
key: new 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.
|
|
MaterialGap gap = _children[startOld];
|
|
_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 swaped.
|
|
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) {
|
|
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 (config.mainAxis == Axis.vertical) {
|
|
return new BorderRadius.vertical(
|
|
top: start ? cardRadius : startRadius,
|
|
bottom: end ? cardRadius : endRadius
|
|
);
|
|
} else {
|
|
return new BorderRadius.horizontal(
|
|
left: start ? cardRadius : startRadius,
|
|
right: end ? cardRadius : endRadius
|
|
);
|
|
}
|
|
}
|
|
|
|
double _getGapSize(int index) {
|
|
MaterialGap gap = _children[index];
|
|
|
|
return lerpDouble(
|
|
_animationTuples[gap.key].gapStart,
|
|
gap.size,
|
|
_animationTuples[gap.key].gapAnimation.value
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
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(
|
|
new Container(
|
|
decoration: new BoxDecoration(
|
|
backgroundColor: Theme.of(context).cardColor,
|
|
borderRadius: _borderRadius(i - 1, widgets.isEmpty, false),
|
|
shape: BoxShape.rectangle
|
|
),
|
|
child: new BlockBody(
|
|
mainAxis: config.mainAxis,
|
|
children: slices
|
|
)
|
|
)
|
|
);
|
|
slices = <Widget>[];
|
|
|
|
widgets.add(
|
|
new SizedBox(
|
|
width: config.mainAxis == Axis.horizontal ? _getGapSize(i) : null,
|
|
height: config.mainAxis == Axis.vertical ? _getGapSize(i) : null
|
|
)
|
|
);
|
|
} else {
|
|
MaterialSlice slice = _children[i];
|
|
|
|
slices.add(
|
|
new Material(
|
|
// Since slices live in different Material widgets, the parent
|
|
// hierarchy can change and lead to the slice being rebuilt. Using
|
|
// a global key solves the issue.
|
|
key: new _MergeableMaterialSliceKey(_children[i].key),
|
|
type: MaterialType.transparency,
|
|
child: slice.child
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
if (slices.isNotEmpty) {
|
|
widgets.add(
|
|
new Container(
|
|
decoration: new BoxDecoration(
|
|
backgroundColor: Theme.of(context).cardColor,
|
|
borderRadius: _borderRadius(i - 1, widgets.isEmpty, true),
|
|
shape: BoxShape.rectangle
|
|
),
|
|
child: new BlockBody(
|
|
mainAxis: config.mainAxis,
|
|
children: slices
|
|
)
|
|
)
|
|
);
|
|
slices = <Widget>[];
|
|
}
|
|
|
|
return new _MergeableMaterialBlockBody(
|
|
mainAxis: config.mainAxis,
|
|
boxShadows: kElevationToShadow[config.elevation],
|
|
items: _children,
|
|
children: widgets
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MergeableMaterialSliceKey extends GlobalKey {
|
|
const _MergeableMaterialSliceKey(this.value) : super.constructor();
|
|
|
|
final LocalKey value;
|
|
|
|
@override
|
|
bool operator ==(dynamic other) {
|
|
if (other is! _MergeableMaterialSliceKey)
|
|
return false;
|
|
final _MergeableMaterialSliceKey typedOther = other;
|
|
return value == typedOther.value;
|
|
}
|
|
|
|
@override
|
|
int get hashCode => value.hashCode;
|
|
}
|
|
|
|
class _MergeableMaterialBlockBody extends BlockBody {
|
|
_MergeableMaterialBlockBody({
|
|
List<Widget> children,
|
|
Axis mainAxis: Axis.vertical,
|
|
this.items,
|
|
this.boxShadows
|
|
}) : super(children: children, mainAxis: mainAxis);
|
|
|
|
List<MergeableMaterialItem> items;
|
|
List<BoxShadow> boxShadows;
|
|
|
|
@override
|
|
RenderBlock createRenderObject(BuildContext context) {
|
|
return new _MergeableMaterialRenderBlock(
|
|
mainAxis: mainAxis,
|
|
boxShadows: boxShadows
|
|
);
|
|
}
|
|
|
|
@override
|
|
void updateRenderObject(BuildContext context, RenderBlock renderObject) {
|
|
_MergeableMaterialRenderBlock materialRenderBlock = renderObject;
|
|
materialRenderBlock
|
|
..mainAxis = mainAxis
|
|
..boxShadows = boxShadows;
|
|
}
|
|
}
|
|
|
|
class _MergeableMaterialRenderBlock extends RenderBlock {
|
|
_MergeableMaterialRenderBlock({
|
|
List<RenderBox> children,
|
|
Axis mainAxis: Axis.vertical,
|
|
this.boxShadows
|
|
}) : super(children: children, mainAxis: mainAxis);
|
|
|
|
List<BoxShadow> boxShadows;
|
|
|
|
void _paintShadows(Canvas canvas, Rect rect) {
|
|
for (BoxShadow boxShadow in boxShadows) {
|
|
final Paint paint = new Paint()
|
|
..color = boxShadow.color
|
|
..maskFilter = new MaskFilter.blur(BlurStyle.normal, boxShadow.blurSigma);
|
|
// TODO(dragostis): Right now, we are only interpolating the border radii
|
|
// of the visible Material slices, not the shadows; they are not getting
|
|
// interpolated and always have the same rounded radii. Once shadow
|
|
// performance is better, shadows should be redrawn every single time the
|
|
// slices' radii get interpolated and use those radii not the defaults.
|
|
canvas.drawRRect(kMaterialEdges[MaterialType.card].toRRect(rect), paint);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void paint(PaintingContext context, Offset offset) {
|
|
RenderBox child = firstChild;
|
|
int i = 0;
|
|
|
|
while (child != null) {
|
|
final BlockParentData childParentData = child.parentData;
|
|
final Rect rect = (childParentData.offset + offset) & child.size;
|
|
if (i % 2 == 0)
|
|
_paintShadows(context.canvas, rect);
|
|
child = childParentData.nextSibling;
|
|
|
|
i += 1;
|
|
}
|
|
|
|
defaultPaint(context, offset);
|
|
}
|
|
}
|