diff --git a/packages/flutter/lib/src/rendering/object.dart b/packages/flutter/lib/src/rendering/object.dart index 50ad60b44d..c517ae1a64 100644 --- a/packages/flutter/lib/src/rendering/object.dart +++ b/packages/flutter/lib/src/rendering/object.dart @@ -121,22 +121,38 @@ class PaintingContext extends ClipContext { if (childLayer == null) { assert(debugAlsoPaintedParent); assert(child._layerHandle.layer == null); + // Not using the `layer` setter because the setter asserts that we not // replace the layer for repaint boundaries. That assertion does not // apply here because this is exactly the place designed to create a // layer for repaint boundaries. - final OffsetLayer layer = OffsetLayer(); + final OffsetLayer layer = child.updateCompositedLayer(oldLayer: null); child._layerHandle.layer = childLayer = layer; } else { assert(debugAlsoPaintedParent || childLayer.attached); + Offset? debugOldOffset; + assert(() { + debugOldOffset = childLayer!.offset; + return true; + }()); childLayer.removeAllChildren(); + final OffsetLayer updatedLayer = child.updateCompositedLayer(oldLayer: childLayer); + assert(identical(updatedLayer, childLayer), + '$child created a new layer instance $updatedLayer instead of reusing the ' + 'existing layer $childLayer. See the documentation of RenderObject.updateCompositedLayer ' + 'for more information on how to correctly implement this method.' + ); + assert(debugOldOffset == updatedLayer.offset); } + child._needsCompositedLayerUpdate = false; + assert(identical(childLayer, child._layerHandle.layer)); assert(child._layerHandle.layer is OffsetLayer); assert(() { childLayer!.debugCreator = child.debugCreator ?? child.runtimeType; return true; }()); + childContext ??= PaintingContext(childLayer, child.paintBounds); child._paintWithContext(childContext, Offset.zero); @@ -146,6 +162,38 @@ class PaintingContext extends ClipContext { childContext.stopRecordingIfNeeded(); } + /// Update the composited layer of [child] without repainting its children. + /// + /// The render object must be attached to a [PipelineOwner], must have a + /// composited layer, and must be in need of a composited layer update but + /// not in need of painting. The render object's layer is re-used, and none + /// of its children are repaint or their layers updated. + /// + /// See also: + /// + /// * [RenderObject.isRepaintBoundary], which determines if a [RenderObject] + /// has a composited layer. + static void updateLayerProperties(RenderObject child) { + assert(child.isRepaintBoundary && child._wasRepaintBoundary); + assert(!child._needsPaint); + assert(child._layerHandle.layer != null); + + final OffsetLayer childLayer = child._layerHandle.layer! as OffsetLayer; + Offset? debugOldOffset; + assert(() { + debugOldOffset = childLayer.offset; + return true; + }()); + final OffsetLayer updatedLayer = child.updateCompositedLayer(oldLayer: childLayer); + assert(identical(updatedLayer, childLayer), + '$child created a new layer instance $updatedLayer instead of reusing the ' + 'existing layer $childLayer. See the documentation of RenderObject.updateCompositedLayer ' + 'for more information on how to correctly implement this method.' + ); + assert(debugOldOffset == updatedLayer.offset); + child._needsCompositedLayerUpdate = false; + } + /// In debug mode, repaint the given render object using a custom painting /// context that can record the results of the painting operation in addition /// to performing the regular paint of the child. @@ -183,6 +231,12 @@ class PaintingContext extends ClipContext { if (child.isRepaintBoundary) { stopRecordingIfNeeded(); _compositeChild(child, offset); + // If a render object was a repaint boundary but no longer is one, this + // is where the framework managed layer is automatically disposed. + } else if (child._wasRepaintBoundary) { + assert(child._layerHandle.layer is OffsetLayer); + child._layerHandle.layer = null; + child._paintWithContext(this, offset); } else { child._paintWithContext(this, offset); } @@ -194,9 +248,12 @@ class PaintingContext extends ClipContext { assert(_canvas == null || _canvas!.getSaveCount() == 1); // Create a layer for our child, and paint the child into it. - if (child._needsPaint) { + if (child._needsPaint || !child._wasRepaintBoundary) { repaintCompositedChild(child, debugAlsoPaintedParent: true); } else { + if (child._needsCompositedLayerUpdate) { + updateLayerProperties(child); + } assert(() { // register the call for RepaintBoundary metrics child.debugRegisterRepaintBoundaryPaint(); @@ -978,19 +1035,25 @@ class PipelineOwner { arguments: debugTimelineArguments, ); } - assert(() { - _debugDoingPaint = true; - return true; - }()); try { + assert(() { + _debugDoingPaint = true; + return true; + }()); final List dirtyNodes = _nodesNeedingPaint; _nodesNeedingPaint = []; + // Sort the dirty nodes in reverse order (deepest first). for (final RenderObject node in dirtyNodes..sort((RenderObject a, RenderObject b) => b.depth - a.depth)) { assert(node._layerHandle.layer != null); - if (node._needsPaint && node.owner == this) { + if ((node._needsPaint || node._needsCompositedLayerUpdate) && node.owner == this) { if (node._layerHandle.layer!.attached) { - PaintingContext.repaintCompositedChild(node); + assert(node.isRepaintBoundary); + if (node._needsPaint) { + PaintingContext.repaintCompositedChild(node); + } else { + PaintingContext.updateLayerProperties(node); + } } else { node._skippedPaintingOnLayer(); } @@ -1236,6 +1299,7 @@ abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin im /// Initializes internal fields for subclasses. RenderObject() { _needsCompositing = isRepaintBoundary || alwaysNeedsCompositing; + _wasRepaintBoundary = isRepaintBoundary; } /// Cause the entire subtree rooted at the given [RenderObject] to be marked @@ -2070,12 +2134,13 @@ abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin im /// to repaint. /// /// If this getter returns true, the [paintBounds] are applied to this object - /// and all descendants. The framework automatically creates an [OffsetLayer] - /// and assigns it to the [layer] field. Render objects that declare - /// themselves as repaint boundaries must not replace the layer created by - /// the framework. + /// and all descendants. The framework invokes [RenderObject.updateCompositedLayer] + /// to create an [OffsetLayer] and assigns it to the [layer] field. + /// Render objects that declare themselves as repaint boundaries must not replace + /// the layer created by the framework. /// - /// Warning: This getter must not change value over the lifetime of this object. + /// If the value of this getter changes, [markNeedsCompositingBitsUpdate] must + /// be called. /// /// See [RepaintBoundary] for more information about how repaint boundaries function. bool get isRepaintBoundary => false; @@ -2098,6 +2163,34 @@ abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin im @protected bool get alwaysNeedsCompositing => false; + late bool _wasRepaintBoundary; + + /// Update the composited layer owned by this render object. + /// + /// This method is called by the framework when [isRepaintBoundary] is true. + /// + /// If [oldLayer] is `null`, this method must return a new [OffsetLayer] + /// (or subtype thereof). If [oldLayer] is not `null`, then this method must + /// reuse the layer instance that is provided - it is an error to create a new + /// layer in this instance. The layer will be disposed by the framework when + /// either the render object is disposed or if it is no longer a repaint + /// boundary. + /// + /// The [OffsetLayer.offset] property will be managed by the framework and + /// must not be updated by this method. + /// + /// If a property of the composited layer needs to be updated, the render object + /// must call [markNeedsCompositedLayerUpdate] which will schedule this method + /// to be called without repainting children. If this widget was marked as + /// needing to paint and needing a composited layer update, this method is only + /// called once. + // TODO(jonahwilliams): https://github.com/flutter/flutter/issues/102102 revisit the + // contraint that the instance/type of layer cannot be changed at runtime. + OffsetLayer updateCompositedLayer({required covariant OffsetLayer? oldLayer}) { + assert(isRepaintBoundary); + return oldLayer ?? OffsetLayer(); + } + /// The compositing layer that this render object uses to repaint. /// /// If this render object is not a repaint boundary, it is the responsibility @@ -2184,7 +2277,8 @@ abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin im final RenderObject parent = this.parent! as RenderObject; if (parent._needsCompositingBitsUpdate) return; - if (!isRepaintBoundary && !parent.isRepaintBoundary) { + + if ((!_wasRepaintBoundary || !isRepaintBoundary) && !parent.isRepaintBoundary) { parent.markNeedsCompositingBitsUpdate(); return; } @@ -2225,9 +2319,23 @@ abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin im }); if (isRepaintBoundary || alwaysNeedsCompositing) _needsCompositing = true; - if (oldNeedsCompositing != _needsCompositing) + // If a node was previously a repaint boundary, but no longer is one, then + // regardless of its compositing state we need to find a new parent to + // paint from. To do this, we mark it clean again so that the traversal + // in markNeedsPaint is not short-circuited. It is removed from _nodesNeedingPaint + // so that we do not attempt to paint from it after locating a parent. + if (!isRepaintBoundary && _wasRepaintBoundary) { + _needsPaint = false; + _needsCompositedLayerUpdate = false; + owner?._nodesNeedingPaint.remove(this); + _needsCompositingBitsUpdate = false; markNeedsPaint(); - _needsCompositingBitsUpdate = false; + } else if (oldNeedsCompositing != _needsCompositing) { + _needsCompositingBitsUpdate = false; + markNeedsPaint(); + } else { + _needsCompositingBitsUpdate = false; + } } /// Whether this render object's paint information is dirty. @@ -2254,6 +2362,24 @@ abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin im } bool _needsPaint = true; + /// Whether this render object's layer information is dirty. + /// + /// This is only set in debug mode. In general, render objects should not need + /// to condition their runtime behavior on whether they are dirty or not, + /// since they should only be marked dirty immediately prior to being laid + /// out and painted. (In release builds, this throws.) + /// + /// It is intended to be used by tests and asserts. + bool get debugNeedsCompositedLayerUpdate { + late bool result; + assert(() { + result = _needsCompositedLayerUpdate; + return true; + }()); + return result; + } + bool _needsCompositedLayerUpdate = false; + /// Mark this render object as having changed its visual appearance. /// /// Rather than eagerly updating this render object's display list @@ -2280,7 +2406,9 @@ abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin im if (_needsPaint) return; _needsPaint = true; - if (isRepaintBoundary) { + // If this was not previously a repaint boundary it will not have + // a layer we can paint from. + if (isRepaintBoundary && _wasRepaintBoundary) { assert(() { if (debugPrintMarkNeedsPaintStacks) debugPrintStack(label: 'markNeedsPaint() called for $this'); @@ -2312,6 +2440,45 @@ abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin im } } + /// Mark this render object as having changed a property on its composited + /// layer. + /// + /// Render objects that have a composited layer have [isRepaintBoundary] equal + /// to true may update the properties of that composited layer without repainting + /// their children. If this render object is a repaint boundary but does + /// not yet have a composited layer created for it, this method will instead + /// mark the nearest repaint boundary parent as needing to be painted. + /// + /// If this method is called on a render object that is not a repaint boundary + /// or is a repaint boundary but hasn't been composited yet, it is equivalent + /// to calling [markNeedsPaint]. + /// + /// See also: + /// + /// * [RenderOpacity], which uses this method when its opacity is updated to + /// update the layer opacity without repainting children. + void markNeedsCompositedLayerUpdate() { + assert(!_debugDisposed); + assert(owner == null || !owner!.debugDoingPaint); + if (_needsCompositedLayerUpdate || _needsPaint) { + return; + } + _needsCompositedLayerUpdate = true; + // If this was not previously a repaint boundary it will not have + // a layer we can paint from. + if (isRepaintBoundary && _wasRepaintBoundary) { + // If we always have our own layer, then we can just repaint + // ourselves without involving any other nodes. + assert(_layerHandle.layer != null); + if (owner != null) { + owner!._nodesNeedingPaint.add(this); + owner!.requestVisualUpdate(); + } + } else { + markNeedsPaint(); + } + } + // Called when flushPaint() tries to make us paint but our layer is detached. // To make sure that our subtree is repainted when it's finally reattached, // even in the case where some ancestor layer is itself never marked dirty, we @@ -2320,7 +2487,7 @@ abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin im void _skippedPaintingOnLayer() { assert(attached); assert(isRepaintBoundary); - assert(_needsPaint); + assert(_needsPaint || _needsCompositedLayerUpdate); assert(_layerHandle.layer != null); assert(!_layerHandle.layer!.attached); AbstractNode? node = parent; @@ -2475,6 +2642,8 @@ abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin im return true; }()); _needsPaint = false; + _needsCompositedLayerUpdate = false; + _wasRepaintBoundary = isRepaintBoundary; try { paint(context, offset); assert(!_needsLayout); // check that the paint() method didn't mark us dirty again diff --git a/packages/flutter/lib/src/rendering/proxy_box.dart b/packages/flutter/lib/src/rendering/proxy_box.dart index 2b62dafa78..7e45a9d82d 100644 --- a/packages/flutter/lib/src/rendering/proxy_box.dart +++ b/packages/flutter/lib/src/rendering/proxy_box.dart @@ -843,7 +843,14 @@ class RenderOpacity extends RenderProxyBox { super(child); @override - bool get alwaysNeedsCompositing => child != null && (_alpha > 0); + bool get isRepaintBoundary => child != null && (_alpha > 0); + + @override + OffsetLayer updateCompositedLayer({required covariant OpacityLayer? oldLayer}) { + final OpacityLayer updatedLayer = oldLayer ?? OpacityLayer(); + updatedLayer.alpha = _alpha; + return updatedLayer; + } int _alpha; @@ -864,13 +871,13 @@ class RenderOpacity extends RenderProxyBox { assert(value >= 0.0 && value <= 1.0); if (_opacity == value) return; - final bool didNeedCompositing = alwaysNeedsCompositing; + final bool wasRepaintBoundary = isRepaintBoundary; final bool wasVisible = _alpha != 0; _opacity = value; _alpha = ui.Color.getAlphaFromOpacity(_opacity); - if (didNeedCompositing != alwaysNeedsCompositing) + if (wasRepaintBoundary != isRepaintBoundary) markNeedsCompositingBitsUpdate(); - markNeedsPaint(); + markNeedsCompositedLayerUpdate(); if (wasVisible != (_alpha != 0) && !alwaysIncludeSemantics) markNeedsSemanticsUpdate(); } @@ -891,19 +898,10 @@ class RenderOpacity extends RenderProxyBox { @override void paint(PaintingContext context, Offset offset) { - if (child != null) { - if (_alpha == 0) { - // No need to keep the layer. We'll create a new one if necessary. - layer = null; - return; - } - assert(needsCompositing); - layer = context.pushOpacity(offset, _alpha, super.paint, oldLayer: layer as OpacityLayer?); - assert(() { - layer!.debugCreator = debugCreator; - return true; - }()); + if (_alpha == 0) { + return; } + super.paint(context, offset); } @override @@ -929,8 +927,15 @@ mixin RenderAnimatedOpacityMixin on RenderObjectWithChil int? _alpha; @override - bool get alwaysNeedsCompositing => child != null && _currentlyNeedsCompositing!; - bool? _currentlyNeedsCompositing; + bool get isRepaintBoundary => child != null && _currentlyIsRepaintBoundary!; + bool? _currentlyIsRepaintBoundary; + + @override + OffsetLayer updateCompositedLayer({required covariant OpacityLayer? oldLayer}) { + final OpacityLayer updatedLayer = oldLayer ?? OpacityLayer(); + updatedLayer.alpha = _alpha; + return updatedLayer; + } /// The animation that drives this render object's opacity. /// @@ -990,11 +995,11 @@ mixin RenderAnimatedOpacityMixin on RenderObjectWithChil final int? oldAlpha = _alpha; _alpha = ui.Color.getAlphaFromOpacity(opacity.value); if (oldAlpha != _alpha) { - final bool? didNeedCompositing = _currentlyNeedsCompositing; - _currentlyNeedsCompositing = _alpha! > 0; - if (child != null && didNeedCompositing != _currentlyNeedsCompositing) + final bool? wasRepaintBoundary = _currentlyIsRepaintBoundary; + _currentlyIsRepaintBoundary = _alpha! > 0; + if (child != null && wasRepaintBoundary != _currentlyIsRepaintBoundary) markNeedsCompositingBitsUpdate(); - markNeedsPaint(); + markNeedsCompositedLayerUpdate(); if (oldAlpha == 0 || _alpha == 0) markNeedsSemanticsUpdate(); } @@ -1002,19 +1007,10 @@ mixin RenderAnimatedOpacityMixin on RenderObjectWithChil @override void paint(PaintingContext context, Offset offset) { - if (child != null) { - if (_alpha == 0) { - // No need to keep the layer. We'll create a new one if necessary. - layer = null; - return; - } - assert(needsCompositing); - layer = context.pushOpacity(offset, _alpha!, super.paint, oldLayer: layer as OpacityLayer?); - assert(() { - layer!.debugCreator = debugCreator; - return true; - }()); + if (_alpha == 0) { + return; } + super.paint(context, offset); } @override diff --git a/packages/flutter/lib/src/widgets/heroes.dart b/packages/flutter/lib/src/widgets/heroes.dart index 8cb222f24f..253a487ba1 100644 --- a/packages/flutter/lib/src/widgets/heroes.dart +++ b/packages/flutter/lib/src/widgets/heroes.dart @@ -545,11 +545,9 @@ class _HeroFlight { bottom: offsets.bottom, left: offsets.left, child: IgnorePointer( - child: RepaintBoundary( - child: FadeTransition( - opacity: _heroOpacity, - child: child, - ), + child: FadeTransition( + opacity: _heroOpacity, + child: child, ), ), ); diff --git a/packages/flutter/test/rendering/debug_test.dart b/packages/flutter/test/rendering/debug_test.dart index 87c69d7f5e..b31703e6c5 100644 --- a/packages/flutter/test/rendering/debug_test.dart +++ b/packages/flutter/test/rendering/debug_test.dart @@ -231,7 +231,8 @@ void main() { rootLayer, const Rect.fromLTWH(0, 0, 500, 500), ); - root.paint(context, const Offset(40, 40)); + context.paintChild(root, const Offset(40, 40)); + final OpacityLayer opacityLayer = rootLayer.firstChild! as OpacityLayer; expect(opacityLayer.offset, const Offset(40, 40)); debugDisableOpacityLayers = false; diff --git a/packages/flutter/test/rendering/proxy_box_test.dart b/packages/flutter/test/rendering/proxy_box_test.dart index 28b1a0fb64..70dfff9df4 100644 --- a/packages/flutter/test/rendering/proxy_box_test.dart +++ b/packages/flutter/test/rendering/proxy_box_test.dart @@ -561,6 +561,178 @@ void main() { // The follower is still hit testable because there is a leader layer. expect(follower.hitTest(hitTestResult, position: Offset.zero), isTrue); }); + + test('RenderObject can become a repaint boundary', () { + final ConditionalRepaintBoundary childBox = ConditionalRepaintBoundary(); + final ConditionalRepaintBoundary renderBox = ConditionalRepaintBoundary(child: childBox); + + layout(renderBox, phase: EnginePhase.composite); + + expect(childBox.paintCount, 1); + expect(renderBox.paintCount, 1); + + renderBox.isRepaintBoundary = true; + renderBox.markNeedsCompositingBitsUpdate(); + renderBox.markNeedsCompositedLayerUpdate(); + + pumpFrame(phase: EnginePhase.composite); + + // The first time the render object becomes a repaint boundary + // we must repaint from the parent to allow the layer to be + // created. + expect(childBox.paintCount, 2); + expect(renderBox.paintCount, 2); + expect(renderBox.debugLayer, isA()); + + renderBox.markNeedsCompositedLayerUpdate(); + expect(renderBox.debugNeedsPaint, false); + expect(renderBox.debugNeedsCompositedLayerUpdate, true); + + pumpFrame(phase: EnginePhase.composite); + + // The second time the layer exists and we can skip paint. + expect(childBox.paintCount, 2); + expect(renderBox.paintCount, 2); + expect(renderBox.debugLayer, isA()); + + renderBox.isRepaintBoundary = false; + renderBox.markNeedsCompositingBitsUpdate(); + + pumpFrame(phase: EnginePhase.composite); + + // Once it stops being a repaint boundary we must repaint to + // remove the layer. its required that the render object + // perform this action in paint. + expect(childBox.paintCount, 3); + expect(renderBox.paintCount, 3); + expect(renderBox.debugLayer, null); + + // When the render object is not a repaint boundary, calling + // markNeedsLayerPropertyUpdate is the same as calling + // markNeedsPaint. + + renderBox.markNeedsCompositedLayerUpdate(); + expect(renderBox.debugNeedsPaint, true); + expect(renderBox.debugNeedsCompositedLayerUpdate, true); + }); + + test('RenderObject with repaint boundary asserts when a composited layer is replaced during layer property update', () { + final ConditionalRepaintBoundary childBox = ConditionalRepaintBoundary(isRepaintBoundary: true); + final ConditionalRepaintBoundary renderBox = ConditionalRepaintBoundary(child: childBox); + + // Ignore old layer. + childBox.offsetLayerFactory = (OffsetLayer? oldLayer) { + return TestOffsetLayerA(); + }; + + layout(renderBox, phase: EnginePhase.composite); + + expect(childBox.paintCount, 1); + expect(renderBox.paintCount, 1); + + renderBox.markNeedsCompositedLayerUpdate(); + + pumpFrame(phase: EnginePhase.composite, onErrors: expectAssertionError); + }, skip: kIsWeb); // https://github.com/flutter/flutter/issues/102086 + + test('RenderObject with repaint boundary asserts when a composited layer is replaced during painting', () { + final ConditionalRepaintBoundary childBox = ConditionalRepaintBoundary(isRepaintBoundary: true); + final ConditionalRepaintBoundary renderBox = ConditionalRepaintBoundary(child: childBox); + + // Ignore old layer. + childBox.offsetLayerFactory = (OffsetLayer? oldLayer) { + return TestOffsetLayerA(); + }; + + layout(renderBox, phase: EnginePhase.composite); + + expect(childBox.paintCount, 1); + expect(renderBox.paintCount, 1); + renderBox.markNeedsPaint(); + + pumpFrame(phase: EnginePhase.composite, onErrors: expectAssertionError); + }, skip: kIsWeb); // https://github.com/flutter/flutter/issues/102086 + + test('RenderObject with repaint boundary asserts when a composited layer tries to update its own offset', () { + final ConditionalRepaintBoundary childBox = ConditionalRepaintBoundary(isRepaintBoundary: true); + final ConditionalRepaintBoundary renderBox = ConditionalRepaintBoundary(child: childBox); + + // Ignore old layer. + childBox.offsetLayerFactory = (OffsetLayer? oldLayer) { + return (oldLayer ?? TestOffsetLayerA())..offset = const Offset(2133, 4422); + }; + + layout(renderBox, phase: EnginePhase.composite); + + expect(childBox.paintCount, 1); + expect(renderBox.paintCount, 1); + renderBox.markNeedsPaint(); + + pumpFrame(phase: EnginePhase.composite, onErrors: expectAssertionError); + }, skip: kIsWeb); // https://github.com/flutter/flutter/issues/102086 + + test('RenderObject markNeedsPaint while repaint boundary, and then updated to no longer be a repaint boundary with ' + 'calling markNeedsCompositingBitsUpdate 1', () { + final ConditionalRepaintBoundary childBox = ConditionalRepaintBoundary(isRepaintBoundary: true); + final ConditionalRepaintBoundary renderBox = ConditionalRepaintBoundary(child: childBox); + // Ignore old layer. + childBox.offsetLayerFactory = (OffsetLayer? oldLayer) { + return oldLayer ?? TestOffsetLayerA(); + }; + + layout(renderBox, phase: EnginePhase.composite); + + expect(childBox.paintCount, 1); + expect(renderBox.paintCount, 1); + + childBox.markNeedsPaint(); + childBox.isRepaintBoundary = false; + childBox.markNeedsCompositingBitsUpdate(); + + expect(() => pumpFrame(phase: EnginePhase.composite), returnsNormally); + }); + + test('RenderObject markNeedsPaint while repaint boundary, and then updated to no longer be a repaint boundary with ' + 'calling markNeedsCompositingBitsUpdate 2', () { + final ConditionalRepaintBoundary childBox = ConditionalRepaintBoundary(isRepaintBoundary: true); + final ConditionalRepaintBoundary renderBox = ConditionalRepaintBoundary(child: childBox); + // Ignore old layer. + childBox.offsetLayerFactory = (OffsetLayer? oldLayer) { + return oldLayer ?? TestOffsetLayerA(); + }; + + layout(renderBox, phase: EnginePhase.composite); + + expect(childBox.paintCount, 1); + expect(renderBox.paintCount, 1); + + childBox.isRepaintBoundary = false; + childBox.markNeedsCompositingBitsUpdate(); + childBox.markNeedsPaint(); + + expect(() => pumpFrame(phase: EnginePhase.composite), returnsNormally); + }); + + test('RenderObject markNeedsPaint while repaint boundary, and then updated to no longer be a repaint boundary with ' + 'calling markNeedsCompositingBitsUpdate 3', () { + final ConditionalRepaintBoundary childBox = ConditionalRepaintBoundary(isRepaintBoundary: true); + final ConditionalRepaintBoundary renderBox = ConditionalRepaintBoundary(child: childBox); + // Ignore old layer. + childBox.offsetLayerFactory = (OffsetLayer? oldLayer) { + return oldLayer ?? TestOffsetLayerA(); + }; + + layout(renderBox, phase: EnginePhase.composite); + + expect(childBox.paintCount, 1); + expect(renderBox.paintCount, 1); + + childBox.isRepaintBoundary = false; + childBox.markNeedsCompositedLayerUpdate(); + childBox.markNeedsCompositingBitsUpdate(); + + expect(() => pumpFrame(phase: EnginePhase.composite), returnsNormally); + }); } class _TestRectClipper extends CustomClipper { @@ -631,3 +803,38 @@ class _TestSemanticsUpdateRenderFractionalTranslation extends RenderFractionalTr super.markNeedsSemanticsUpdate(); } } + +class ConditionalRepaintBoundary extends RenderProxyBox { + ConditionalRepaintBoundary({this.isRepaintBoundary = false, RenderBox? child}) : super(child); + + @override + bool isRepaintBoundary = false; + + OffsetLayer Function(OffsetLayer?)? offsetLayerFactory; + + int paintCount = 0; + + @override + OffsetLayer updateCompositedLayer({required covariant OffsetLayer? oldLayer}) { + if (offsetLayerFactory != null) { + return offsetLayerFactory!.call(oldLayer); + } + return super.updateCompositedLayer(oldLayer: oldLayer); + } + + @override + void paint(PaintingContext context, Offset offset) { + paintCount += 1; + super.paint(context, offset); + } +} + +class TestOffsetLayerA extends OffsetLayer {} + +void expectAssertionError() { + final FlutterErrorDetails errorDetails = TestRenderingFlutterBinding.instance.takeFlutterErrorDetails()!; + final bool asserted = errorDetails.toString().contains('Failed assertion'); + if (!asserted) { + FlutterError.reportError(errorDetails); + } +} diff --git a/packages/flutter/test/widgets/animated_opacity_repaint_test.dart b/packages/flutter/test/widgets/animated_opacity_repaint_test.dart new file mode 100644 index 0000000000..664a118df2 --- /dev/null +++ b/packages/flutter/test/widgets/animated_opacity_repaint_test.dart @@ -0,0 +1,90 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('RenderAnimatedOpacityMixin avoids repainting child as it animates', (WidgetTester tester) async { + RenderTestObject.paintCount = 0; + final AnimationController controller = AnimationController(vsync: const TestVSync(), duration: const Duration(seconds: 1)); + final Tween opacityTween = Tween(begin: 0, end: 1); + await tester.pumpWidget( + Container( + color: Colors.red, + child: FadeTransition( + opacity: controller.drive(opacityTween), + child: const TestWidget(), + ), + ) + ); + + expect(RenderTestObject.paintCount, 0); + controller.forward(); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(RenderTestObject.paintCount, 1); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(RenderTestObject.paintCount, 1); + + controller.stop(); + await tester.pump(); + + expect(RenderTestObject.paintCount, 1); + }); + + testWidgets('RenderAnimatedOpacityMixin allows opacity layer to be disposed when animating to 0 opacity', (WidgetTester tester) async { + RenderTestObject.paintCount = 0; + final AnimationController controller = AnimationController(vsync: const TestVSync(), duration: const Duration(seconds: 1)); + final Tween opacityTween = Tween(begin: 1, end: 0); + await tester.pumpWidget( + Container( + color: Colors.red, + child: FadeTransition( + opacity: controller.drive(opacityTween), + child: const TestWidget(), + ), + ) + ); + + expect(RenderTestObject.paintCount, 1); + expect(tester.layers, contains(isA())); + controller.forward(); + + await tester.pump(); + await tester.pump(const Duration(seconds: 2)); + + expect(RenderTestObject.paintCount, 1); + + controller.stop(); + await tester.pump(); + + expect(tester.layers, isNot(contains(isA()))); + }); +} + +class TestWidget extends SingleChildRenderObjectWidget { + const TestWidget({super.key, super.child}); + + @override + RenderObject createRenderObject(BuildContext context) { + return RenderTestObject(); + } +} + +class RenderTestObject extends RenderProxyBox { + static int paintCount = 0; + + @override + void paint(PaintingContext context, Offset offset) { + paintCount += 1; + super.paint(context, offset); + } +} diff --git a/packages/flutter/test/widgets/opacity_repaint_test.dart b/packages/flutter/test/widgets/opacity_repaint_test.dart new file mode 100644 index 0000000000..00ca2964d3 --- /dev/null +++ b/packages/flutter/test/widgets/opacity_repaint_test.dart @@ -0,0 +1,275 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('RenderOpacity acts as a repaint boundary for changes above the widget when partially opaque', (WidgetTester tester) async { + RenderTestObject.paintCount = 0; + await tester.pumpWidget( + Container( + color: Colors.red, + child: const Opacity( + opacity: 0.5, + child: TestWidget(), + ), + ) + ); + + expect(RenderTestObject.paintCount, 1); + + await tester.pumpWidget( + Container( + color: Colors.blue, + child: const Opacity( + opacity: 0.5, + child: TestWidget(), + ), + ) + ); + + expect(RenderTestObject.paintCount, 1); + }); + + testWidgets('RenderOpacity acts as a repaint boundary for changes above the widget when fully opaque', (WidgetTester tester) async { + RenderTestObject.paintCount = 0; + await tester.pumpWidget( + Container( + color: Colors.red, + child: const Opacity( + opacity: 1, + child: TestWidget(), + ), + ) + ); + + expect(RenderTestObject.paintCount, 1); + + await tester.pumpWidget( + Container( + color: Colors.blue, + child: const Opacity( + opacity: 1, + child: TestWidget(), + ), + ) + ); + + expect(RenderTestObject.paintCount, 1); + }); + + testWidgets('RenderOpacity can update its opacity without repainting its child - partially opaque to partially opaque', (WidgetTester tester) async { + RenderTestObject.paintCount = 0; + await tester.pumpWidget( + Container( + color: Colors.red, + child: const Opacity( + opacity: 0.5, + child: TestWidget(), + ), + ) + ); + + expect(RenderTestObject.paintCount, 1); + + await tester.pumpWidget( + Container( + color: Colors.blue, + child: const Opacity( + opacity: 0.9, + child: TestWidget(), + ), + ) + ); + + expect(RenderTestObject.paintCount, 1); + }); + + testWidgets('RenderOpacity can update its opacity without repainting its child - partially opaque to fully opaque', (WidgetTester tester) async { + RenderTestObject.paintCount = 0; + await tester.pumpWidget( + Container( + color: Colors.red, + child: const Opacity( + opacity: 0.5, + child: TestWidget(), + ), + ) + ); + + expect(RenderTestObject.paintCount, 1); + + await tester.pumpWidget( + Container( + color: Colors.blue, + child: const Opacity( + opacity: 1, + child: TestWidget(), + ), + ) + ); + + expect(RenderTestObject.paintCount, 1); + }); + + testWidgets('RenderOpacity can update its opacity without repainting its child - fully opaque to partially opaque', (WidgetTester tester) async { + RenderTestObject.paintCount = 0; + await tester.pumpWidget( + Container( + color: Colors.red, + child: const Opacity( + opacity: 1, + child: TestWidget(), + ), + ) + ); + + expect(RenderTestObject.paintCount, 1); + + await tester.pumpWidget( + Container( + color: Colors.blue, + child: const Opacity( + opacity: 0.5, + child: TestWidget(), + ), + ) + ); + + expect(RenderTestObject.paintCount, 1); + }); + + testWidgets('RenderOpacity can update its opacity without repainting its child - fully opaque to fully transparent', (WidgetTester tester) async { + RenderTestObject.paintCount = 0; + await tester.pumpWidget( + Container( + color: Colors.red, + child: const Opacity( + opacity: 1, + child: TestWidget(), + ), + ) + ); + + expect(RenderTestObject.paintCount, 1); + + await tester.pumpWidget( + Container( + color: Colors.blue, + child: const Opacity( + opacity: 0, + child: TestWidget(), + ), + ) + ); + + expect(RenderTestObject.paintCount, 1); + }); + + testWidgets('RenderOpacity must paint child - fully transparent to partially opaque', (WidgetTester tester) async { + RenderTestObject.paintCount = 0; + await tester.pumpWidget( + Container( + color: Colors.red, + child: const Opacity( + opacity: 0, + child: TestWidget(), + ), + ) + ); + + expect(RenderTestObject.paintCount, 0); + + await tester.pumpWidget( + Container( + color: Colors.blue, + child: const Opacity( + opacity: 0.5, + child: TestWidget(), + ), + ) + ); + + expect(RenderTestObject.paintCount, 1); + }); + + testWidgets('RenderOpacity allows child to update without updating parent', (WidgetTester tester) async { + RenderTestObject.paintCount = 0; + await tester.pumpWidget( + TestWidget( + child: Opacity( + opacity: 0.5, + child: Container( + color: Colors.red, + ), + ), + ) + ); + + expect(RenderTestObject.paintCount, 1); + + await tester.pumpWidget( + TestWidget( + child: Opacity( + opacity: 0.5, + child: Container( + color: Colors.blue, + ), + ), + ) + ); + + expect(RenderTestObject.paintCount, 1); + }); + + testWidgets('RenderOpacity disposes of opacity layer when opacity is updated to 0', (WidgetTester tester) async { + RenderTestObject.paintCount = 0; + await tester.pumpWidget( + Container( + color: Colors.red, + child: const Opacity( + opacity: 0.5, + child: TestWidget(), + ), + ) + ); + + expect(RenderTestObject.paintCount, 1); + expect(tester.layers, contains(isA())); + + await tester.pumpWidget( + Container( + color: Colors.blue, + child: const Opacity( + opacity: 0, + child: TestWidget(), + ), + ) + ); + + expect(RenderTestObject.paintCount, 1); + expect(tester.layers, isNot(contains(isA()))); + }); +} + +class TestWidget extends SingleChildRenderObjectWidget { + const TestWidget({super.key, super.child}); + + @override + RenderObject createRenderObject(BuildContext context) { + return RenderTestObject(); + } +} + +class RenderTestObject extends RenderProxyBox { + static int paintCount = 0; + + @override + void paint(PaintingContext context, Offset offset) { + paintCount += 1; + super.paint(context, offset); + } +}