mergeable_material.dart 21.8 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 'colors.dart';
11
import 'divider.dart';
12 13
import 'material.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);
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 43
    required LocalKey key,
    required this.child,
44
    this.color,
45
  }) : super(key);
46 47

  /// The contents of this slice.
48
  ///
49
  /// {@macro flutter.widgets.ProxyWidget.child}
50 51
  final Widget child;

52 53
  /// Defines the color for the slice.
  ///
54
  /// By default, the value of [color] is [ThemeData.cardColor].
55 56
  final Color? color;

57 58
  @override
  String toString() {
59
    return 'MergeableSlice(key: $key, child: $child, color: $color)';
60 61 62 63 64 65 66 67
  }
}

/// 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.
68
  const MaterialGap({
69
    required LocalKey key,
70
    this.size = 16.0,
71
  }) : super(key);
72

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

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

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

121 122 123
  /// The z-coordinate at which to place all the [Material] slices.
  ///
  /// Defaults to 2, the appropriate elevation for cards.
124
  final double elevation;
125

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

129 130
  /// Defines color used for dividers if [hasDividers] is true.
  ///
131
  /// If [dividerColor] is null, then [DividerThemeData.color] is used. If that
132
  /// is null, then [ThemeData.dividerColor] is used.
133
  final Color? dividerColor;
134

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

  @override
143
  State<MergeableMaterial> createState() => _MergeableMaterialState();
144 145 146 147
}

class _AnimationTuple {
  _AnimationTuple({
148 149 150 151
    required this.controller,
    required this.startAnimation,
    required this.endAnimation,
    required this.gapAnimation,
152 153 154 155 156 157
  });

  final AnimationController controller;
  final CurvedAnimation startAnimation;
  final CurvedAnimation endAnimation;
  final CurvedAnimation gapAnimation;
158
  double gapStart = 0.0;
159 160
}

161
class _MergeableMaterialState extends State<MergeableMaterial> with TickerProviderStateMixin {
162 163
  late List<MergeableMaterialItem> _children;
  final Map<LocalKey, _AnimationTuple?> _animationTuples = <LocalKey, _AnimationTuple?>{};
164 165 166 167

  @override
  void initState() {
    super.initState();
168
    _children = List<MergeableMaterialItem>.of(widget.children);
169 170

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

  void _initGap(MaterialGap gap) {
181
    final AnimationController controller = AnimationController(
182 183
      duration: kThemeAnimationDuration,
      vsync: this,
184 185
    );

186
    final CurvedAnimation startAnimation = CurvedAnimation(
187
      parent: controller,
188
      curve: Curves.fastOutSlowIn,
189
    );
190
    final CurvedAnimation endAnimation = CurvedAnimation(
191
      parent: controller,
192
      curve: Curves.fastOutSlowIn,
193
    );
194
    final CurvedAnimation gapAnimation = CurvedAnimation(
195
      parent: controller,
196
      curve: Curves.fastOutSlowIn,
197 198
    );

199
    controller.addListener(_handleTick);
200

201
    _animationTuples[gap.key] = _AnimationTuple(
202 203 204
      controller: controller,
      startAnimation: startAnimation,
      endAnimation: endAnimation,
205
      gapAnimation: gapAnimation,
206 207 208 209 210
    );
  }

  @override
  void dispose() {
211
    for (final MergeableMaterialItem child in _children) {
212
      if (child is MaterialGap) {
213
        _animationTuples[child.key]!.controller.dispose();
214
      }
215 216 217 218 219 220 221 222 223 224 225
    }
    super.dispose();
  }

  void _handleTick() {
    setState(() {
      // The animation's state is our build state, and it changed already.
    });
  }

  bool _debugHasConsecutiveGaps(List<MergeableMaterialItem> children) {
226 227
    for (int i = 0; i < widget.children.length - 1; i += 1) {
      if (widget.children[i] is MaterialGap &&
228
          widget.children[i + 1] is MaterialGap) {
229
        return true;
230
      }
231 232 233 234 235 236
    }
    return false;
  }

  bool _debugGapsAreValid(List<MergeableMaterialItem> children) {
    // Check for consecutive gaps.
237
    if (_debugHasConsecutiveGaps(children)) {
238
      return false;
239
    }
240 241 242

    // First and last children must not be gaps.
    if (children.isNotEmpty) {
243
      if (children.first is MaterialGap || children.last is MaterialGap) {
244
        return false;
245
      }
246 247 248 249 250 251 252 253
    }

    return true;
  }

  void _insertChild(int index, MergeableMaterialItem child) {
    _children.insert(index, child);

254
    if (child is MaterialGap) {
255
      _initGap(child);
256
    }
257 258 259
  }

  void _removeChild(int index) {
260
    final MergeableMaterialItem child = _children.removeAt(index);
261

262
    if (child is MaterialGap) {
263
      _animationTuples[child.key] = null;
264
    }
265 266
  }

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

    return false;
  }

276 277 278 279 280 281
  void _removeEmptyGaps() {
    int j = 0;

    while (j < _children.length) {
      if (
        _children[j] is MaterialGap &&
282
        _animationTuples[_children[j].key]!.controller.status == AnimationStatus.dismissed
283 284 285 286 287 288 289 290
      ) {
        _removeChild(j);
      } else {
        j += 1;
      }
    }
  }

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

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

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

    assert(_debugGapsAreValid(newChildren));

310
    _removeEmptyGaps();
311 312 313 314 315 316 317 318

    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.
319
        while (newOnly.contains(newChildren[i].key)) {
320
          i += 1;
321
        }
322 323

        // Skip old keys.
324
        while (oldOnly.contains(_children[j].key) || _isClosingGap(j)) {
325
          j += 1;
326
        }
327 328 329 330 331 332 333 334 335 336 337 338

        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) {
339 340 341
                final MergeableMaterialItem child = _children[startOld];
                if (child is MaterialGap) {
                  final MaterialGap gap = child;
342 343 344 345 346 347 348 349
                  gapSizeSum += gap.size;
                }

                _removeChild(startOld);
                j -= 1;
              }

              _insertChild(startOld, newChildren[startNew]);
350
              _animationTuples[newChildren[startNew].key]!
351 352 353 354 355 356
                ..gapStart = gapSizeSum
                ..controller.forward();

              j += 1;
            } else {
              // No animation if replaced items are more than one.
357
              for (int k = 0; k < oldLength; k += 1) {
358
                _removeChild(startOld);
359 360
              }
              for (int k = 0; k < newLength; k += 1) {
361
                _insertChild(startOld + k, newChildren[startNew + k]);
362
              }
363 364 365 366 367 368 369

              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.
370
              _animationTuples[newChildren[startNew].key]!.controller.forward();
371 372 373 374 375
            } else {
              final double gapSize = _getGapSize(startOld);

              _removeChild(startOld);

376
              for (int k = 0; k < newLength; k += 1) {
377
                _insertChild(startOld + k, newChildren[startNew + k]);
378
              }
379 380 381 382 383

              j += newLength - 1;
              double gapSizeSum = 0.0;

              for (int k = startNew; k < i; k += 1) {
384 385 386
                final MergeableMaterialItem newChild = newChildren[k];
                if (newChild is MaterialGap) {
                  gapSizeSum += newChild.size;
387 388 389 390 391 392
                }
              }

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

407 408 409
              _insertChild(startOld + k, newChild);

              if (newChild is MaterialGap) {
410
                _animationTuples[newChild.key]!.controller.forward();
411 412 413 414 415 416 417 418 419 420 421 422
              }
            }

            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) {
423 424 425
              final MergeableMaterialItem child = _children[startOld];
              if (child is MaterialGap) {
                gapSizeSum += child.size;
426 427 428 429 430 431 432
              }

              _removeChild(startOld);
              j -= 1;
            }

            if (gapSizeSum != 0.0) {
433 434
              final MaterialGap gap = MaterialGap(
                key: UniqueKey(),
435
                size: gapSizeSum,
436 437
              );
              _insertChild(startOld, gap);
438 439
              _animationTuples[gap.key]!.gapStart = 0.0;
              _animationTuples[gap.key]!.controller
440 441 442 443 444 445 446
                ..value = 1.0
                ..reverse();

              j += 1;
            }
          } else if (oldLength == 1) {
            // Shrink gap.
447
            final MaterialGap gap = _children[startOld] as MaterialGap;
448 449
            _animationTuples[gap.key]!.gapStart = 0.0;
            _animationTuples[gap.key]!.controller.reverse();
450 451 452 453
          }
        }
      } else {
        // Check whether the items are the same type. If they are, it means that
454
        // their places have been swapped.
455 456 457 458 459 460 461 462 463 464 465 466 467 468
        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.
469
    while (j < _children.length) {
470
      _removeChild(j);
471
    }
472
    while (i < newChildren.length) {
473 474 475 476 477 478
      final MergeableMaterialItem newChild = newChildren[i];
      _insertChild(j, newChild);

      if (newChild is MaterialGap) {
        _animationTuples[newChild.key]!.controller.forward();
      }
479 480 481 482 483 484 485

      i += 1;
      j += 1;
    }
  }

  BorderRadius _borderRadius(int index, bool start, bool end) {
486 487 488 489
    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;
490 491 492 493 494 495 496 497

    Radius startRadius = Radius.zero;
    Radius endRadius = Radius.zero;

    if (index > 0 && _children[index - 1] is MaterialGap) {
      startRadius = Radius.lerp(
        Radius.zero,
        cardRadius,
498 499
        _animationTuples[_children[index - 1].key]!.startAnimation.value,
      )!;
500 501 502 503 504
    }
    if (index < _children.length - 2 && _children[index + 1] is MaterialGap) {
      endRadius = Radius.lerp(
        Radius.zero,
        cardRadius,
505 506
        _animationTuples[_children[index + 1].key]!.endAnimation.value,
      )!;
507 508
    }

509
    if (widget.mainAxis == Axis.vertical) {
510
      return BorderRadius.vertical(
511
        top: start ? cardRadius : startRadius,
512
        bottom: end ? cardRadius : endRadius,
513 514
      );
    } else {
515
      return BorderRadius.horizontal(
516
        left: start ? cardRadius : startRadius,
517
        right: end ? cardRadius : endRadius,
518 519 520 521 522
      );
    }
  }

  double _getGapSize(int index) {
523
    final MaterialGap gap = _children[index] as MaterialGap;
524 525

    return lerpDouble(
526
      _animationTuples[gap.key]!.gapStart,
527
      gap.size,
528 529
      _animationTuples[gap.key]!.gapAnimation.value,
    )!;
530 531
  }

532
  bool _willNeedDivider(int index) {
533
    if (index < 0) {
534
      return false;
535 536
    }
    if (index >= _children.length) {
537
      return false;
538
    }
539
    return _children[index] is MaterialSlice || _isClosingGap(index);
540 541
  }

542 543
  @override
  Widget build(BuildContext context) {
544 545
    _removeEmptyGaps();

546 547 548 549 550 551 552 553
    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(
554 555 556
          ListBody(
            mainAxis: widget.mainAxis,
            children: slices,
557
          ),
558 559 560 561
        );
        slices = <Widget>[];

        widgets.add(
562
          SizedBox(
563
            width: widget.mainAxis == Axis.horizontal ? _getGapSize(i) : null,
564
            height: widget.mainAxis == Axis.vertical ? _getGapSize(i) : null,
565
          ),
566 567
        );
      } else {
568
        final MaterialSlice slice = _children[i] as MaterialSlice;
569 570
        Widget child = slice.child;

571
        if (widget.hasDividers) {
572 573 574
          final bool hasTopDivider = _willNeedDivider(i - 1);
          final bool hasBottomDivider = _willNeedDivider(i + 1);

575 576 577
          final BorderSide divider = Divider.createBorderSide(
            context,
            width: 0.5, // TODO(ianh): This probably looks terrible when the dpr isn't a power of two.
578
            color: widget.dividerColor,
579 580
          );

581
          final Border border;
582
          if (i == 0) {
583
            border = Border(
584
              bottom: hasBottomDivider ? divider : BorderSide.none,
585 586
            );
          } else if (i == _children.length - 1) {
587
            border = Border(
588
              top: hasTopDivider ? divider : BorderSide.none,
589 590
            );
          } else {
591
            border = Border(
592
              top: hasTopDivider ? divider : BorderSide.none,
593
              bottom: hasBottomDivider ? divider : BorderSide.none,
594 595 596
            );
          }

597
          child = AnimatedContainer(
598
            key: _MergeableMaterialSliceKey(_children[i].key),
599
            decoration: BoxDecoration(border: border),
600 601
            duration: kThemeAnimationDuration,
            curve: Curves.fastOutSlowIn,
602
            child: child,
603 604
          );
        }
605 606

        slices.add(
607 608 609 610 611 612 613 614 615
          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,
            ),
616
          ),
617 618 619 620 621 622
        );
      }
    }

    if (slices.isNotEmpty) {
      widgets.add(
623 624 625
        ListBody(
          mainAxis: widget.mainAxis,
          children: slices,
626
        ),
627 628 629 630
      );
      slices = <Widget>[];
    }

631
    return _MergeableMaterialListBody(
632
      mainAxis: widget.mainAxis,
633
      elevation: widget.elevation,
634
      items: _children,
635
      children: widgets,
636 637 638 639
    );
  }
}

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

  final LocalKey value;

  @override
648
  bool operator ==(Object other) {
649 650
    return other is _MergeableMaterialSliceKey
        && other.value == value;
651 652 653 654
  }

  @override
  int get hashCode => value.hashCode;
655 656 657 658 659

  @override
  String toString() {
    return '_MergeableMaterialSliceKey($value)';
  }
660 661
}

662
class _MergeableMaterialListBody extends ListBody {
663
  const _MergeableMaterialListBody({
664 665
    required super.children,
    super.mainAxis,
666
    required this.items,
667
    required this.elevation,
668
  });
669

670
  final List<MergeableMaterialItem> items;
671
  final double elevation;
672

673 674 675 676
  AxisDirection _getDirection(BuildContext context) {
    return getAxisDirectionFromAxisReverseAndDirectionality(context, mainAxis, false);
  }

677
  @override
678
  RenderListBody createRenderObject(BuildContext context) {
679
    return _RenderMergeableMaterialListBody(
680
      axisDirection: _getDirection(context),
681
      elevation: elevation,
682 683 684 685
    );
  }

  @override
686
  void updateRenderObject(BuildContext context, RenderListBody renderObject) {
687
    final _RenderMergeableMaterialListBody materialRenderListBody = renderObject as _RenderMergeableMaterialListBody;
688
    materialRenderListBody
689
      ..axisDirection = _getDirection(context)
690
      ..elevation = elevation;
691 692 693
  }
}

694 695
class _RenderMergeableMaterialListBody extends RenderListBody {
  _RenderMergeableMaterialListBody({
696
    super.axisDirection,
697
    double elevation = 0.0,
698
  }) : _elevation = elevation;
699 700 701 702

  double get elevation => _elevation;
  double _elevation;
  set elevation(double value) {
703
    if (value == _elevation) {
704
      return;
705
    }
706 707 708
    _elevation = value;
    markNeedsPaint();
  }
709 710

  void _paintShadows(Canvas canvas, Rect rect) {
711
    // TODO(ianh): We should interpolate the border radii of the shadows the same way we do those of the visible Material slices.
712 713 714 715 716 717 718 719
    if (elevation != 0) {
      canvas.drawShadow(
        Path()..addRRect(kMaterialEdges[MaterialType.card]!.toRRect(rect)),
        Colors.black,
        elevation,
        true, // occluding object is not (necessarily) opaque
      );
    }
720 721 722 723
  }

  @override
  void paint(PaintingContext context, Offset offset) {
724
    RenderBox? child = firstChild;
725
    int index = 0;
726
    while (child != null) {
727
      final ListBodyParentData childParentData = child.parentData! as ListBodyParentData;
728
      final Rect rect = (childParentData.offset + offset) & child.size;
729
      if (index.isEven) {
730
        _paintShadows(context.canvas, rect);
731
      }
732
      child = childParentData.nextSibling;
733
      index += 1;
734 735 736 737
    }
    defaultPaint(context, offset);
  }
}