mergeable_material.dart 21.7 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
import 'dart:ui' show lerpDouble;

7
import 'package:flutter/rendering.dart';
8 9
import 'package:flutter/widgets.dart';

10
import 'divider.dart';
11 12 13
import 'material.dart';
import 'shadows.dart';
import 'theme.dart';
14 15 16 17

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

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

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

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

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

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

116 117 118 119 120
  /// 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
121 122 123
  ///
  /// This uses [kElevationToShadow] to simulate shadows, it does not use
  /// [Material]'s arbitrary elevation feature.
124 125
  final int elevation;

126 127 128
  /// Whether connected pieces of [MaterialSlice] have dividers between them.
  final bool hasDividers;

129
  @override
130 131
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
132 133
    properties.add(EnumProperty<Axis>('mainAxis', mainAxis));
    properties.add(DoubleProperty('elevation', elevation.toDouble()));
134 135 136
  }

  @override
137
  _MergeableMaterialState createState() => _MergeableMaterialState();
138 139 140 141 142 143 144 145
}

class _AnimationTuple {
  _AnimationTuple({
    this.controller,
    this.startAnimation,
    this.endAnimation,
    this.gapAnimation,
146
    this.gapStart = 0.0,
147 148 149 150 151 152 153 154 155
  });

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

156
class _MergeableMaterialState extends State<MergeableMaterial> with TickerProviderStateMixin {
157 158
  List<MergeableMaterialItem> _children;
  final Map<LocalKey, _AnimationTuple> _animationTuples =
159
      <LocalKey, _AnimationTuple>{};
160 161 162 163

  @override
  void initState() {
    super.initState();
164
    _children = List<MergeableMaterialItem>.from(widget.children);
165 166

    for (int i = 0; i < _children.length; i += 1) {
167 168 169 170
      final MergeableMaterialItem child = _children[i];
      if (child is MaterialGap) {
        _initGap(child);
        _animationTuples[child.key].controller.value = 1.0; // Gaps are initially full-sized.
171 172 173 174 175 176
      }
    }
    assert(_debugGapsAreValid(_children));
  }

  void _initGap(MaterialGap gap) {
177
    final AnimationController controller = AnimationController(
178 179
      duration: kThemeAnimationDuration,
      vsync: this,
180 181
    );

182
    final CurvedAnimation startAnimation = CurvedAnimation(
183
      parent: controller,
184
      curve: Curves.fastOutSlowIn,
185
    );
186
    final CurvedAnimation endAnimation = CurvedAnimation(
187
      parent: controller,
188
      curve: Curves.fastOutSlowIn,
189
    );
190
    final CurvedAnimation gapAnimation = CurvedAnimation(
191
      parent: controller,
192
      curve: Curves.fastOutSlowIn,
193 194
    );

195
    controller.addListener(_handleTick);
196

197
    _animationTuples[gap.key] = _AnimationTuple(
198 199 200
      controller: controller,
      startAnimation: startAnimation,
      endAnimation: endAnimation,
201
      gapAnimation: gapAnimation,
202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220
    );
  }

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

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

257
  bool _isClosingGap(int index) {
258 259 260 261 262 263 264 265
    if (index < _children.length - 1 && _children[index] is MaterialGap) {
      return _animationTuples[_children[index].key].controller.status ==
          AnimationStatus.reverse;
    }

    return false;
  }

266 267 268 269 270 271 272 273 274 275 276 277 278 279 280
  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;
      }
    }
  }

281
  @override
282 283
  void didUpdateWidget(MergeableMaterial oldWidget) {
    super.didUpdateWidget(oldWidget);
284

285
    final Set<LocalKey> oldKeys = oldWidget.children.map<LocalKey>(
286 287
      (MergeableMaterialItem child) => child.key
    ).toSet();
288
    final Set<LocalKey> newKeys = widget.children.map<LocalKey>(
289 290 291 292 293
      (MergeableMaterialItem child) => child.key
    ).toSet();
    final Set<LocalKey> newOnly = newKeys.difference(oldKeys);
    final Set<LocalKey> oldOnly = oldKeys.difference(newKeys);

294
    final List<MergeableMaterialItem> newChildren = widget.children;
295 296 297 298 299
    int i = 0;
    int j = 0;

    assert(_debugGapsAreValid(newChildren));

300
    _removeEmptyGaps();
301 302 303 304 305 306 307 308 309 310 311 312

    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.
313
        while (oldOnly.contains(_children[j].key) || _isClosingGap(j))
314 315 316 317 318 319 320 321 322 323 324 325 326
          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) {
327 328 329
                final MergeableMaterialItem child = _children[startOld];
                if (child is MaterialGap) {
                  final MaterialGap gap = child;
330 331 332 333 334 335 336 337 338 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
                  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) {
369 370 371
                final MergeableMaterialItem newChild = newChildren[k];
                if (newChild is MaterialGap) {
                  gapSizeSum += newChild.size;
372 373 374 375 376 377
                }
              }

              // 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) {
378 379 380 381
                final MergeableMaterialItem newChild = newChildren[k];
                if (newChild is MaterialGap) {
                  _animationTuples[newChild.key].gapStart = gapSize * newChild.size / gapSizeSum;
                  _animationTuples[newChild.key].controller
382 383 384 385 386 387 388 389
                    ..value = 0.0
                    ..forward();
                }
              }
            }
          } else {
            // Grow gaps.
            for (int k = 0; k < newLength; k += 1) {
390
              final MergeableMaterialItem newChild = newChildren[startNew + k];
391

392 393 394 395
              _insertChild(startOld + k, newChild);

              if (newChild is MaterialGap) {
                _animationTuples[newChild.key].controller.forward();
396 397 398 399 400 401 402 403 404 405 406 407
              }
            }

            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) {
408 409 410
              final MergeableMaterialItem child = _children[startOld];
              if (child is MaterialGap) {
                gapSizeSum += child.size;
411 412 413 414 415 416 417
              }

              _removeChild(startOld);
              j -= 1;
            }

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

488
    if (widget.mainAxis == Axis.vertical) {
489
      return BorderRadius.vertical(
490
        top: start ? cardRadius : startRadius,
491
        bottom: end ? cardRadius : endRadius,
492 493
      );
    } else {
494
      return BorderRadius.horizontal(
495
        left: start ? cardRadius : startRadius,
496
        right: end ? cardRadius : endRadius,
497 498 499 500 501
      );
    }
  }

  double _getGapSize(int index) {
502
    final MaterialGap gap = _children[index] as MaterialGap;
503 504 505 506

    return lerpDouble(
      _animationTuples[gap.key].gapStart,
      gap.size,
507
      _animationTuples[gap.key].gapAnimation.value,
508 509 510
    );
  }

511 512 513 514 515 516
  bool _willNeedDivider(int index) {
    if (index < 0)
      return false;
    if (index >= _children.length)
      return false;
    return _children[index] is MaterialSlice || _isClosingGap(index);
517 518
  }

519 520
  @override
  Widget build(BuildContext context) {
521 522
    _removeEmptyGaps();

523 524 525 526 527 528 529 530
    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(
531 532
          Container(
            decoration: BoxDecoration(
533
              color: Theme.of(context).cardColor,
534
              borderRadius: _borderRadius(i - 1, widgets.isEmpty, false),
535
              shape: BoxShape.rectangle,
536
            ),
537
            child: ListBody(
538
              mainAxis: widget.mainAxis,
539 540
              children: slices,
            ),
541
          ),
542 543 544 545
        );
        slices = <Widget>[];

        widgets.add(
546
          SizedBox(
547
            width: widget.mainAxis == Axis.horizontal ? _getGapSize(i) : null,
548
            height: widget.mainAxis == Axis.vertical ? _getGapSize(i) : null,
549
          ),
550 551
        );
      } else {
552
        final MaterialSlice slice = _children[i] as MaterialSlice;
553 554
        Widget child = slice.child;

555
        if (widget.hasDividers) {
556 557 558 559
          final bool hasTopDivider = _willNeedDivider(i - 1);
          final bool hasBottomDivider = _willNeedDivider(i + 1);

          Border border;
560 561 562
          final BorderSide divider = Divider.createBorderSide(
            context,
            width: 0.5, // TODO(ianh): This probably looks terrible when the dpr isn't a power of two.
563 564 565
          );

          if (i == 0) {
566
            border = Border(
567 568 569
              bottom: hasBottomDivider ? divider : BorderSide.none
            );
          } else if (i == _children.length - 1) {
570
            border = Border(
571 572 573
              top: hasTopDivider ? divider : BorderSide.none
            );
          } else {
574
            border = Border(
575
              top: hasTopDivider ? divider : BorderSide.none,
576
              bottom: hasBottomDivider ? divider : BorderSide.none,
577 578 579 580 581
            );
          }

          assert(border != null);

582 583 584
          child = AnimatedContainer(
            key: _MergeableMaterialSliceKey(_children[i].key),
            decoration: BoxDecoration(border: border),
585 586
            duration: kThemeAnimationDuration,
            curve: Curves.fastOutSlowIn,
587
            child: child,
588 589
          );
        }
590 591

        slices.add(
592
          Material(
593
            type: MaterialType.transparency,
594
            child: child,
595
          ),
596 597 598 599 600 601
        );
      }
    }

    if (slices.isNotEmpty) {
      widgets.add(
602 603
        Container(
          decoration: BoxDecoration(
604
            color: Theme.of(context).cardColor,
605
            borderRadius: _borderRadius(i - 1, widgets.isEmpty, true),
606
            shape: BoxShape.rectangle,
607
          ),
608
          child: ListBody(
609
            mainAxis: widget.mainAxis,
610 611
            children: slices,
          ),
612
        ),
613 614 615 616
      );
      slices = <Widget>[];
    }

617
    return _MergeableMaterialListBody(
618 619
      mainAxis: widget.mainAxis,
      boxShadows: kElevationToShadow[widget.elevation],
620
      items: _children,
621
      children: widgets,
622 623 624 625
    );
  }
}

626
// The parent hierarchy can change and lead to the slice being
627
// rebuilt. Using a global key solves the issue.
628
class _MergeableMaterialSliceKey extends GlobalKey {
629
  const _MergeableMaterialSliceKey(this.value) : super.constructor();
630 631 632 633 634

  final LocalKey value;

  @override
  bool operator ==(dynamic other) {
635 636
    return other is _MergeableMaterialSliceKey
        && other.value == value;
637 638 639 640
  }

  @override
  int get hashCode => value.hashCode;
641 642 643 644 645

  @override
  String toString() {
    return '_MergeableMaterialSliceKey($value)';
  }
646 647
}

648 649
class _MergeableMaterialListBody extends ListBody {
  _MergeableMaterialListBody({
650
    List<Widget> children,
651
    Axis mainAxis = Axis.vertical,
652
    this.items,
653
    this.boxShadows,
654 655
  }) : super(children: children, mainAxis: mainAxis);

656 657
  final List<MergeableMaterialItem> items;
  final List<BoxShadow> boxShadows;
658

659 660 661 662
  AxisDirection _getDirection(BuildContext context) {
    return getAxisDirectionFromAxisReverseAndDirectionality(context, mainAxis, false);
  }

663
  @override
664
  RenderListBody createRenderObject(BuildContext context) {
665
    return _RenderMergeableMaterialListBody(
666 667
      axisDirection: _getDirection(context),
      boxShadows: boxShadows,
668 669 670 671
    );
  }

  @override
672
  void updateRenderObject(BuildContext context, RenderListBody renderObject) {
673
    final _RenderMergeableMaterialListBody materialRenderListBody = renderObject as _RenderMergeableMaterialListBody;
674
    materialRenderListBody
675
      ..axisDirection = _getDirection(context)
676 677 678 679
      ..boxShadows = boxShadows;
  }
}

680 681
class _RenderMergeableMaterialListBody extends RenderListBody {
  _RenderMergeableMaterialListBody({
682
    List<RenderBox> children,
683
    AxisDirection axisDirection = AxisDirection.down,
684
    this.boxShadows,
685
  }) : super(children: children, axisDirection: axisDirection);
686 687 688 689 690

  List<BoxShadow> boxShadows;

  void _paintShadows(Canvas canvas, Rect rect) {
    for (BoxShadow boxShadow in boxShadows) {
691
      final Paint paint = boxShadow.toPaint();
692 693 694 695 696 697 698 699 700 701 702 703 704 705 706
      // 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) {
707
      final ListBodyParentData childParentData = child.parentData as ListBodyParentData;
708 709 710 711 712 713 714 715 716 717 718
      final Rect rect = (childParentData.offset + offset) & child.size;
      if (i % 2 == 0)
        _paintShadows(context.canvas, rect);
      child = childParentData.nextSibling;

      i += 1;
    }

    defaultPaint(context, offset);
  }
}