custom_layout.dart 16 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
import 'package:flutter/foundation.dart';
Adam Barth's avatar
Adam Barth committed
6

7 8 9
import 'box.dart';
import 'object.dart';

10
// For SingleChildLayoutDelegate and RenderCustomSingleChildLayoutBox, see shifted_box.dart
Hixie's avatar
Hixie committed
11

12
/// [ParentData] used by [RenderCustomMultiChildLayoutBox].
13
class MultiChildLayoutParentData extends ContainerBoxParentData<RenderBox> {
14
  /// An object representing the identity of this child.
15
  Object? id;
Adam Barth's avatar
Adam Barth committed
16

17
  @override
Adam Barth's avatar
Adam Barth committed
18
  String toString() => '${super.toString()}; id=$id';
19
}
20

21
/// A delegate that controls the layout of multiple children.
22
///
23 24 25
/// Used with [CustomMultiChildLayout] (in the widgets library) and
/// [RenderCustomMultiChildLayoutBox] (in the rendering library).
///
26 27 28 29 30 31
/// Delegates must be idempotent. Specifically, if two delegates are equal, then
/// they must produce the same layout. To change the layout, replace the
/// delegate with a different instance whose [shouldRelayout] returns true when
/// given the previous instance.
///
/// Override [getSize] to control the overall size of the layout. The size of
32 33 34 35 36 37 38 39 40
/// the layout cannot depend on layout properties of the children. This was
/// a design decision to simplify the delegate implementations: This way,
/// the delegate implementations do not have to also handle various intrinsic
/// sizing functions if the parent's size depended on the children.
/// If you want to build a custom layout where you define the size of that widget
/// based on its children, then you will have to create a custom render object.
/// See [MultiChildRenderObjectWidget] with [ContainerRenderObjectMixin] and
/// [RenderBoxContainerDefaultsMixin] to get started or [RenderStack] for an
/// example implementation.
41 42 43 44 45 46 47 48 49 50 51
///
/// Override [performLayout] to size and position the children. An
/// implementation of [performLayout] must call [layoutChild] exactly once for
/// each child, but it may call [layoutChild] on children in an arbitrary order.
/// Typically a delegate will use the size returned from [layoutChild] on one
/// child to determine the constraints for [performLayout] on another child or
/// to determine the offset for [positionChild] for that child or another child.
///
/// Override [shouldRelayout] to determine when the layout of the children needs
/// to be recomputed when the delegate changes.
///
52 53 54 55 56
/// The most efficient way to trigger a relayout is to supply a `relayout`
/// argument to the constructor of the [MultiChildLayoutDelegate]. The custom
/// layout will listen to this value and relayout whenever the Listenable
/// notifies its listeners, such as when an [Animation] ticks. This allows
/// the custom layout to avoid the build phase of the pipeline.
57
///
58 59 60 61
/// Each child must be wrapped in a [LayoutId] widget to assign the id that
/// identifies it to the delegate. The [LayoutId.id] needs to be unique among
/// the children that the [CustomMultiChildLayout] manages.
///
62
/// {@tool snippet}
63 64
///
/// Below is an example implementation of [performLayout] that causes one widget
65
/// (the follower) to be the same size as another (the leader):
66 67
///
/// ```dart
68 69 70 71 72 73 74
/// // Define your own slot numbers, depending upon the id assigned by LayoutId.
/// // Typical usage is to define an enum like the one below, and use those
/// // values as the ids.
/// enum _Slot {
///   leader,
///   follower,
/// }
75
///
76 77 78 79
/// class FollowTheLeader extends MultiChildLayoutDelegate {
///   @override
///   void performLayout(Size size) {
///     Size leaderSize = Size.zero;
80
///
81
///     if (hasChild(_Slot.leader)) {
82
///       leaderSize = layoutChild(_Slot.leader, BoxConstraints.loose(size));
83 84
///       positionChild(_Slot.leader, Offset.zero);
///     }
85
///
86
///     if (hasChild(_Slot.follower)) {
87 88
///       layoutChild(_Slot.follower, BoxConstraints.tight(leaderSize));
///       positionChild(_Slot.follower, Offset(size.width - leaderSize.width,
89 90
///           size.height - leaderSize.height));
///     }
91
///   }
92
///
93 94
///   @override
///   bool shouldRelayout(MultiChildLayoutDelegate oldDelegate) => false;
95 96
/// }
/// ```
97
/// {@end-tool}
98 99 100 101 102 103 104 105 106 107 108 109 110
///
/// The delegate gives the leader widget loose constraints, which means the
/// child determines what size to be (subject to fitting within the given size).
/// The delegate then remembers the size of that child and places it in the
/// upper left corner.
///
/// The delegate then gives the follower widget tight constraints, forcing it to
/// match the size of the leader widget. The delegate then places the follower
/// widget in the bottom right corner.
///
/// The leader and follower widget will paint in the order they appear in the
/// child list, regardless of the order in which [layoutChild] is called on
/// them.
111 112 113 114 115 116
///
/// See also:
///
///  * [CustomMultiChildLayout], the widget that uses this delegate.
///  * [RenderCustomMultiChildLayoutBox], render object that uses this
///    delegate.
117
abstract class MultiChildLayoutDelegate {
118 119 120
  /// Creates a layout delegate.
  ///
  /// The layout will update whenever [relayout] notifies its listeners.
121
  MultiChildLayoutDelegate({ Listenable? relayout }) : _relayout = relayout;
122

123
  final Listenable? _relayout;
124

125 126
  Map<Object, RenderBox>? _idToChild;
  Set<RenderBox>? _debugChildrenNeedingLayout;
127

128
  /// True if a non-null LayoutChild was provided for the specified id.
129 130 131 132
  ///
  /// Call this from the [performLayout] or [getSize] methods to
  /// determine which children are available, if the child list might
  /// vary.
133
  bool hasChild(Object childId) => _idToChild![childId] != null;
134

135 136
  /// Ask the child to update its layout within the limits specified by
  /// the constraints parameter. The child's size is returned.
137 138 139
  ///
  /// Call this from your [performLayout] function to lay out each
  /// child. Every child must be laid out using this function exactly
140
  /// once each time the [performLayout] function is called.
141 142
  Size layoutChild(Object childId, BoxConstraints constraints) {
    final RenderBox? child = _idToChild![childId];
143
    assert(() {
Hixie's avatar
Hixie committed
144
      if (child == null) {
145 146 147 148
        throw FlutterError(
          'The $this custom multichild layout delegate tried to lay out a non-existent child.\n'
          'There is no child with the id "$childId".'
        );
Hixie's avatar
Hixie committed
149
      }
150
      if (!_debugChildrenNeedingLayout!.remove(child)) {
151 152 153 154
        throw FlutterError(
          'The $this custom multichild layout delegate tried to lay out the child with id "$childId" more than once.\n'
          'Each child must be laid out exactly once.'
        );
Hixie's avatar
Hixie committed
155 156
      }
      try {
157
        assert(constraints.debugAssertIsValid(isAppliedConstraint: true));
Hixie's avatar
Hixie committed
158
      } on AssertionError catch (exception) {
159 160 161 162 163 164 165 166 167
        throw FlutterError.fromParts(<DiagnosticsNode>[
          ErrorSummary('The $this custom multichild layout delegate provided invalid box constraints for the child with id "$childId".'),
          DiagnosticsProperty<AssertionError>('Exception', exception, showName: false),
          ErrorDescription(
            'The minimum width and height must be greater than or equal to zero.\n'
            'The maximum width must be greater than or equal to the minimum width.\n'
            'The maximum height must be greater than or equal to the minimum height.'
          )
        ]);
Hixie's avatar
Hixie committed
168 169
      }
      return true;
170
    }());
171
    child!.layout(constraints, parentUsesSize: true);
172 173 174 175
    return child.size;
  }

  /// Specify the child's origin relative to this origin.
176 177 178 179 180
  ///
  /// Call this from your [performLayout] function to position each
  /// child. If you do not call this for a child, its position will
  /// remain unchanged. Children initially have their position set to
  /// (0,0), i.e. the top left of the [RenderCustomMultiChildLayoutBox].
181
  void positionChild(Object childId, Offset offset) {
182
    final RenderBox? child = _idToChild![childId];
Hixie's avatar
Hixie committed
183 184
    assert(() {
      if (child == null) {
185 186 187 188
        throw FlutterError(
          'The $this custom multichild layout delegate tried to position out a non-existent child:\n'
          'There is no child with the id "$childId".'
        );
Hixie's avatar
Hixie committed
189
      }
190 191 192 193
      // `offset` has a non-nullable return type, but might be null when
      // running with weak checking, so we need to null check it anyway (and
      // ignore the warning that the null-handling logic is dead code).
      if (offset == null) { // ignore: dead_code
194 195 196
        throw FlutterError(
          'The $this custom multichild layout delegate provided a null position for the child with id "$childId".'
        );
Hixie's avatar
Hixie committed
197 198
      }
      return true;
199
    }());
200
    final MultiChildLayoutParentData childParentData = child!.parentData! as MultiChildLayoutParentData;
201
    childParentData.offset = offset;
202 203
  }

204
  DiagnosticsNode _debugDescribeChild(RenderBox child) {
205
    final MultiChildLayoutParentData childParentData = child.parentData! as MultiChildLayoutParentData;
206
    return DiagnosticsProperty<RenderBox>('${childParentData.id}', child);
Hixie's avatar
Hixie committed
207 208
  }

209
  void _callPerformLayout(Size size, RenderBox? firstChild) {
210 211 212
    // A particular layout delegate could be called reentrantly, e.g. if it used
    // by both a parent and a child. So, we must restore the _idToChild map when
    // we return.
213
    final Map<Object, RenderBox>? previousIdToChild = _idToChild;
214

215
    Set<RenderBox>? debugPreviousChildrenNeedingLayout;
216 217
    assert(() {
      debugPreviousChildrenNeedingLayout = _debugChildrenNeedingLayout;
218
      _debugChildrenNeedingLayout = <RenderBox>{};
219
      return true;
220
    }());
221

222
    try {
223
      _idToChild = <Object, RenderBox>{};
224
      RenderBox? child = firstChild;
225
      while (child != null) {
226
        final MultiChildLayoutParentData childParentData = child.parentData! as MultiChildLayoutParentData;
Hixie's avatar
Hixie committed
227 228
        assert(() {
          if (childParentData.id == null) {
229 230
            throw FlutterError.fromParts(<DiagnosticsNode>[
              ErrorSummary('Every child of a RenderCustomMultiChildLayoutBox must have an ID in its parent data.'),
231
              child!.describeForError('The following child has no ID'),
232
            ]);
Hixie's avatar
Hixie committed
233 234
          }
          return true;
235
        }());
236
        _idToChild![childParentData.id!] = child;
237
        assert(() {
238
          _debugChildrenNeedingLayout!.add(child!);
239
          return true;
240
        }());
241 242
        child = childParentData.nextSibling;
      }
243
      performLayout(size);
244
      assert(() {
245
        if (_debugChildrenNeedingLayout!.isNotEmpty) {
246 247 248 249 250 251
          throw FlutterError.fromParts(<DiagnosticsNode>[
          ErrorSummary('Each child must be laid out exactly once.'),
            DiagnosticsBlock(
              name:
                'The $this custom multichild layout delegate forgot '
                'to lay out the following '
252 253
                '${_debugChildrenNeedingLayout!.length > 1 ? 'children' : 'child'}',
              properties: _debugChildrenNeedingLayout!.map<DiagnosticsNode>(_debugDescribeChild).toList(),
254 255 256
              style: DiagnosticsTreeStyle.whitespace,
            ),
          ]);
Hixie's avatar
Hixie committed
257 258
        }
        return true;
259
      }());
260 261
    } finally {
      _idToChild = previousIdToChild;
262 263 264
      assert(() {
        _debugChildrenNeedingLayout = debugPreviousChildrenNeedingLayout;
        return true;
265
      }());
266 267 268
    }
  }

269
  /// Override this method to return the size of this object given the
270 271 272 273
  /// incoming constraints.
  ///
  /// The size cannot reflect the sizes of the children. If this layout has a
  /// fixed width or height the returned size can reflect that; the size will be
274 275 276 277 278
  /// constrained to the given constraints.
  ///
  /// By default, attempts to size the box to the biggest size
  /// possible given the constraints.
  Size getSize(BoxConstraints constraints) => constraints.biggest;
279

280
  /// Override this method to lay out and position all children given this
281 282 283 284
  /// widget's size.
  ///
  /// This method must call [layoutChild] for each child. It should also specify
  /// the final position of each child with [positionChild].
285
  void performLayout(Size size);
Hixie's avatar
Hixie committed
286

287
  /// Override this method to return true when the children need to be
288 289 290 291 292
  /// laid out.
  ///
  /// This should compare the fields of the current delegate and the given
  /// `oldDelegate` and return true if the fields are such that the layout would
  /// be different.
293
  bool shouldRelayout(covariant MultiChildLayoutDelegate oldDelegate);
294 295 296 297 298

  /// Override this method to include additional information in the
  /// debugging data printed by [debugDumpRenderTree] and friends.
  ///
  /// By default, returns the [runtimeType] of the class.
299
  @override
300
  String toString() => objectRuntimeType(this, 'MultiChildLayoutDelegate');
301 302
}

303 304 305 306 307 308
/// Defers the layout of multiple children to a delegate.
///
/// The delegate can determine the layout constraints for each child and can
/// decide where to position each child. The delegate can also determine the
/// size of the parent, but the size of the parent cannot depend on the sizes of
/// the children.
309
class RenderCustomMultiChildLayoutBox extends RenderBox
310 311
  with ContainerRenderObjectMixin<RenderBox, MultiChildLayoutParentData>,
       RenderBoxContainerDefaultsMixin<RenderBox, MultiChildLayoutParentData> {
312 313 314
  /// Creates a render object that customizes the layout of multiple children.
  ///
  /// The [delegate] argument must not be null.
315
  RenderCustomMultiChildLayoutBox({
316 317
    List<RenderBox>? children,
    required MultiChildLayoutDelegate delegate,
318 319
  }) : assert(delegate != null),
       _delegate = delegate {
320 321 322
    addAll(children);
  }

323
  @override
324
  void setupParentData(RenderBox child) {
325
    if (child.parentData is! MultiChildLayoutParentData)
326
      child.parentData = MultiChildLayoutParentData();
327 328
  }

329
  /// The delegate that controls the layout of the children.
330 331
  MultiChildLayoutDelegate get delegate => _delegate;
  MultiChildLayoutDelegate _delegate;
332 333 334
  set delegate(MultiChildLayoutDelegate newDelegate) {
    assert(newDelegate != null);
    if (_delegate == newDelegate)
335
      return;
336 337
    final MultiChildLayoutDelegate oldDelegate = _delegate;
    if (newDelegate.runtimeType != oldDelegate.runtimeType || newDelegate.shouldRelayout(oldDelegate))
338
      markNeedsLayout();
339 340
    _delegate = newDelegate;
    if (attached) {
341 342
      oldDelegate._relayout?.removeListener(markNeedsLayout);
      newDelegate._relayout?.addListener(markNeedsLayout);
343 344 345 346 347 348
    }
  }

  @override
  void attach(PipelineOwner owner) {
    super.attach(owner);
349
    _delegate._relayout?.addListener(markNeedsLayout);
350 351 352 353
  }

  @override
  void detach() {
354
    _delegate._relayout?.removeListener(markNeedsLayout);
355
    super.detach();
356 357 358
  }

  Size _getSize(BoxConstraints constraints) {
359
    assert(constraints.debugAssertIsValid());
360 361 362
    return constraints.constrain(_delegate.getSize(constraints));
  }

363 364 365 366
  // TODO(ianh): It's a bit dubious to be using the getSize function from the delegate to
  // figure out the intrinsic dimensions. We really should either not support intrinsics,
  // or we should expose intrinsic delegate callbacks and throw if they're not implemented.

367
  @override
368
  double computeMinIntrinsicWidth(double height) {
369
    final double width = _getSize(BoxConstraints.tightForFinite(height: height)).width;
370 371 372
    if (width.isFinite)
      return width;
    return 0.0;
373 374
  }

375
  @override
376
  double computeMaxIntrinsicWidth(double height) {
377
    final double width = _getSize(BoxConstraints.tightForFinite(height: height)).width;
378 379 380
    if (width.isFinite)
      return width;
    return 0.0;
381 382
  }

383
  @override
384
  double computeMinIntrinsicHeight(double width) {
385
    final double height = _getSize(BoxConstraints.tightForFinite(width: width)).height;
386 387 388
    if (height.isFinite)
      return height;
    return 0.0;
389 390
  }

391
  @override
392
  double computeMaxIntrinsicHeight(double width) {
393
    final double height = _getSize(BoxConstraints.tightForFinite(width: width)).height;
394 395 396
    if (height.isFinite)
      return height;
    return 0.0;
397 398
  }

399 400 401 402 403
  @override
  Size computeDryLayout(BoxConstraints constraints) {
    return _getSize(constraints);
  }

404
  @override
405
  void performLayout() {
406
    size = _getSize(constraints);
407
    delegate._callPerformLayout(size, firstChild);
408 409
  }

410
  @override
411 412 413 414
  void paint(PaintingContext context, Offset offset) {
    defaultPaint(context, offset);
  }

415
  @override
416
  bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
Adam Barth's avatar
Adam Barth committed
417
    return defaultHitTestChildren(result, position: position);
418 419
  }
}