diff --git a/.github/workflows/pr_validation.yml b/.github/workflows/pr_validation.yml index 66bc3c3..2e53696 100644 --- a/.github/workflows/pr_validation.yml +++ b/.github/workflows/pr_validation.yml @@ -1,7 +1,7 @@ name: Run analysis and tests for pull requests on: [pull_request] jobs: - test: + analysis: runs-on: ubuntu-latest steps: # Checkout the PR branch @@ -18,6 +18,20 @@ jobs: # Static analysis - run: flutter analyze + test: + runs-on: ubuntu-latest + steps: + # Checkout the PR branch + - uses: actions/checkout@v3 + + # Setup Flutter environment + - uses: subosito/flutter-action@v2 + with: + channel: "stable" + + # Download all the packages that the app uses + - run: flutter pub get + # Run all tests - run: flutter test @@ -36,8 +50,5 @@ jobs: # Download all the packages that the app uses - run: flutter pub get - # Static analysis - - run: flutter analyze - # Run all tests - run: flutter test test_goldens diff --git a/lib/follow_the_leader.dart b/lib/follow_the_leader.dart index 7d1537c..2be5ed0 100644 --- a/lib/follow_the_leader.dart +++ b/lib/follow_the_leader.dart @@ -1,8 +1,11 @@ library follow_the_leader; export 'src/build_in_order.dart'; +export 'src/content_layers.dart'; export 'src/follower.dart'; export 'src/follower_extensions.dart'; export 'src/leader_link.dart'; export 'src/leader.dart'; export 'src/logging.dart'; +export 'src/render_sliver_ext.dart'; +export 'src/sliver_hybrid_stack.dart'; diff --git a/lib/src/content_layers.dart b/lib/src/content_layers.dart new file mode 100644 index 0000000..b082b47 --- /dev/null +++ b/lib/src/content_layers.dart @@ -0,0 +1,985 @@ +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:logging/logging.dart'; + +/// Widget that displays [content] above a number of [underlays], and beneath a number of +/// [overlays]. +/// +/// This widget is similar in behavior to a `Stack`, except this widget alters the build +/// and layout order to support use-cases where various layers depend upon the layout of +/// a single [content] layer. +/// +/// This widget is useful for use-cases where decorations need to be positioned relative +/// to content within the [content] widget. For example, this [ContentLayers] might be +/// used to display a document as [content] and then display text selection as an +/// underlay, the caret as an overlay, and user comments as another overlay. +/// +/// The layers are sized to be exactly the same as the [content], and the layers are +/// positioned at the same (x,y) as [content]. +/// +/// The layers are built after [content] is laid out, so that the layers can inspect the +/// [content] layout during the layers' build phase. This makes it easy, for example, to +/// position a caret on top of a document, using only the widget tree. +class ContentLayers extends RenderObjectWidget { + const ContentLayers({ + super.key, + this.underlays = const [], + required this.content, + this.overlays = const [], + }); + + /// Layers displayed beneath the [content]. + /// + /// These layers are placed at the same (x,y) as [content], and they're forced to layout + /// at the exact same size as [content]. + /// + /// {@template layers_as_builders} + /// Layers are structured as [WidgetBuilder]s so that they can be re-built whenever + /// the content layout changes, without interference from Flutter's standard build system. + /// Ideally, layers would be pure [Widget]s, but this is a consequence of how Flutter's + /// [BuildOwner] works. For more details, see https://github.com/flutter/flutter/issues/123305 + /// and https://github.com/superlistapp/super_editor/pull/1239 + /// {@endtemplate} + final List underlays; + + /// The primary content displayed in this widget, which determines the size and location + /// of all [underlays] and [overlays]. + final Widget Function(VoidCallback onBuildScheduled) content; + + /// Layers displayed above the [content]. + /// + /// These layers are placed at the same (x,y) as [content], and they're forced to layout + /// at the exact same size as [content]. + /// + /// {@macro layers_as_builders} + final List overlays; + + @override + RenderObjectElement createElement() { + return ContentLayersElement(this); + } + + @override + RenderContentLayers createRenderObject(BuildContext context) { + return RenderContentLayers(context as ContentLayersElement); + } +} + +/// `Element` for a [ContentLayers] widget. +/// +/// Must have a [renderObject] of type [RenderContentLayers]. +class ContentLayersElement extends RenderObjectElement { + /// The real Flutter framework `onBuildScheduled` callback. + /// + /// This property is non-null when one or more [ContentLayersElement]s are in the + /// tree, and `null` otherwise. + /// + /// This callback is held statically, rather than per-instance, because Flutter + /// might activate a new [ContentLayersElement] before deactivating an old + /// [ContentLayersElement], or there might be multiple [ContentLayersElement]s + /// in the tree. In these cases, we can't consistently replace Flutter's + /// `onBuildScheduled` callback without losing the original callback. + static VoidCallback? _realOnBuildScheduled; + + /// Listeners that are registered by [ContentLayersElement]s to find out when + /// the Flutter framework schedules builds, so that [ContentLayerElement]s can + /// manage their layers to avoid invalid build timing. + static final _onBuildListeners = {}; + + /// The Flutter framework has scheduled a build by calling `onBuildScheduled` + /// on a [BuildOwner]. + /// + /// This global static method calls build schedule listeners on all instances + /// of [ContentLayersElement], which registered a listener with [_onBuildListeners]. + static void _globalOnBuildScheduled() { + // Call the real Flutter onBuildScheduled callback so Flutter works as expected. + _realOnBuildScheduled!(); + + for (final listener in _onBuildListeners) { + listener(); + } + } + + ContentLayersElement(ContentLayers widget) : super(widget); + + List _underlays = []; + Element? _content; + List _overlays = []; + + @override + ContentLayers get widget => super.widget as ContentLayers; + + @override + RenderContentLayers get renderObject => super.renderObject as RenderContentLayers; + + @override + void mount(Element? parent, Object? newSlot) { + FtlLogs.contentLayers.fine("ContentLayersElement - mounting"); + super.mount(parent, newSlot); + + // Intercept calls to the BuildOwner's onBuildScheduled so that we can hijack an + // opportunity to check our subtrees for dirty elements before they rebuild. + if (_realOnBuildScheduled == null) { + _realOnBuildScheduled = owner!.onBuildScheduled!; + owner!.onBuildScheduled = _globalOnBuildScheduled; + _onBuildListeners.add(_onBuildScheduled); + } + + _content = inflateWidget(widget.content(_onContentBuildScheduled), _contentSlot); + } + + @override + void activate() { + FtlLogs.contentLayers.fine("ContentLayersElement - activating"); + super.activate(); + } + + @override + void deactivate() { + FtlLogs.contentLayers.fine("ContentLayersElement - deactivating"); + // We have to deactivate the underlays and overlays ourselves, because we + // intentionally don't visit them in visitChildren(). + for (final underlay in _underlays) { + deactivateChild(underlay); + } + _underlays = const []; + + for (final overlay in _overlays) { + deactivateChild(overlay); + } + _overlays = const []; + + super.deactivate(); + } + + @override + void unmount() { + FtlLogs.contentLayers.fine("ContentLayersElement - unmounting"); + + // Remove our intercepting onBuildScheduled callback. + _onBuildListeners.remove(_onBuildScheduled); + if (_onBuildListeners.isEmpty) { + owner!.onBuildScheduled = _realOnBuildScheduled; + } + + super.unmount(); + } + + void _onBuildScheduled() { + FtlLogs.contentLayers.finer("ON BUILD SCHEDULED"); + + // Schedule a callback to run at the beginning of the next frame so we can check + // for dirty subtrees. + // + // If the content is dirty, but the layers are clean, then the layers won't attempt + // to rebuild, and we can let Flutter build the content whenever it wants. + // + // If a layer is dirty, but the content is clean, then the content layout is still + // valid, and we can let Flutter build the layer whenever it wants. + // + // However, if both the content and at least one layer are both dirty, then we must + // make absolutely sure that the content builds first. To do this, we deactivate the + // layer Elements, preventing Flutter from rebuilding them, and then we reactivate + // the layers during the next layout pass, after the content is laid out. + SchedulerBinding.instance.scheduleFrameCallback((timeStamp) { + FtlLogs.contentLayers.finer("SCHEDULED FRAME CALLBACK"); + if (!mounted) { + FtlLogs.contentLayers.finer("We've unmounted since the end of the frame. Fizzling."); + return; + } + + final isContentDirty = _isContentDirty(); + final isAnyLayerDirty = _isAnyLayerDirty(); + + if (isContentDirty && isAnyLayerDirty) { + FtlLogs.contentLayers.fine("Marking needs build because content and at least one layer are both dirty."); + _temporarilyForgetLayers(); + } + }); + } + + bool _isContentDirty() => _isSubtreeDirty(_content!); + + bool _isAnyLayerDirty() { + FtlLogs.contentLayers.finer("Checking if any layer is dirty"); + bool hasDirtyElements = false; + + FtlLogs.contentLayers.finer("Checking underlays"); + for (final underlay in _underlays) { + FtlLogs.contentLayers.finer(() => " - Is underlay ($underlay) subtree dirty? ${_isSubtreeDirty(underlay)}"); + hasDirtyElements = hasDirtyElements || _isSubtreeDirty(underlay); + } + + FtlLogs.contentLayers.finer("Checking overlays"); + for (final overlay in _overlays) { + FtlLogs.contentLayers.finer(() => " - Is overlay ($overlay) subtree dirty? ${_isSubtreeDirty(overlay)}"); + hasDirtyElements = hasDirtyElements || _isSubtreeDirty(overlay); + } + + return hasDirtyElements; + } + + static bool _isDirty = false; + + bool _isSubtreeDirty(Element element) { + _isDirty = false; + element.visitChildren(_isSubtreeDirtyVisitor); + return _isDirty; + } + +// This is intentionally static to prevent closure allocation during + // the traversal of the element tree. + static void _isSubtreeDirtyVisitor(Element element) { + // Can't use the () => message syntax because it allocates a closure. + assert(() { + if (FtlLogs.contentLayers.isLoggable(Level.FINEST)) { + FtlLogs.contentLayers.finest("Finding dirty children for: $element"); + } + return true; + }()); + if (element.dirty) { + assert(() { + if (FtlLogs.contentLayers.isLoggable(Level.FINEST)) { + FtlLogs.contentLayers.finest("Found a dirty child: $element"); + } + return true; + }()); + _isDirty = true; + return; + } + element.visitChildren(_isSubtreeDirtyVisitor); + } + + void _onContentBuildScheduled() { + _temporarilyForgetLayers(); + } + + @override + void markNeedsBuild() { + FtlLogs.contentLayers.finer("ContentLayersElement - marking needs build"); + super.markNeedsBuild(); + } + + void buildLayers() { + FtlLogs.contentLayers.finer("ContentLayersElement - (re)building layers"); + + owner!.buildScope(this, () { + final List underlays = List.filled(widget.underlays.length, _NullElement.instance); + for (int i = 0; i < underlays.length; i += 1) { + late final Element child; + if (i > _underlays.length - 1) { + child = inflateWidget(widget.underlays[i](this), _UnderlaySlot(i)); + } else { + child = super.updateChild(_underlays[i], widget.underlays[i](this), _UnderlaySlot(i))!; + } + underlays[i] = child; + } + _underlays = underlays; + + final List overlays = List.filled(widget.overlays.length, _NullElement.instance); + for (int i = 0; i < overlays.length; i += 1) { + late final Element child; + if (i > _overlays.length - 1) { + child = inflateWidget(widget.overlays[i](this), _OverlaySlot(i)); + } else { + child = super.updateChild(_overlays[i], widget.overlays[i](this), _OverlaySlot(i))!; + } + overlays[i] = child; + } + _overlays = overlays; + }); + } + + @override + Element inflateWidget(Widget newWidget, Object? newSlot) { + final Element newChild = super.inflateWidget(newWidget, newSlot); + + assert(_debugCheckHasAssociatedRenderObject(newChild)); + + return newChild; + } + + /// Forgets the overlay and underlay children so that they don't run build at a + /// problematic time, but the same layers can be brought back later, with retained + /// `Element` and `State` objects. + /// + /// Note: If the layers are deactivated, rather than forgotten, new `Element`s and + /// `State`s will be created on every build, which prevents layer `State` objects + /// from retaining information across builds, thus defeating the purpose of using + /// a `StatefulWidget`. + void _temporarilyForgetLayers() { + FtlLogs.contentLayers.finer("ContentLayersElement - temporarily forgetting layers"); + for (final underlay in _underlays) { + forgetChild(underlay); + } + + for (final overlay in _overlays) { + forgetChild(overlay); + } + } + + @override + void update(ContentLayers newWidget) { + super.update(newWidget); + + final newContent = widget.content(_onContentBuildScheduled); + + assert(widget == newWidget); + assert(!debugChildrenHaveDuplicateKeys(widget, [newContent])); + + _content = updateChild(_content, newContent, _contentSlot); + } + + @override + Element? updateChild(Element? child, Widget? newWidget, Object? newSlot) { + if (newSlot != _contentSlot) { + // Never update underlays or overlays because they MUST only build during + // layout. + return null; + } + + return super.updateChild(child, newWidget, newSlot); + } + + @override + void insertRenderObjectChild(RenderObject child, Object? slot) { + assert(slot != null); + assert(_isContentLayersSlot(slot!), "Invalid ContentLayers slot: $slot"); + + renderObject.insertChild(child, slot!); + } + + @override + void moveRenderObjectChild(RenderObject child, Object? oldSlot, Object? newSlot) { + assert(child.parent == renderObject); + assert(oldSlot != null); + assert(newSlot != null); + assert(_isContentLayersSlot(oldSlot!), "Invalid ContentLayers slot: $oldSlot"); + assert(_isContentLayersSlot(newSlot!), "Invalid ContentLayers slot: $newSlot"); + + // Can't move renderBox children to and from content slot (which is a sliver) + if (oldSlot == _contentSlot || newSlot == _contentSlot) { + assert(false); + } else { + renderObject.moveChildLayer(child as RenderBox, oldSlot!, newSlot!); + } + } + + @override + void removeRenderObjectChild(RenderObject child, Object? slot) { + assert(child is RenderBox); + assert(child.parent == renderObject); + assert(slot != null); + assert(_isContentLayersSlot(slot!), "Invalid ContentLayers slot: $slot"); + + renderObject.removeChild(child, slot!); + } + + @override + void visitChildren(ElementVisitor visitor) { + if (_content != null) { + visitor(_content!); + } + + // WARNING: Do not visit underlays or overlays when "locked". If you do, then the pipeline + // owner will collect those children for rebuild, e.g., for hot reload, and the + // pipeline owner will tell them to build before the content is laid out. We only + // want the underlays and overlays to build during the layout phase, after the + // content is laid out. + + // FIXME: locked is supposed to be private. We're using it as a proxy indication for when + // the build owner wants to build. Find an appropriate way to distinguish this. + // ignore: invalid_use_of_protected_member + if (!WidgetsBinding.instance.locked) { + for (final Element child in _underlays) { + visitor(child); + } + + for (final Element child in _overlays) { + visitor(child); + } + } + } + + bool _debugCheckHasAssociatedRenderObject(Element newChild) { + assert(() { + if (newChild.renderObject == null) { + FlutterError.reportError( + FlutterErrorDetails( + exception: FlutterError.fromParts([ + ErrorSummary('The children of `ContentLayersElement` must each have an associated render object.'), + ErrorHint( + 'This typically means that the `${newChild.widget}` or its children\n' + 'are not a subtype of `RenderObjectWidget`.', + ), + newChild.describeElement('The following element does not have an associated render object'), + DiagnosticsDebugCreator(DebugCreator(newChild)), + ]), + ), + ); + } + return true; + }()); + return true; + } +} + +/// `RenderObject` for a [ContentLayers] widget. +/// +/// Must be associated with an `Element` of type [ContentLayersElement]. +class RenderContentLayers extends RenderSliver with RenderSliverHelpers { + RenderContentLayers(this._element); + + @override + void dispose() { + _element = null; + super.dispose(); + } + + ContentLayersElement? _element; + + final _underlays = []; + RenderSliver? _content; + final _overlays = []; + + /// Whether this render object's layout information or its content + /// layout information is dirty. + /// + /// This is set to `true` when `markNeedsLayout` is called and it's + /// set to `false` after laying out the content. + bool get contentNeedsLayout => _contentNeedsLayout; + bool _contentNeedsLayout = true; + + /// Whether we are at the middle of a [performLayout] call. + bool _runningLayout = false; + + @override + void attach(PipelineOwner owner) { + FtlLogs.contentLayers.info("Attaching RenderContentLayers to owner: $owner"); + super.attach(owner); + + visitChildren((child) { + child.attach(owner); + }); + } + + @override + void detach() { + FtlLogs.contentLayers.info("detach()'ing RenderContentLayers from pipeline"); + // IMPORTANT: we must detach ourselves before detaching our children. + // This is a Flutter framework requirement. + super.detach(); + + // Detach our children. + visitChildren((child) { + child.detach(); + }); + } + + @override + void markNeedsLayout() { + super.markNeedsLayout(); + + if (_runningLayout) { + // We are already in a layout phase. + // When we call ContentLayerElement.buildLayers, markNeedsLayout is called again. + // We don't to mark the content as dirty, because otherwise the layers will never build. + return; + } + _contentNeedsLayout = true; + } + + @override + List debugDescribeChildren() { + final childDiagnostics = []; + + if (_content != null) { + childDiagnostics.add(_content!.toDiagnosticsNode(name: "content")); + } + + for (int i = 0; i < _underlays.length; i += 1) { + childDiagnostics.add(_underlays[i].toDiagnosticsNode(name: "underlay-$i")); + } + for (int i = 0; i < _overlays.length; i += 1) { + childDiagnostics.add(_overlays[i].toDiagnosticsNode(name: "overlay-#$i")); + } + + return childDiagnostics; + } + + void insertChild(RenderObject child, Object slot) { + assert(_isContentLayersSlot(slot)); + + if (slot == _contentSlot) { + _content = child as RenderSliver; + } else if (slot is _UnderlaySlot) { + _underlays.insert(slot.index, child as RenderBox); + } else if (slot is _OverlaySlot) { + _overlays.insert(slot.index, child as RenderBox); + } + + adoptChild(child); + } + + void moveChildLayer(RenderBox child, Object oldSlot, Object newSlot) { + assert(oldSlot is _UnderlaySlot || oldSlot is _OverlaySlot); + assert(newSlot is _UnderlaySlot || newSlot is _OverlaySlot); + + if (oldSlot is _UnderlaySlot) { + assert(_underlays.contains(child)); + _underlays.remove(child); + } else if (oldSlot is _OverlaySlot) { + assert(_overlays.contains(child)); + _overlays.remove(child); + } + + if (newSlot is _UnderlaySlot) { + _underlays.insert(newSlot.index, child); + } else if (newSlot is _OverlaySlot) { + _overlays.insert(newSlot.index, child); + } + } + + void removeChild(RenderObject child, Object slot) { + assert(_isContentLayersSlot(slot)); + + if (slot == _contentSlot) { + _content = null; + } else if (slot is _UnderlaySlot) { + _underlays.remove(child); + } else if (slot is _OverlaySlot) { + _overlays.remove(child); + } + + dropChild(child); + } + + @override + void visitChildren(RenderObjectVisitor visitor) { + if (_content != null) { + visitor(_content!); + } + + for (final RenderBox child in _underlays) { + visitor(child); + } + + for (final RenderBox child in _overlays) { + visitor(child); + } + } + + @override + void performLayout() { + FtlLogs.contentLayers.info("Laying out ContentLayers"); + if (_content == null) { + geometry = SliverGeometry.zero; + _contentNeedsLayout = false; + return; + } + + _runningLayout = true; + + // Always layout the content first, so that layers can inspect the content layout. + FtlLogs.contentLayers.fine("Laying out content - $_content"); + (_content!.parentData! as SliverLogicalParentData).layoutOffset = 0.0; + _content!.layout(constraints, parentUsesSize: true); + FtlLogs.contentLayers.fine("Content after layout: $_content"); + + // The size of the layers, and the our size, is exactly the same as the content. + final SliverGeometry sliverLayoutGeometry = _content!.geometry!; + if (sliverLayoutGeometry.scrollOffsetCorrection != null) { + geometry = SliverGeometry( + scrollOffsetCorrection: sliverLayoutGeometry.scrollOffsetCorrection, + ); + return; + } + geometry = SliverGeometry( + scrollExtent: sliverLayoutGeometry.scrollExtent, + paintExtent: sliverLayoutGeometry.paintExtent, + maxPaintExtent: sliverLayoutGeometry.maxPaintExtent, + maxScrollObstructionExtent: sliverLayoutGeometry.maxScrollObstructionExtent, + cacheExtent: sliverLayoutGeometry.cacheExtent, + hasVisualOverflow: sliverLayoutGeometry.hasVisualOverflow, + ); + + _contentNeedsLayout = false; + + // Build the underlay and overlays during the layout phase so that they can inspect an + // up-to-date content layout. + // + // This behavior is what allows us to avoid layers that are always one frame behind the + // content changes. + FtlLogs.contentLayers.fine("Building layers"); + invokeLayoutCallback((constraints) { + _element!.buildLayers(); + }); + FtlLogs.contentLayers.finer("Done building layers"); + + FtlLogs.contentLayers.fine("Laying out layers (${_underlays.length} underlays, ${_overlays.length} overlays)"); + // Layout the layers below and above the content. + final layerConstraints = ScrollingBoxConstraints( + minWidth: constraints.crossAxisExtent, + maxWidth: constraints.crossAxisExtent, + minHeight: sliverLayoutGeometry.scrollExtent, + maxHeight: sliverLayoutGeometry.scrollExtent, + scrollOffset: constraints.scrollOffset, + ); + + for (final underlay in _underlays) { + final childParentData = underlay.parentData! as SliverLogicalParentData; + childParentData.layoutOffset = -constraints.scrollOffset; + FtlLogs.contentLayers.fine("Laying out underlay: $underlay"); + underlay.layout(layerConstraints); + } + for (final overlay in _overlays) { + final childParentData = overlay.parentData! as SliverLogicalParentData; + childParentData.layoutOffset = -constraints.scrollOffset; + FtlLogs.contentLayers.fine("Laying out overlay: $overlay"); + overlay.layout(layerConstraints); + } + + _runningLayout = false; + FtlLogs.contentLayers.finer("Done laying out layers"); + } + + @override + bool hitTestChildren( + SliverHitTestResult result, { + required double mainAxisPosition, + required double crossAxisPosition, + }) { + if (_content == null) { + return false; + } + + // Run hit tests in reverse-paint order. + bool didHit = false; + + final boxResult = BoxHitTestResult.wrap(result); + + // First, hit-test overlays. + for (final overlay in _overlays) { + didHit = + hitTestBoxChild(boxResult, overlay, mainAxisPosition: mainAxisPosition, crossAxisPosition: crossAxisPosition); + if (didHit) { + return true; + } + } + + // Second, hit-test the content. + didHit = _content!.hitTest(result, mainAxisPosition: mainAxisPosition, crossAxisPosition: crossAxisPosition); + if (didHit) { + return true; + } + + // Third, hit-test the underlays. + for (final underlay in _underlays) { + didHit = hitTestBoxChild(boxResult, underlay, + mainAxisPosition: mainAxisPosition, crossAxisPosition: crossAxisPosition); + if (didHit) { + return true; + } + } + + return false; + } + + @override + void paint(PaintingContext context, Offset offset) { + if (_content == null) { + return; + } + + void paintChild(RenderObject child) { + final childParentData = child.parentData! as SliverLogicalParentData; + context.paintChild( + child, + offset + Offset(0, childParentData.layoutOffset!), + ); + } + + // First, paint the underlays. + for (final underlay in _underlays) { + paintChild(underlay); + } + + // Second, paint the content. + paintChild(_content!); + + // Third, paint the overlays. + for (final overlay in _overlays) { + paintChild(overlay); + } + } + + @override + void applyPaintTransform(covariant RenderObject child, Matrix4 transform) { + final childParentData = child.parentData! as SliverLogicalParentData; + transform.translate(0.0, childParentData.layoutOffset!); + } + + @override + double childMainAxisPosition(covariant RenderObject child) { + final childParentData = child.parentData! as SliverLogicalParentData; + return childParentData.layoutOffset!; + } + + @override + void setupParentData(covariant RenderObject child) { + child.parentData = _ChildParentData(); + } +} + +bool _isContentLayersSlot(Object slot) => slot == _contentSlot || slot is _UnderlaySlot || slot is _OverlaySlot; + +const _contentSlot = "content"; + +class _UnderlaySlot extends _IndexedSlot { + const _UnderlaySlot(int index) : super(index); + + @override + String toString() => "[$_UnderlaySlot] - underlay index: $index"; +} + +class _OverlaySlot extends _IndexedSlot { + const _OverlaySlot(int index) : super(index); + + @override + String toString() => "[$_OverlaySlot] - overlay index: $index"; +} + +class _IndexedSlot { + const _IndexedSlot(this.index); + + final int index; +} + +/// Used as a placeholder in [List] objects when the actual +/// elements are not yet determined. +/// +/// Copied from the framework. +class _NullElement extends Element { + _NullElement() : super(const _NullWidget()); + + static _NullElement instance = _NullElement(); + + @override + bool get debugDoingBuild => throw UnimplementedError(); +} + +class _NullWidget extends Widget { + const _NullWidget(); + + @override + Element createElement() => throw UnimplementedError(); +} + +/// A widget builder, which builds a [ContentLayerWidget]. +typedef ContentLayerWidgetBuilder = ContentLayerWidget Function(BuildContext context); + +/// A widget that can be displayed as a layer in a [ContentLayers] widget. +/// +/// [ContentLayers] uses a special type of layer widget to avoid timing issues with +/// Flutter's build order. This timing issue is only a concern when a layer +/// widget inspects content layout within [ContentLayers]. However, to prevent +/// developer confusion and mistakes, all layer widgets are forced to be +/// [ContentLayerWidget]s. +/// +/// Extend [ContentLayerStatefulWidget] to create a layer that's based on the +/// content layout within the ancestor [ContentLayers], and requires mutable state. +/// +/// Extend [ContentLayerStatelessWidget] to create a layer that's based on the +/// content layout within the ancestor [ContentLayers], but doesn't require mutable +/// state. +/// +/// To quickly and easily build a layer from a traditional widget tree, create a +/// [ContentLayerProxyWidget] with the desired subtree. This approach is a +/// quicker and more convenient alternative to [ContentLayerStatelessWidget] +/// for the simplest of layer trees. +abstract class ContentLayerWidget implements Widget { + // Marker interface. +} + +/// A [ContentLayerWidget] that displays nothing. +/// +/// Useful when a layer should conditionally display content. An [EmptyContentLayer] can +/// be returned in cases where no visuals are desired. +class EmptyContentLayer extends ContentLayerStatelessWidget { + const EmptyContentLayer({super.key}); + + @override + Widget doBuild(BuildContext context, Element? contentElement, RenderObject? contentLayout) { + return const SizedBox(); + } +} + +/// Widget that builds a [ContentLayers] layer based on a traditional widget +/// subtree, as represented by the given [child]. +/// +/// The [child] subtree must NOT access the content layout within [ContentLayers]. +/// +/// This widget is an escape hatch to easily display traditional widget subtrees +/// as content layers, when those layers don't care about the layout of the content. +class ContentLayerProxyWidget extends ContentLayerStatelessWidget { + const ContentLayerProxyWidget({ + super.key, + required this.child, + }); + + final Widget child; + + @override + Widget doBuild(BuildContext context, Element? contentElement, RenderObject? contentLayout) { + return child; + } +} + +/// Widget that builds a stateless [ContentLayers] layer, which is given access +/// to the ancestor [ContentLayers] content [Element] and [RenderObject]. +abstract class ContentLayerStatelessWidget extends StatelessWidget implements ContentLayerWidget { + const ContentLayerStatelessWidget({super.key}); + + @override + Widget build(BuildContext context) { + final contentLayers = (context as Element).findAncestorContentLayers(); + final contentElement = contentLayers?._content; + final contentLayout = contentElement?.findRenderObject(); + + return doBuild(context, contentElement, contentLayout); + } + + @protected + Widget doBuild(BuildContext context, Element? contentElement, RenderObject? contentLayout); +} + +/// Widget that builds a stateful [ContentLayers] layer, which is given access +/// to the ancestor [ContentLayers] content [Element] and [RenderObject]. +/// +/// See [ContentLayerState] for information about why a special type of [StatefulWidget] +/// is required for use within [ContentLayers]. +abstract class ContentLayerStatefulWidget extends StatefulWidget implements ContentLayerWidget { + const ContentLayerStatefulWidget({super.key}); + + @override + StatefulElement createElement() => ContentLayerStatefulElement(this); + + @override + ContentLayerState createState(); +} + +/// A [StatefulElement] that looks for an ancestor [ContentLayersElement] and marks +/// that element as needing to rebuild any time that this [ContentLayerStatefulElement] +/// needs to rebuild. +/// +/// In effect, this [Element] connects its dirty state to an ancestor [ContentLayersElement]. +class ContentLayerStatefulElement extends StatefulElement { + ContentLayerStatefulElement(super.widget); + + bool _isActive = false; + + @override + void activate() { + super.activate(); + _isActive = true; + } + + @override + void deactivate() { + _isActive = false; + super.deactivate(); + } + + @override + void markNeedsBuild() { + if (_isActive && mounted) { + // Our Element is attached to the tree. Mark our ancestor ContentLayers as + // needing to build, too. + // + // Flutter blows up if we try to climb the Element tree when this Element + // isn't active, because when this Element is deactivated, it's technically + // detached from the tree until its reactivated or disposed. + findAncestorContentLayers()?.markNeedsBuild(); + } + + super.markNeedsBuild(); + } +} + +extension on Element { + /// Finds and returns a [ContentLayersElement] by walking up the [Element] tree, + /// beginning with this [Element]. + ContentLayersElement? findAncestorContentLayers() { + ContentLayersElement? contentLayersElement; + + visitAncestorElements((element) { + if (element is ContentLayersElement) { + contentLayersElement = element; + return false; + } + + return true; + }); + + return contentLayersElement; + } +} + +/// A state object for a [ContentLayerStatefulWidget]. +/// +/// A [ContentLayerState] needs to be implemented a little bit differently than +/// a traditional [StatefulWidget]. Calling `setState()` will cause this widget +/// to rebuild, but the ancestor [ContentLayers] has no control over WHEN this +/// widget will rebuild. This widget might rebuild before the content layer can +/// run its layout. If this widget then attempts to query the content layout, +/// Flutter throws an exception. +/// +/// To work around the rebuild timing issues, a [ContentLayerState] separates +/// layout inspection from the build process. A [ContentLayerState] should +/// collect all the layout information it needs in [computeLayoutData] and then +/// it should build its subtree in [doBuild]. +/// +/// A [ContentLayerState] should NOT implement [build] - that implementation is +/// handled on your behalf, and it coordinates between [computeLayoutData] and +/// [doBuild]. +abstract class ContentLayerState + extends State { + @protected + LayoutDataType? get layoutData => _layoutData; + LayoutDataType? _layoutData; + + /// Traditional build method for this widget - this method should not be overridden + /// in subclasses. + @override + Widget build(BuildContext context) { + final contentLayers = (context as Element).findAncestorContentLayers(); + final contentElement = contentLayers?._content; + final contentLayout = contentElement?.findRenderObject(); + + if (contentLayers != null && !contentLayers.renderObject.contentNeedsLayout) { + _layoutData = computeLayoutData(contentElement, contentLayout); + } + + return doBuild(context, _layoutData); + } + + /// Computes and returns cached layout data, derived from the content layer's [Element] + /// and [RenderObject]. + /// + /// Subclasses can choose what action to take when the [contentElement] or [contentLayout] + /// are `null`, and therefore unavailable. + LayoutDataType? computeLayoutData(Element? contentElement, RenderObject? contentLayout); + + /// Composes and returns the subtree for this widget. + /// + /// This method should be treated as the replacement for the traditional [build] method. + /// + /// [doBuild] is provided with the latest available layout data, which was computed + /// by [computeLayoutData]. + @protected + Widget doBuild(BuildContext context, LayoutDataType? layoutData); +} + +class _ChildParentData extends SliverLogicalParentData with ContainerParentDataMixin {} diff --git a/lib/src/logging.dart b/lib/src/logging.dart index ee079a3..e6a6ae3 100644 --- a/lib/src/logging.dart +++ b/lib/src/logging.dart @@ -9,6 +9,7 @@ class LogNames { static const link = 'link'; static const boundary = 'boundary'; static const widgetBoundary = 'boundary.widget'; + static const contentLayers = 'contentLayers'; } /// Follow the Leader logging. @@ -18,6 +19,7 @@ class FtlLogs { static final link = logging.Logger(LogNames.link); static final boundary = logging.Logger(LogNames.boundary); static final widgetBoundary = logging.Logger(LogNames.widgetBoundary); + static final contentLayers = logging.Logger(LogNames.contentLayers); static final _activeLoggers = {}; diff --git a/lib/src/render_sliver_ext.dart b/lib/src/render_sliver_ext.dart new file mode 100644 index 0000000..89a64b9 --- /dev/null +++ b/lib/src/render_sliver_ext.dart @@ -0,0 +1,28 @@ +import 'package:flutter/rendering.dart'; + +/// Extension on [RenderSliver] that that brings over some of the missing +/// [RenderBox] functionality. +extension RenderSliverExt on RenderSliver { + Size get size { + assert(attached); + return Size(geometry!.crossAxisExtent ?? constraints.crossAxisExtent, geometry!.paintExtent); + } + + bool get hasSize { + assert(attached); + return geometry != null; + } + + Offset globalToLocal(Offset point, {RenderObject? ancestor}) { + assert(attached); + final transform = getTransformTo(ancestor); + transform.invert(); + return MatrixUtils.transformPoint(transform, point); + } + + Offset localToGlobal(Offset point, {RenderObject? ancestor}) { + assert(attached); + final transform = getTransformTo(ancestor); + return MatrixUtils.transformPoint(transform, point); + } +} diff --git a/lib/src/sliver_hybrid_stack.dart b/lib/src/sliver_hybrid_stack.dart new file mode 100644 index 0000000..bba2396 --- /dev/null +++ b/lib/src/sliver_hybrid_stack.dart @@ -0,0 +1,209 @@ +import "package:flutter/rendering.dart"; +import "package:flutter/widgets.dart"; + +/// Component that allows mixing RenderSliver child with other RenderBox +/// children. +/// +/// The RenderSliver child will be laid out first, and then the RenderBox +/// children will be laid out to cover the entire scroll extent of the +/// RenderSliver child. +class SliverHybridStack extends MultiChildRenderObjectWidget { + /// Creates a SliverHybridStack. The [children] must contain exactly one + /// child that a RenderSliver, and zero or more RenderBox children. + /// + /// The [fillViewport] flag controls whether the RenderBox children should + /// be stretched if necessary to fill the entire viewport. + const SliverHybridStack({ + super.key, + this.fillViewport = false, + super.children, + }); + + final bool fillViewport; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderSliverHybridStack(fillViewport: fillViewport); + } + + @override + void updateRenderObject(BuildContext context, covariant RenderSliver renderObject) { + (renderObject as _RenderSliverHybridStack).fillViewport = fillViewport; + } +} + +class _ChildParentData extends SliverLogicalParentData with ContainerParentDataMixin {} + +class _RenderSliverHybridStack extends RenderSliver + with ContainerRenderObjectMixin>, RenderSliverHelpers { + _RenderSliverHybridStack({required this.fillViewport}); + + bool fillViewport; + + @override + void performLayout() { + RenderSliver? sliver; + var child = firstChild; + while (child != null) { + if (child is RenderSliver) { + assert(sliver == null, "There can only be one sliver in a SliverHybridStack"); + sliver = child; + break; + } + child = childAfter(child); + } + if (sliver == null) { + geometry = SliverGeometry.zero; + return; + } + + (sliver.parentData! as SliverLogicalParentData).layoutOffset = 0.0; + sliver.layout(constraints, parentUsesSize: true); + final SliverGeometry sliverLayoutGeometry = sliver.geometry!; + if (sliverLayoutGeometry.scrollOffsetCorrection != null) { + geometry = SliverGeometry( + scrollOffsetCorrection: sliverLayoutGeometry.scrollOffsetCorrection, + ); + return; + } + + geometry = SliverGeometry( + scrollExtent: sliverLayoutGeometry.scrollExtent, + paintExtent: sliverLayoutGeometry.paintExtent, + maxPaintExtent: sliverLayoutGeometry.maxPaintExtent, + maxScrollObstructionExtent: sliverLayoutGeometry.maxScrollObstructionExtent, + cacheExtent: sliverLayoutGeometry.cacheExtent, + hasVisualOverflow: sliverLayoutGeometry.hasVisualOverflow, + ); + + final boxConstraints = ScrollingBoxConstraints( + minWidth: constraints.crossAxisExtent, + maxWidth: constraints.crossAxisExtent, + minHeight: sliverLayoutGeometry.scrollExtent, + maxHeight: sliverLayoutGeometry.scrollExtent, + scrollOffset: constraints.scrollOffset, + ); + + child = firstChild; + while (child != null) { + if (child is RenderBox) { + final childParentData = child.parentData! as SliverLogicalParentData; + childParentData.layoutOffset = -constraints.scrollOffset; + if (constraints.scrollOffset == 0.0 && fillViewport) { + child.layout( + BoxConstraints.tightFor( + width: constraints.crossAxisExtent, + height: constraints.viewportMainAxisExtent, + ), + parentUsesSize: true, + ); + } else { + child.layout(boxConstraints, parentUsesSize: true); + } + } + child = childAfter(child); + } + } + + @override + bool hitTest(SliverHitTestResult result, {required double mainAxisPosition, required double crossAxisPosition}) { + if (mainAxisPosition >= 0.0 && crossAxisPosition >= 0.0 && crossAxisPosition < constraints.crossAxisExtent) { + if (hitTestChildren(result, mainAxisPosition: mainAxisPosition, crossAxisPosition: crossAxisPosition) || + hitTestSelf(mainAxisPosition: mainAxisPosition, crossAxisPosition: crossAxisPosition)) { + result.add(SliverHitTestEntry( + this, + mainAxisPosition: mainAxisPosition, + crossAxisPosition: crossAxisPosition, + )); + return true; + } + } + return false; + } + + @override + bool hitTestChildren( + SliverHitTestResult result, { + required double mainAxisPosition, + required double crossAxisPosition, + }) { + var child = lastChild; + while (child != null) { + if (child is RenderSliver) { + final isHit = child.hitTest( + result, + mainAxisPosition: mainAxisPosition, + crossAxisPosition: crossAxisPosition, + ); + if (isHit) { + return true; + } + } else if (child is RenderBox) { + final boxResult = BoxHitTestResult.wrap(result); + final isHit = hitTestBoxChild( + boxResult, + child, + mainAxisPosition: mainAxisPosition, + crossAxisPosition: crossAxisPosition, + ); + if (isHit) { + return true; + } + } + child = childBefore(child); + } + return false; + } + + @override + void setupParentData(covariant RenderObject child) { + child.parentData = _ChildParentData(); + } + + @override + void paint(PaintingContext context, Offset offset) { + var child = firstChild; + while (child != null) { + final childParentData = child.parentData! as SliverLogicalParentData; + context.paintChild( + child, + offset + Offset(0, childParentData.layoutOffset!), + ); + child = childAfter(child); + } + } + + @override + void applyPaintTransform(covariant RenderObject child, Matrix4 transform) { + final childParentData = child.parentData! as SliverLogicalParentData; + transform.translate(0.0, childParentData.layoutOffset!); + } + + @override + double childMainAxisPosition(covariant RenderObject child) { + final childParentData = child.parentData! as SliverLogicalParentData; + return childParentData.layoutOffset!; + } +} + +// Box constraints that will cause relayout when the scroll offset changes. +class ScrollingBoxConstraints extends BoxConstraints { + const ScrollingBoxConstraints({ + super.minWidth, + super.maxWidth, + super.minHeight, + super.maxHeight, + required this.scrollOffset, + }); + + final double scrollOffset; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is ScrollingBoxConstraints && super == other && scrollOffset == other.scrollOffset; + } + + @override + int get hashCode => Object.hash(super.hashCode, scrollOffset); +} diff --git a/pubspec.yaml b/pubspec.yaml index 1898400..4d37406 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,7 +6,7 @@ repository: https://github.com/Flutter-Bounty-Hunters/follow_the_leader version: 0.0.4+8 environment: - sdk: ">=2.15.0 <4.0.0" + sdk: ">=3.0.0 <4.0.0" dependencies: flutter: diff --git a/test/content_layers_test.dart b/test/content_layers_test.dart new file mode 100644 index 0000000..de88848 --- /dev/null +++ b/test/content_layers_test.dart @@ -0,0 +1,749 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; + +void main() { + group("Content layers", () { + testWidgets("build without any layers", (tester) async { + await _pumpScaffold( + tester, + child: ContentLayers( + content: (_) => SliverToBoxAdapter( + child: LayoutBuilder( + builder: (context, constraints) { + // The content should be able to take up whatever width it wants, within the available space. + // The height is infinite because `ContentLayers` is a sliver. + expect(constraints.isTight, isFalse); + expect(constraints.maxWidth, _windowSize.width); + expect(constraints.maxHeight, double.infinity); + + return SizedBox.fromSize(size: _windowSize); + }, + ), + ), + ), + ); + + // Getting here without an error means the test passes. + }); + + testWidgets("build with a single underlay and is same size as content", (tester) async { + await _pumpScaffold( + tester, + child: ContentLayers( + content: (_) => SliverToBoxAdapter( + child: SizedBox.fromSize(size: _windowSize), + ), + underlays: [ + _buildSizeValidatingLayer(), + ], + ), + ); + + // Getting here without an error means the test passes. + }); + + testWidgets("build with a single overlay and is same size as content", (tester) async { + await _pumpScaffold( + tester, + child: ContentLayers( + content: (_) => SliverToBoxAdapter( + child: SizedBox.fromSize(size: _windowSize), + ), + overlays: [ + _buildSizeValidatingLayer(), + ], + ), + ); + + // Getting here without an error means the test passes. + }); + + testWidgets("build with a single underlay and overlay and they are the same size as content", (tester) async { + await _pumpScaffold( + tester, + child: ContentLayers( + content: (_) => SliverToBoxAdapter( + child: SizedBox.fromSize(size: _windowSize), + ), + underlays: [ + _buildSizeValidatingLayer(), + ], + overlays: [ + _buildSizeValidatingLayer(), + ], + ), + ); + + // Getting here without an error means the test passes. + }); + + testWidgets("build with multiple underlays and overlays and they are the same size as content", (tester) async { + await _pumpScaffold( + tester, + child: ContentLayers( + content: (_) => SliverToBoxAdapter( + child: SizedBox.fromSize(size: _windowSize), + ), + underlays: [ + _buildSizeValidatingLayer(), + _buildSizeValidatingLayer(), + _buildSizeValidatingLayer(), + ], + overlays: [ + _buildSizeValidatingLayer(), + _buildSizeValidatingLayer(), + _buildSizeValidatingLayer(), + ], + ), + ); + + // Getting here without an error means the test passes. + }); + + testWidgets("rebuilds layers when they setState()", (tester) async { + final contentRebuildSignal = ValueNotifier(0); + final contentBuildTracker = ValueNotifier(0); + + final underlayRebuildSignal = ValueNotifier(0); + final underlayBuildTracker = ValueNotifier(0); + + final overlayRebuildSignal = ValueNotifier(0); + final overlayBuildTracker = ValueNotifier(0); + + await _pumpScaffold( + tester, + child: ContentLayers( + content: (onBuildScheduled) => _RebuildableWidget( + rebuildSignal: contentRebuildSignal, + buildTracker: contentBuildTracker, + onBuildScheduled: onBuildScheduled, + child: SliverToBoxAdapter( + child: SizedBox.fromSize(size: _windowSize), + ), + ), + underlays: [ + (context) => _RebuildableContentLayerWidget( + rebuildSignal: underlayRebuildSignal, + buildTracker: underlayBuildTracker, + child: const SizedBox(), + ), + ], + overlays: [ + (context) => _RebuildableContentLayerWidget( + rebuildSignal: overlayRebuildSignal, + buildTracker: overlayBuildTracker, + child: const SizedBox(), + ), + ], + ), + ); + expect(contentBuildTracker.value, 1); + expect(underlayBuildTracker.value, 1); + expect(overlayBuildTracker.value, 1); + + // Tell the underlay widget to rebuild itself. + underlayRebuildSignal.value += 1; + await tester.pump(); + expect(underlayBuildTracker.value, 2); + expect(contentBuildTracker.value, 1); + + // Tell the overlay widget to rebuild itself. + overlayRebuildSignal.value += 1; + await tester.pump(); + expect(overlayBuildTracker.value, 2); + expect(contentBuildTracker.value, 1); + }); + + testWidgets("lays out the content before building the layers during full tree build", (tester) async { + final didContentLayout = ValueNotifier(false); + bool didUnderlayLayout = false; + + await _pumpScaffold( + tester, + child: ContentLayers( + content: (_) => _LayoutTrackingWidget( + onLayout: () { + didContentLayout.value = true; + }, + child: SliverToBoxAdapter( + child: SizedBox.fromSize(size: _windowSize), + ), + ), + underlays: [ + (context) { + expect(didContentLayout.value, isTrue); + didUnderlayLayout = true; + return const ContentLayerProxyWidget( + child: SizedBox(), + ); + }, + ], + overlays: [ + (context) { + expect(didContentLayout.value, isTrue); + expect(didUnderlayLayout, isTrue); + return const ContentLayerProxyWidget( + child: SizedBox(), + ); + }, + ], + ), + ); + + // Getting here without an error means the test passes. + }); + + testWidgets("lays out the content before building the layers when the content root rebuilds", (tester) async { + final rebuildSignal = ValueNotifier(0); + final buildTracker = ValueNotifier(0); + final contentLayoutCount = ValueNotifier(0); + final layerLayoutCount = ValueNotifier(0); + + await _pumpScaffold( + tester, + child: ContentLayers( + content: (onBuildScheduled) => _RebuildableWidget( + rebuildSignal: rebuildSignal, + buildTracker: buildTracker, + onBuildScheduled: onBuildScheduled, + child: _LayoutTrackingWidget( + onLayout: () { + contentLayoutCount.value += 1; + }, + child: SliverToBoxAdapter( + child: SizedBox.fromSize(size: _windowSize), + ), + ), + ), + underlays: [ + (context) { + expect(contentLayoutCount.value, layerLayoutCount.value + 1); + return const ContentLayerProxyWidget( + child: SizedBox(), + ); + }, + ], + overlays: [ + (context) { + expect(contentLayoutCount.value, layerLayoutCount.value + 1); + layerLayoutCount.value += 1; + return const ContentLayerProxyWidget( + child: SizedBox(), + ); + }, + ], + ), + ); + expect(buildTracker.value, 1); + + // Tell the content widget to rebuild itself. + rebuildSignal.value += 1; + await tester.pump(); + + // We expect build and layout to run twice. First, during the initial pump. Second, + // after we tell the content to rebuild. + expect(buildTracker.value, 2); + expect(contentLayoutCount.value, 2); + expect(layerLayoutCount.value, 2); + }); + + testWidgets("lays out the content before building the layers when a content descendant rebuilds", (tester) async { + final rebuildSignal = ValueNotifier(0); + final buildTracker = ValueNotifier(0); + final contentLayoutCount = ValueNotifier(0); + final layerLayoutCount = ValueNotifier(0); + + await _pumpScaffold( + tester, + child: ContentLayers( + // Place a couple stateful widgets above the _RebuildableWidget to ensure that + // when a widget deeper in the tree rebuilds, we still rebuild ContentLayers. + content: (_) => _NoRebuildWidget( + child: _NoRebuildWidget( + child: _RebuildableWidget( + rebuildSignal: rebuildSignal, + buildTracker: buildTracker, + // We don't pass in the onBuildScheduled callback here because we're simulating + // an entire subtree that a client might provide as content. + child: _LayoutTrackingWidget( + onLayout: () { + contentLayoutCount.value += 1; + }, + child: SliverToBoxAdapter( + child: SizedBox.fromSize(size: _windowSize), + ), + ), + ), + ), + ), + underlays: [ + (context) { + expect(contentLayoutCount.value, layerLayoutCount.value + 1); + return const ContentLayerProxyWidget( + child: SizedBox(), + ); + }, + ], + overlays: [ + (context) { + expect(contentLayoutCount.value, layerLayoutCount.value + 1); + layerLayoutCount.value += 1; + return const ContentLayerProxyWidget( + child: SizedBox(), + ); + }, + ], + ), + ); + expect(buildTracker.value, 1); + expect(contentLayoutCount.value, 1); + expect(layerLayoutCount.value, 1); + + // Tell the content widget to rebuild itself. + rebuildSignal.value += 1; + await tester.pump(); + + // We expect build and layout to run twice. First, during the initial pump. Second, + // after we tell the content to rebuild. + expect(buildTracker.value, 2); + expect(contentLayoutCount.value, 2); + expect(layerLayoutCount.value, 2); + }); + + testWidgets("re-uses layer Elements instead of always re-inflating layer Widgets", (tester) async { + final rebuildSignal = ValueNotifier(0); + final buildTracker = ValueNotifier(0); + final contentKey = GlobalKey(); + final contentLayoutCount = ValueNotifier(0); + final underlayElementTracker = ValueNotifier(null); + Element? underlayElement; + final overlayElementTracker = ValueNotifier(null); + Element? overlayElement; + + await _pumpScaffold( + tester, + child: ContentLayers( + content: (_) => _RebuildableWidget( + key: contentKey, + rebuildSignal: rebuildSignal, + buildTracker: buildTracker, + // We don't pass in the onBuildScheduled callback here because we're simulating + // an entire subtree that a client might provide as content. + child: _LayoutTrackingWidget( + onLayout: () { + contentLayoutCount.value += 1; + }, + child: SliverToBoxAdapter( + child: SizedBox.fromSize(size: _windowSize), + ), + ), + ), + underlays: [ + (context) => _RebuildableContentLayerWidget( + elementTracker: underlayElementTracker, + onBuild: () { + // Ensure that this layer can access the render object of the content. + final contentSliver = contentKey.currentContext!.findRenderObject() as RenderSliver?; + expect(contentSliver, isNotNull); + expect(contentSliver!.geometry, isNotNull); + final viewport = context.findAncestorRenderObjectOfType(); + // Build happens during viewport layout, which is not finished at this point. So transform to viewport + // coordinate space is as far as we can go. + expect(contentSliver.localToGlobal(Offset.zero, ancestor: viewport), isNotNull); + }, + child: const SizedBox.expand(), + ), + ], + overlays: [ + (context) => _RebuildableContentLayerWidget( + elementTracker: overlayElementTracker, + onBuild: () { + // Ensure that this layer can access the render object of the content. + final contentSliver = contentKey.currentContext!.findRenderObject() as RenderSliver?; + expect(contentSliver, isNotNull); + expect(contentSliver!.geometry, isNotNull); + final viewport = context.findAncestorRenderObjectOfType(); + // Build happens during viewport layout, which is not finished at this point. So transform to viewport + // coordinate space is as far as we can go. + expect(contentSliver.localToGlobal(Offset.zero, ancestor: viewport), isNotNull); + }, + child: const SizedBox.expand(), + ), + ], + ), + ); + expect(buildTracker.value, 1); + + underlayElement = underlayElementTracker.value; + expect(underlayElement, isNotNull); + + overlayElement = overlayElementTracker.value; + expect(overlayElement, isNotNull); + + // Tell the content widget to rebuild itself. + rebuildSignal.value += 1; + await tester.pump(); + + // We expect build and layout to run twice. First, during the initial pump. Second, + // after we tell the content to rebuild. + expect(buildTracker.value, 2); + expect(contentLayoutCount.value, 2); + expect(underlayElementTracker.value, underlayElement); + expect(overlayElementTracker.value, overlayElement); + }); + + testWidgets("lets layers access inherited widgets", (tester) async { + await _pumpScaffold( + tester, + child: ContentLayers( + content: (_) => SliverToBoxAdapter( + child: SizedBox.fromSize(size: _windowSize), + ), + underlays: [ + (context) { + // Ensure that this layer can access ancestors. + final directionality = Directionality.of(context); + expect(directionality, isNotNull); + + return const ContentLayerProxyWidget( + child: SizedBox(), + ); + }, + ], + overlays: [ + (context) { + // Ensure that this layer can access ancestors. + final directionality = Directionality.of(context); + expect(directionality, isNotNull); + + return const ContentLayerProxyWidget( + child: SizedBox(), + ); + }, + ], + ), + ); + + // Getting here without an error means the test passes. + }); + }); +} + +Future _pumpScaffold( + WidgetTester tester, { + required Widget child, +}) async { + addTearDown(() => tester.platformDispatcher.clearAllTestValues()); + + tester.view + ..physicalSize = _windowSize + ..devicePixelRatio = 1.0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CustomScrollView( + slivers: [ + child, + ], + ), + ), + ), + ); +} + +// We control the window size in these tests so that we can easily compare and validate +// the layout sizes for underlays and overlays. +const _windowSize = Size(600, 1000); + +/// Returns a [LayoutBuilder] that expects its constraints to be the same as the window, +/// used for quickly verifying the constraints given to underlays and overlays in +/// ContentLayers widgets in this test suite. +ContentLayerWidgetBuilder _buildSizeValidatingLayer() { + return (context) => const _SizeValidatingLayer(); +} + +class _SizeValidatingLayer extends ContentLayerStatefulWidget { + const _SizeValidatingLayer(); + + @override + ContentLayerState createState() => _SizeValidatingLayerState(); +} + +class _SizeValidatingLayerState extends ContentLayerState<_SizeValidatingLayer, Object> { + @override + Object? computeLayoutData(Element? contentElement, RenderObject? contentLayout) => null; + + @override + Widget doBuild(BuildContext context, Object? layoutData) { + return LayoutBuilder( + builder: (context, constraints) { + _expectLayerConstraintsThatMatchContent(constraints); + return const SizedBox(); + }, + ); + } +} + +void _expectLayerConstraintsThatMatchContent(BoxConstraints constraints) { + expect(constraints.isTight, isTrue); + expect(constraints.maxWidth, _windowSize.width); + expect(constraints.maxHeight, _windowSize.height); +} + +/// A [StatefulWidget] that never rebuilds. +/// +/// Used to inject an `Element` above another widget to test what happens when a descendant +/// rebuilds, and that descendant isn't the top-level widget in a subtree. +class _NoRebuildWidget extends StatefulWidget { + const _NoRebuildWidget({ + Key? key, + required this.child, + }) : super(key: key); + + final Widget child; + + @override + State<_NoRebuildWidget> createState() => _NoRebuildWidgetState(); +} + +class _NoRebuildWidgetState extends State<_NoRebuildWidget> { + @override + Widget build(BuildContext context) { + return widget.child; + } +} + +/// Widget that can be told to rebuild from the outside, and also tracks its build count. +class _RebuildableWidget extends StatefulWidget { + const _RebuildableWidget({ + Key? key, + this.rebuildSignal, + this.buildTracker, + // TODO(srawlins): `unused_element`, when reporting a parameter, is being + // renamed to `unused_element_parameter`. For now, ignore each; when the SDK + // constraint is >= 3.6.0, just ignore `unused_element_parameter`. + // ignore: unused_element, unused_element_parameter + this.elementTracker, + this.onBuildScheduled, + // TODO(srawlins): `unused_element`, when reporting a parameter, is being + // renamed to `unused_element_parameter`. For now, ignore each; when the SDK + // constraint is >= 3.6.0, just ignore `unused_element_parameter`. + // ignore: unused_element, unused_element_parameter + this.onBuild, + this.builder, + this.child, + }) : assert(child != null || builder != null, "Must provide either a child OR a builder."), + assert(child == null || builder == null, "Can't provide a child AND a builder. Choose one."), + super(key: key); + + /// Signal that instructs this widget to call `setState()`. + final Listenable? rebuildSignal; + + /// The number of times this widget has run `build()`. + final ValueNotifier? buildTracker; + + /// The [Element] that currently owns this `Widget` and its `State`. + final ValueNotifier? elementTracker; + + /// Callback that's invoked when this widget calls `setState()`. + final VoidCallback? onBuildScheduled; + + /// Callback that's invoked during this widget's `build()` method. + final VoidCallback? onBuild; + + final WidgetBuilder? builder; + final Widget? child; + + @override + State<_RebuildableWidget> createState() => _RebuildableWidgetState(); +} + +class _RebuildableWidgetState extends State<_RebuildableWidget> { + @override + void initState() { + super.initState(); + widget.rebuildSignal?.addListener(_onRebuildSignal); + } + + @override + void didUpdateWidget(_RebuildableWidget oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.rebuildSignal != oldWidget.rebuildSignal) { + oldWidget.rebuildSignal?.removeListener(_onRebuildSignal); + widget.rebuildSignal?.addListener(_onRebuildSignal); + } + } + + @override + void dispose() { + widget.rebuildSignal?.removeListener(_onRebuildSignal); + super.dispose(); + } + + void _onRebuildSignal() { + setState(() { + // rebuild + }); + + // Explicitly mark our RenderObject as needing layout so that we simulate content + // that rebuilds because its layout changed. Without this call, we'd get a widget + // rebuild, but we wouldn't trigger another content layout pass. We want that + // layout pass so that our tests can inspect the order of operations and ensure that + // when the content layout changes, the content is always laid out before layers. + context.findRenderObject()?.markNeedsLayout(); + } + + // This override is a regrettable requirement for ContentLayers, which is needed so + // that ContentLayers can remove the layers to prevent them from building during a + // regular build phase when the content changes. This is the result of Flutter making + // it impossible to monitor dirty subtrees, and making it impossible to control build + // order. + @override + void setState(VoidCallback fn) { + super.setState(fn); + widget.onBuildScheduled?.call(); + } + + @override + Widget build(BuildContext context) { + widget.buildTracker?.value += 1; + widget.elementTracker?.value = context as Element; + + widget.onBuild?.call(); + + return widget.child != null ? widget.child! : widget.builder!.call(context); + } +} + +/// Content layer that can be told to rebuild from the outside, and also tracks its build count. +class _RebuildableContentLayerWidget extends ContentLayerStatefulWidget { + const _RebuildableContentLayerWidget({ + Key? key, + this.rebuildSignal, + this.buildTracker, + this.elementTracker, + // TODO(srawlins): `unused_element`, when reporting a parameter, is being + // renamed to `unused_element_parameter`. For now, ignore each; when the SDK + // constraint is >= 3.6.0, just ignore `unused_element_parameter`. + // ignore: unused_element, unused_element_parameter + this.onBuildScheduled, + this.onBuild, + this.builder, + this.child, + }) : assert(child != null || builder != null, "Must provide either a child OR a builder."), + assert(child == null || builder == null, "Can't provide a child AND a builder. Choose one."), + super(key: key); + + /// Signal that instructs this widget to call `setState()`. + final Listenable? rebuildSignal; + + /// The number of times this widget has run `build()`. + final ValueNotifier? buildTracker; + + /// The [Element] that currently owns this `Widget` and its `State`. + final ValueNotifier? elementTracker; + + /// Callback that's invoked when this widget calls `setState()`. + final VoidCallback? onBuildScheduled; + + /// Callback that's invoked during this widget's `build()` method. + final VoidCallback? onBuild; + + final WidgetBuilder? builder; + final Widget? child; + + @override + ContentLayerState createState() => _RebuildableContentLayerWidgetState(); +} + +class _RebuildableContentLayerWidgetState extends ContentLayerState<_RebuildableContentLayerWidget, Object> { + @override + void initState() { + super.initState(); + widget.rebuildSignal?.addListener(_onRebuildSignal); + } + + @override + void didUpdateWidget(_RebuildableContentLayerWidget oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.rebuildSignal != oldWidget.rebuildSignal) { + oldWidget.rebuildSignal?.removeListener(_onRebuildSignal); + widget.rebuildSignal?.addListener(_onRebuildSignal); + } + } + + @override + void dispose() { + widget.rebuildSignal?.removeListener(_onRebuildSignal); + super.dispose(); + } + + void _onRebuildSignal() { + setState(() { + // rebuild + }); + + // Explicitly mark our RenderObject as needing layout so that we simulate content + // that rebuilds because its layout changed. Without this call, we'd get a widget + // rebuild, but we wouldn't trigger another content layout pass. We want that + // layout pass so that our tests can inspect the order of operations and ensure that + // when the content layout changes, the content is always laid out before layers. + context.findRenderObject()?.markNeedsLayout(); + } + + // This override is a regrettable requirement for ContentLayers, which is needed so + // that ContentLayers can remove the layers to prevent them from building during a + // regular build phase when the content changes. This is the result of Flutter making + // it impossible to monitor dirty subtrees, and making it impossible to control build + // order. + @override + void setState(VoidCallback fn) { + super.setState(fn); + widget.onBuildScheduled?.call(); + } + + @override + Object? computeLayoutData(Element? contentElement, RenderObject? contentLayout) => null; + + @override + Widget doBuild(BuildContext context, Object? object) { + widget.buildTracker?.value += 1; + widget.elementTracker?.value = context as Element; + + widget.onBuild?.call(); + + return widget.child != null ? widget.child! : widget.builder!.call(context); + } +} + +/// Widget that reports every time it runs layout. +class _LayoutTrackingWidget extends SingleChildRenderObjectWidget { + const _LayoutTrackingWidget({ + required this.onLayout, + required Widget child, + }) : super(child: child); + + final VoidCallback onLayout; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderLayoutTrackingWidget(onLayout); + } +} + +class _RenderLayoutTrackingWidget extends RenderProxySliver { + _RenderLayoutTrackingWidget(this._onLayout); + + final VoidCallback _onLayout; + + @override + void performLayout() { + _onLayout(); + super.performLayout(); + } +}