mergeable_material.dart 22 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5 6
// @dart = 2.8

7 8
import 'dart:ui' show lerpDouble;

9
import 'package:flutter/rendering.dart';
10 11
import 'package:flutter/widgets.dart';

12
import 'divider.dart';
13 14 15
import 'material.dart';
import 'shadows.dart';
import 'theme.dart';
16 17 18 19

/// The base type for [MaterialSlice] and [MaterialGap].
///
/// All [MergeableMaterialItem] objects need a [LocalKey].
20
@immutable
21
abstract class MergeableMaterialItem {
22 23 24 25
  /// 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.
26
  const MergeableMaterialItem(this.key) : assert(key != null);
27 28 29 30 31 32

  /// 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.
33 34 35 36 37 38 39 40 41 42
  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].
43
  const MaterialSlice({
44
    @required LocalKey key,
45
    @required this.child,
46 47
  }) : assert(key != null),
       super(key);
48 49

  /// The contents of this slice.
50 51
  ///
  /// {@macro flutter.widgets.child}
52 53 54 55 56 57 58 59 60 61 62 63 64
  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.
65
  const MaterialGap({
66
    @required LocalKey key,
67
    this.size = 16.0,
68 69
  }) : assert(key != null),
       super(key);
70

71
  /// The main axis extent of this gap. For example, if the [MergeableMaterial]
72 73 74 75 76 77 78 79 80 81 82 83
  /// 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
84
/// the given [mainAxis] in the same way as the children of a [ListBody]. When
85 86 87 88 89 90 91 92 93 94 95 96
/// 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.
97 98 99 100 101
///
/// See also:
///
///  * [Card], a piece of material that does not support splitting and merging
///    but otherwise looks the same.
102 103
class MergeableMaterial extends StatefulWidget {
  /// Creates a mergeable Material list of items.
104
  const MergeableMaterial({
105
    Key key,
106 107 108
    this.mainAxis = Axis.vertical,
    this.elevation = 2,
    this.hasDividers = false,
109
    this.children = const <MergeableMaterialItem>[],
110
    this.dividerColor,
111 112 113 114 115 116 117 118
  }) : super(key: key);

  /// The children of the [MergeableMaterial].
  final List<MergeableMaterialItem> children;

  /// The main layout axis.
  final Axis mainAxis;

119 120 121 122 123
  /// The z-coordinate at which to place all the [Material] slices.
  ///
  /// The following elevations have defined shadows: 1, 2, 3, 4, 6, 8, 9, 12, 16, 24
  ///
  /// Defaults to 2, the appropriate elevation for cards.
Ian Hickson's avatar
Ian Hickson committed
124 125 126
  ///
  /// This uses [kElevationToShadow] to simulate shadows, it does not use
  /// [Material]'s arbitrary elevation feature.
127 128
  final int elevation;

129 130 131
  /// Whether connected pieces of [MaterialSlice] have dividers between them.
  final bool hasDividers;

132 133 134 135 136 137
  /// 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;

138
  @override
139 140
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
141 142
    properties.add(EnumProperty<Axis>('mainAxis', mainAxis));
    properties.add(DoubleProperty('elevation', elevation.toDouble()));
143 144 145
  }

  @override
146
  _MergeableMaterialState createState() => _MergeableMaterialState();
147 148 149 150 151 152 153 154
}

class _AnimationTuple {
  _AnimationTuple({
    this.controller,
    this.startAnimation,
    this.endAnimation,
    this.gapAnimation,
155
    this.gapStart = 0.0,
156 157 158 159 160 161 162 163 164
  });

  final AnimationController controller;
  final CurvedAnimation startAnimation;
  final CurvedAnimation endAnimation;
  final CurvedAnimation gapAnimation;
  double gapStart;
}

165
class _MergeableMaterialState extends State<MergeableMaterial> with TickerProviderStateMixin {
166 167
  List<MergeableMaterialItem> _children;
  final Map<LocalKey, _AnimationTuple> _animationTuples =
168
      <LocalKey, _AnimationTuple>{};
169 170 171 172

  @override
  void initState() {
    super.initState();
173
    _children = List<MergeableMaterialItem>.from(widget.children);
174 175

    for (int i = 0; i < _children.length; i += 1) {
176 177 178 179
      final MergeableMaterialItem child = _children[i];
      if (child is MaterialGap) {
        _initGap(child);
        _animationTuples[child.key].controller.value = 1.0; // Gaps are initially full-sized.
180 181 182 183 184 185
      }
    }
    assert(_debugGapsAreValid(_children));
  }

  void _initGap(MaterialGap gap) {
186
    final AnimationController controller = AnimationController(
187 188
      duration: kThemeAnimationDuration,
      vsync: this,
189 190
    );

191
    final CurvedAnimation startAnimation = CurvedAnimation(
192
      parent: controller,
193
      curve: Curves.fastOutSlowIn,
194
    );
195
    final CurvedAnimation endAnimation = CurvedAnimation(
196
      parent: controller,
197
      curve: Curves.fastOutSlowIn,
198
    );
199
    final CurvedAnimation gapAnimation = CurvedAnimation(
200
      parent: controller,
201
      curve: Curves.fastOutSlowIn,
202 203
    );

204
    controller.addListener(_handleTick);
205

206
    _animationTuples[gap.key] = _AnimationTuple(
207 208 209
      controller: controller,
      startAnimation: startAnimation,
      endAnimation: endAnimation,
210
      gapAnimation: gapAnimation,
211 212 213 214 215
    );
  }

  @override
  void dispose() {
216
    for (final MergeableMaterialItem child in _children) {
217 218 219 220 221 222 223 224 225 226 227 228 229
      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) {
230 231 232
    for (int i = 0; i < widget.children.length - 1; i += 1) {
      if (widget.children[i] is MaterialGap &&
          widget.children[i + 1] is MaterialGap)
233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259
        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) {
260
    final MergeableMaterialItem child = _children.removeAt(index);
261 262 263 264 265

    if (child is MaterialGap)
      _animationTuples[child.key] = null;
  }

266
  bool _isClosingGap(int index) {
267 268 269 270 271 272 273 274
    if (index < _children.length - 1 && _children[index] is MaterialGap) {
      return _animationTuples[_children[index].key].controller.status ==
          AnimationStatus.reverse;
    }

    return false;
  }

275 276 277 278 279 280 281 282 283 284 285 286 287 288 289
  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;
      }
    }
  }

290
  @override
291 292
  void didUpdateWidget(MergeableMaterial oldWidget) {
    super.didUpdateWidget(oldWidget);
293

294
    final Set<LocalKey> oldKeys = oldWidget.children.map<LocalKey>(
295 296
      (MergeableMaterialItem child) => child.key
    ).toSet();
297
    final Set<LocalKey> newKeys = widget.children.map<LocalKey>(
298 299 300 301 302
      (MergeableMaterialItem child) => child.key
    ).toSet();
    final Set<LocalKey> newOnly = newKeys.difference(oldKeys);
    final Set<LocalKey> oldOnly = oldKeys.difference(newKeys);

303
    final List<MergeableMaterialItem> newChildren = widget.children;
304 305 306 307 308
    int i = 0;
    int j = 0;

    assert(_debugGapsAreValid(newChildren));

309
    _removeEmptyGaps();
310 311 312 313 314 315 316 317 318 319 320 321

    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.
322
        while (oldOnly.contains(_children[j].key) || _isClosingGap(j))
323 324 325 326 327 328 329 330 331 332 333 334 335
          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) {
336 337 338
                final MergeableMaterialItem child = _children[startOld];
                if (child is MaterialGap) {
                  final MaterialGap gap = child;
339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377
                  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) {
378 379 380
                final MergeableMaterialItem newChild = newChildren[k];
                if (newChild is MaterialGap) {
                  gapSizeSum += newChild.size;
381 382 383 384 385 386
                }
              }

              // 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) {
387 388 389 390
                final MergeableMaterialItem newChild = newChildren[k];
                if (newChild is MaterialGap) {
                  _animationTuples[newChild.key].gapStart = gapSize * newChild.size / gapSizeSum;
                  _animationTuples[newChild.key].controller
391 392 393 394 395 396 397 398
                    ..value = 0.0
                    ..forward();
                }
              }
            }
          } else {
            // Grow gaps.
            for (int k = 0; k < newLength; k += 1) {
399
              final MergeableMaterialItem newChild = newChildren[startNew + k];
400

401 402 403 404
              _insertChild(startOld + k, newChild);

              if (newChild is MaterialGap) {
                _animationTuples[newChild.key].controller.forward();
405 406 407 408 409 410 411 412 413 414 415 416
              }
            }

            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) {
417 418 419
              final MergeableMaterialItem child = _children[startOld];
              if (child is MaterialGap) {
                gapSizeSum += child.size;
420 421 422 423 424 425 426
              }

              _removeChild(startOld);
              j -= 1;
            }

            if (gapSizeSum != 0.0) {
427 428
              final MaterialGap gap = MaterialGap(
                key: UniqueKey(),
429
                size: gapSizeSum,
430 431 432 433 434 435 436 437 438 439 440
              );
              _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.
441
            final MaterialGap gap = _children[startOld] as MaterialGap;
442 443 444 445 446 447
            _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
448
        // their places have been swapped.
449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473
        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) {
474 475 476
    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);
477 478 479 480 481 482 483 484 485
    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,
486
        _animationTuples[_children[index - 1].key].startAnimation.value,
487 488 489 490 491 492
      );
    }
    if (index < _children.length - 2 && _children[index + 1] is MaterialGap) {
      endRadius = Radius.lerp(
        Radius.zero,
        cardRadius,
493
        _animationTuples[_children[index + 1].key].endAnimation.value,
494 495 496
      );
    }

497
    if (widget.mainAxis == Axis.vertical) {
498
      return BorderRadius.vertical(
499
        top: start ? cardRadius : startRadius,
500
        bottom: end ? cardRadius : endRadius,
501 502
      );
    } else {
503
      return BorderRadius.horizontal(
504
        left: start ? cardRadius : startRadius,
505
        right: end ? cardRadius : endRadius,
506 507 508 509 510
      );
    }
  }

  double _getGapSize(int index) {
511
    final MaterialGap gap = _children[index] as MaterialGap;
512 513 514 515

    return lerpDouble(
      _animationTuples[gap.key].gapStart,
      gap.size,
516
      _animationTuples[gap.key].gapAnimation.value,
517 518 519
    );
  }

520 521 522 523 524 525
  bool _willNeedDivider(int index) {
    if (index < 0)
      return false;
    if (index >= _children.length)
      return false;
    return _children[index] is MaterialSlice || _isClosingGap(index);
526 527
  }

528 529
  @override
  Widget build(BuildContext context) {
530 531
    _removeEmptyGaps();

532 533 534 535 536 537 538 539
    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(
540 541
          Container(
            decoration: BoxDecoration(
542
              color: Theme.of(context).cardColor,
543
              borderRadius: _borderRadius(i - 1, widgets.isEmpty, false),
544
              shape: BoxShape.rectangle,
545
            ),
546
            child: ListBody(
547
              mainAxis: widget.mainAxis,
548 549
              children: slices,
            ),
550
          ),
551 552 553 554
        );
        slices = <Widget>[];

        widgets.add(
555
          SizedBox(
556
            width: widget.mainAxis == Axis.horizontal ? _getGapSize(i) : null,
557
            height: widget.mainAxis == Axis.vertical ? _getGapSize(i) : null,
558
          ),
559 560
        );
      } else {
561
        final MaterialSlice slice = _children[i] as MaterialSlice;
562 563
        Widget child = slice.child;

564
        if (widget.hasDividers) {
565 566 567 568
          final bool hasTopDivider = _willNeedDivider(i - 1);
          final bool hasBottomDivider = _willNeedDivider(i + 1);

          Border border;
569 570 571
          final BorderSide divider = Divider.createBorderSide(
            context,
            width: 0.5, // TODO(ianh): This probably looks terrible when the dpr isn't a power of two.
572
            color: widget.dividerColor,
573 574 575
          );

          if (i == 0) {
576
            border = Border(
577 578 579
              bottom: hasBottomDivider ? divider : BorderSide.none
            );
          } else if (i == _children.length - 1) {
580
            border = Border(
581 582 583
              top: hasTopDivider ? divider : BorderSide.none
            );
          } else {
584
            border = Border(
585
              top: hasTopDivider ? divider : BorderSide.none,
586
              bottom: hasBottomDivider ? divider : BorderSide.none,
587 588 589 590 591
            );
          }

          assert(border != null);

592 593 594
          child = AnimatedContainer(
            key: _MergeableMaterialSliceKey(_children[i].key),
            decoration: BoxDecoration(border: border),
595 596
            duration: kThemeAnimationDuration,
            curve: Curves.fastOutSlowIn,
597
            child: child,
598 599
          );
        }
600 601

        slices.add(
602
          Material(
603
            type: MaterialType.transparency,
604
            child: child,
605
          ),
606 607 608 609 610 611
        );
      }
    }

    if (slices.isNotEmpty) {
      widgets.add(
612 613
        Container(
          decoration: BoxDecoration(
614
            color: Theme.of(context).cardColor,
615
            borderRadius: _borderRadius(i - 1, widgets.isEmpty, true),
616
            shape: BoxShape.rectangle,
617
          ),
618
          child: ListBody(
619
            mainAxis: widget.mainAxis,
620 621
            children: slices,
          ),
622
        ),
623 624 625 626
      );
      slices = <Widget>[];
    }

627
    return _MergeableMaterialListBody(
628 629
      mainAxis: widget.mainAxis,
      boxShadows: kElevationToShadow[widget.elevation],
630
      items: _children,
631
      children: widgets,
632 633 634 635
    );
  }
}

636
// The parent hierarchy can change and lead to the slice being
637
// rebuilt. Using a global key solves the issue.
638
class _MergeableMaterialSliceKey extends GlobalKey {
639
  const _MergeableMaterialSliceKey(this.value) : super.constructor();
640 641 642 643

  final LocalKey value;

  @override
644
  bool operator ==(Object other) {
645 646
    return other is _MergeableMaterialSliceKey
        && other.value == value;
647 648 649 650
  }

  @override
  int get hashCode => value.hashCode;
651 652 653 654 655

  @override
  String toString() {
    return '_MergeableMaterialSliceKey($value)';
  }
656 657
}

658 659
class _MergeableMaterialListBody extends ListBody {
  _MergeableMaterialListBody({
660
    List<Widget> children,
661
    Axis mainAxis = Axis.vertical,
662
    this.items,
663
    this.boxShadows,
664 665
  }) : super(children: children, mainAxis: mainAxis);

666 667
  final List<MergeableMaterialItem> items;
  final List<BoxShadow> boxShadows;
668

669 670 671 672
  AxisDirection _getDirection(BuildContext context) {
    return getAxisDirectionFromAxisReverseAndDirectionality(context, mainAxis, false);
  }

673
  @override
674
  RenderListBody createRenderObject(BuildContext context) {
675
    return _RenderMergeableMaterialListBody(
676 677
      axisDirection: _getDirection(context),
      boxShadows: boxShadows,
678 679 680 681
    );
  }

  @override
682
  void updateRenderObject(BuildContext context, RenderListBody renderObject) {
683
    final _RenderMergeableMaterialListBody materialRenderListBody = renderObject as _RenderMergeableMaterialListBody;
684
    materialRenderListBody
685
      ..axisDirection = _getDirection(context)
686 687 688 689
      ..boxShadows = boxShadows;
  }
}

690 691
class _RenderMergeableMaterialListBody extends RenderListBody {
  _RenderMergeableMaterialListBody({
692
    List<RenderBox> children,
693
    AxisDirection axisDirection = AxisDirection.down,
694
    this.boxShadows,
695
  }) : super(children: children, axisDirection: axisDirection);
696 697 698 699

  List<BoxShadow> boxShadows;

  void _paintShadows(Canvas canvas, Rect rect) {
700
    for (final BoxShadow boxShadow in boxShadows) {
701
      final Paint paint = boxShadow.toPaint();
702 703 704 705 706 707 708 709 710 711 712 713 714 715 716
      // 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) {
717
      final ListBodyParentData childParentData = child.parentData as ListBodyParentData;
718 719 720 721 722 723 724 725 726 727 728
      final Rect rect = (childParentData.offset + offset) & child.size;
      if (i % 2 == 0)
        _paintShadows(context.canvas, rect);
      child = childParentData.nextSibling;

      i += 1;
    }

    defaultPaint(context, offset);
  }
}