From e7266dbb079d31fff0f886518c6a1b25bf89bb94 Mon Sep 17 00:00:00 2001 From: YeungKC Date: Sat, 27 Mar 2021 10:39:01 +0800 Subject: [PATCH] Let InkWell/Ink/ancestor support GlobalKey so that splash does not stop when changing position. (#71138) --- .../lib/src/material/ink_decoration.dart | 14 +- .../flutter/lib/src/material/ink_well.dart | 82 ++++- .../flutter/lib/src/material/material.dart | 26 +- .../lib/src/material/mergeable_material.dart | 2 +- .../flutter/lib/src/widgets/framework.dart | 57 ++- .../flutter/test/material/ink_paint_test.dart | 63 ++++ .../flutter/test/material/ink_well_test.dart | 339 ++++++++++++------ .../flutter/test/widgets/framework_test.dart | 60 +++- 8 files changed, 499 insertions(+), 144 deletions(-) diff --git a/packages/flutter/lib/src/material/ink_decoration.dart b/packages/flutter/lib/src/material/ink_decoration.dart index b2eba34b8d..434f7bdda9 100644 --- a/packages/flutter/lib/src/material/ink_decoration.dart +++ b/packages/flutter/lib/src/material/ink_decoration.dart @@ -251,9 +251,21 @@ class _InkState extends State { @override void deactivate() { + _ink?.visible = false; + super.deactivate(); + } + + @override + void reactivate() { + _ink?.visible = true; + super.reactivate(); + } + + @override + void dispose() { _ink?.dispose(); assert(_ink == null); - super.deactivate(); + super.dispose(); } Widget _build(BuildContext context) { diff --git a/packages/flutter/lib/src/material/ink_well.dart b/packages/flutter/lib/src/material/ink_well.dart index a13f7e9be2..99f9235c5e 100644 --- a/packages/flutter/lib/src/material/ink_well.dart +++ b/packages/flutter/lib/src/material/ink_well.dart @@ -733,6 +733,7 @@ class _InkResponseState extends State<_InkResponseStateWidget> implements _ParentInkResponseState { Set? _splashes; InteractiveInkFeature? _currentSplash; + bool _active = true; bool _hovering = false; final Map<_HighlightType, InkHighlight?> _highlights = <_HighlightType, InkHighlight?>{}; late final Map> _actionMap = >{ @@ -779,7 +780,16 @@ class _InkResponseState extends State<_InkResponseStateWidget> @override void didUpdateWidget(_InkResponseStateWidget oldWidget) { super.didUpdateWidget(oldWidget); - if (_isWidgetEnabled(widget) != _isWidgetEnabled(oldWidget)) { + final InkFeature? validInkFeature = _getSingleInkFeature(); + if (validInkFeature != null && !identical(validInkFeature.controller, Material.of(context)!)) { + _removeAllFeatures(); + if (_hovering && enabled) + updateHighlight(_HighlightType.hover, value: _hovering, callOnHover: false); + _updateFocusHighlights(); + return; + } + + if (enabled != _isWidgetEnabled(oldWidget)) { if (enabled) { // Don't call wigdet.onHover because many wigets, including the button // widgets, apply setState to an ancestor context from onHover. @@ -789,12 +799,47 @@ class _InkResponseState extends State<_InkResponseStateWidget> } } + InkFeature? _getSingleInkFeature() { + final List inkFeatures = [...?_splashes, ..._highlights.values]; + assert(() { + MaterialInkController? lastController; + for (final InkFeature? inkFeature in inkFeatures) { + if (inkFeature == null) + continue; + final MaterialInkController controller = inkFeature.controller; + if (lastController != null && !identical(controller, lastController)) + return false; + lastController = controller; + } + return true; + }()); + final InkFeature? validInkFeature = inkFeatures.firstWhere((InkFeature? inkFeature) => inkFeature != null, orElse: () => null); + return validInkFeature; + } + @override void dispose() { + _removeAllFeatures(); FocusManager.instance.removeHighlightModeListener(_handleFocusHighlightModeChange); super.dispose(); } + void _removeAllFeatures() { + if (_splashes != null) { + final Set splashes = _splashes!; + _splashes = null; + for (final InteractiveInkFeature splash in splashes) + splash.dispose(); + _currentSplash = null; + } + assert(_currentSplash == null); + for (final _HighlightType highlight in _highlights.keys) { + _highlights[highlight]?.dispose(); + _highlights[highlight] = null; + } + widget.parentState?.markChildInkResponsePressed(this, false); + } + @override bool get wantKeepAlive => highlightsExist || (_splashes != null && _splashes!.isNotEmpty); @@ -830,7 +875,8 @@ class _InkResponseState extends State<_InkResponseStateWidget> void handleInkRemoval() { assert(_highlights[type] != null); _highlights[type] = null; - updateKeepAlive(); + if (_active) + updateKeepAlive(); } if (type == _HighlightType.pressed) { @@ -1013,24 +1059,26 @@ class _InkResponseState extends State<_InkResponseStateWidget> } } + void _setAllFeaturesVisible(bool visible) { + for (final InkFeature? splash in [...?_splashes, ..._highlights.values]) + splash?.visible = visible; + } + @override void deactivate() { - if (_splashes != null) { - final Set splashes = _splashes!; - _splashes = null; - for (final InteractiveInkFeature splash in splashes) - splash.dispose(); - _currentSplash = null; - } - assert(_currentSplash == null); - for (final _HighlightType highlight in _highlights.keys) { - _highlights[highlight]?.dispose(); - _highlights[highlight] = null; - } - widget.parentState?.markChildInkResponsePressed(this, false); + _active = !_active; + _setAllFeaturesVisible(false); super.deactivate(); } + @override + void reactivate() { + _active = !_active; + _setAllFeaturesVisible(true); + updateKeepAlive(); + super.reactivate(); + } + bool _isWidgetEnabled(_InkResponseStateWidget widget) { return widget.onTap != null || widget.onDoubleTap != null || widget.onLongPress != null; } @@ -1201,6 +1249,10 @@ class _InkResponseState extends State<_InkResponseStateWidget> /// during animation. You should avoid using InkWells within [Material] widgets /// that are changing size. /// +/// Animations triggered by an [InkWell] will survive their widget moving due +/// to [GlobalKey] reparenting, as long as the nearest [Material] ancestor is +/// the same before and after the move. +/// /// See also: /// /// * [GestureDetector], for listening for gestures without ink splashes. diff --git a/packages/flutter/lib/src/material/material.dart b/packages/flutter/lib/src/material/material.dart index 1e85a90545..98f686075f 100644 --- a/packages/flutter/lib/src/material/material.dart +++ b/packages/flutter/lib/src/material/material.dart @@ -544,12 +544,20 @@ class _RenderInkFeatures extends RenderProxyBox implements MaterialInkController canvas.save(); canvas.translate(offset.dx, offset.dy); canvas.clipRect(Offset.zero & size); - for (final InkFeature inkFeature in _inkFeatures!) - inkFeature._paint(canvas); + for (final InkFeature inkFeature in _inkFeatures!) { + if (inkFeature.visible) + inkFeature._paint(canvas); + } canvas.restore(); } super.paint(context, offset); } + + void dispose() { + // [InkFeature.dispose] will eventually call [_inkFeatures!.remove]. + while (_inkFeatures?.isNotEmpty == true) + _inkFeatures!.first.dispose(); + } } class _InkFeatures extends SingleChildRenderObjectWidget { @@ -585,6 +593,11 @@ class _InkFeatures extends SingleChildRenderObjectWidget { ..absorbHitTest = absorbHitTest; assert(vsync == renderObject.vsync); } + + @override + void didUnmountRenderObject(_RenderInkFeatures renderObject) { + renderObject.dispose(); + } } /// A visual reaction on a piece of [Material]. @@ -617,6 +630,15 @@ abstract class InkFeature { bool _debugDisposed = false; + /// Whether or not visual reaction is activated. + /// + /// Change this field will affect whether this InkFeature is render in next + /// frame. + /// + /// For this InkFeature to render properly, it should usually be change in + /// [State.deactivate] and [State.reactivate]. + bool visible = true; + /// Free up the resources associated with this ink feature. @mustCallSuper void dispose() { diff --git a/packages/flutter/lib/src/material/mergeable_material.dart b/packages/flutter/lib/src/material/mergeable_material.dart index d8b2c306fb..507d68b960 100644 --- a/packages/flutter/lib/src/material/mergeable_material.dart +++ b/packages/flutter/lib/src/material/mergeable_material.dart @@ -584,7 +584,6 @@ class _MergeableMaterialState extends State with TickerProvid } child = AnimatedContainer( - key: _MergeableMaterialSliceKey(_children[i].key), decoration: BoxDecoration(border: border), duration: kThemeAnimationDuration, curve: Curves.fastOutSlowIn, @@ -600,6 +599,7 @@ class _MergeableMaterialState extends State with TickerProvid shape: BoxShape.rectangle, ), child: Material( + key: _MergeableMaterialSliceKey(_children[i].key), type: MaterialType.transparency, child: child, ), diff --git a/packages/flutter/lib/src/widgets/framework.dart b/packages/flutter/lib/src/widgets/framework.dart index d3b343caf3..ffb94b8fa7 100644 --- a/packages/flutter/lib/src/widgets/framework.dart +++ b/packages/flutter/lib/src/widgets/framework.dart @@ -930,6 +930,12 @@ abstract class State with Diagnosticable { /// It is an error to call [setState] unless [mounted] is true. bool get mounted => _element != null; + /// This field is used tracks [reactivate] and [deactivate], to assert that + /// they are called alternatively. + /// + /// This field is not set in release mode. + bool _debugActive = true; + /// Called when this object is inserted into the tree. /// /// The framework will call this method exactly once for each [State] object @@ -1110,17 +1116,17 @@ abstract class State with Diagnosticable { _element!.markNeedsBuild(); } - /// Called when this object is removed from the tree. + /// Whenever the framework removes this [State] object from the tree, the + /// framework will call this method. /// - /// The framework calls this method whenever it removes this [State] object - /// from the tree. In some cases, the framework will reinsert the [State] - /// object into another part of the tree (e.g., if the subtree containing this - /// [State] object is grafted from one location in the tree to another). If - /// that happens, the framework will ensure that it calls [build] to give the - /// [State] object a chance to adapt to its new location in the tree. If - /// the framework does reinsert this subtree, it will do so before the end of - /// the animation frame in which the subtree was removed from the tree. For - /// this reason, [State] objects can defer releasing most resources until the + /// In some cases, the framework will reinsert the [State] object into + /// another part of the tree (e.g., if the subtree containing this [State] + /// object is grafted from one location in the tree to another). If that + /// happens, the framework will ensure that it calls [reactivate] to give the + /// [State] object a chance to adapt to its new location in the tree. If the + /// framework does reinsert this subtree, it will do so before the end of the + /// animation frame in which the subtree was removed from the tree. For this + /// reason, [State] objects can defer releasing most resources until the /// framework calls their [dispose] method. /// /// Subclasses should override this method to clean up any links between @@ -1136,7 +1142,35 @@ abstract class State with Diagnosticable { /// from the tree permanently. @protected @mustCallSuper - void deactivate() { } + void deactivate() { + assert(() { + _debugActive = !_debugActive; + return !_debugActive; + }()); + } + + /// Called when this object is reactivated. + /// + /// If the [widget] or one of its ancestors has a [GlobalKey], the framework + /// will mark this object as inactive when it is removed and call + /// [deactivate]. + /// + /// If the object is reinserted to the tree in the next frame (e.g. by + /// changing position), it will be marked as active again and this method will be + /// called. + /// + /// See also: + /// + /// * [Element.activate] and [Element.deactivate] for more information about + /// lifecycle. + @protected + @mustCallSuper + void reactivate() { + assert(() { + _debugActive = !_debugActive; + return _debugActive; + }()); + } /// Called when this object is removed from the tree permanently. /// @@ -4777,6 +4811,7 @@ class StatefulElement extends ComponentElement { @override void activate() { super.activate(); + state.reactivate(); // Since the State could have observed the deactivate() and thus disposed of // resources allocated in the build method, we have to rebuild the widget // so that its State can reallocate its resources. diff --git a/packages/flutter/test/material/ink_paint_test.dart b/packages/flutter/test/material/ink_paint_test.dart index cd08bd514f..19bc829846 100644 --- a/packages/flutter/test/material/ink_paint_test.dart +++ b/packages/flutter/test/material/ink_paint_test.dart @@ -431,4 +431,67 @@ void main() { throw 'Expected: paint.color.alpha == 0, found: ${paint.color.alpha}'; })); }); + + // Regression test for https://github.com/flutter/flutter/issues/6751 + testWidgets('When Ink has a GlobalKey and changes position, splash should not stop', (WidgetTester tester) async { + const Color color = Colors.blue; + const Color splashColor = Colors.green; + + void expectTest(bool painted) { + final PaintPattern paintPattern = paints + ..rect(color: Color(color.value)) + ..circle(color: Color(splashColor.value)); + + expect( + Material.of(tester.element(find.byType(InkWell)))! as RenderBox, + painted ? paintPattern : isNot(paintPattern), + ); + } + + bool wrap = false; + final Key globalKey = GlobalKey(); + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, void Function(void Function()) setState) { + Widget child = Ink( + key: globalKey, + color: color, + width: 200.0, + height: 200.0, + child: InkWell( + splashColor: splashColor, + onTap: () { }, + onTapDown: (_) async { + await Future.delayed(const Duration(milliseconds: 50)); + + setState(() { + wrap = !wrap; + }); + } + ), + ); + + if (wrap) { + child = Container( + margin: const EdgeInsets.only(top: 320), + child: child, + ); + } + + return child; + } + ), + ), + ), + )); + final TestGesture testGesture = await tester.startGesture(tester.getRect(find.byType(InkWell)).center); + await tester.pump(const Duration(milliseconds: 60)); + expectTest(true); + await testGesture.up(); + await tester.pumpAndSettle(); + expectTest(false); + }); } diff --git a/packages/flutter/test/material/ink_well_test.dart b/packages/flutter/test/material/ink_well_test.dart index 01e797ee6b..c0bf67f6d2 100644 --- a/packages/flutter/test/material/ink_well_test.dart +++ b/packages/flutter/test/material/ink_well_test.dart @@ -1055,118 +1055,6 @@ void main() { await gesture3.up(); }); - testWidgets('When ink wells are reparented, the old parent can display splash while the new parent can not', (WidgetTester tester) async { - final GlobalKey innerKey = GlobalKey(); - final GlobalKey leftKey = GlobalKey(); - final GlobalKey rightKey = GlobalKey(); - - Widget doubleInkWellRow({ - required double leftWidth, - required double rightWidth, - Widget? leftChild, - Widget? rightChild, - }) { - return Material( - child: Directionality( - textDirection: TextDirection.ltr, - child: Align( - alignment: Alignment.topLeft, - child: SizedBox( - width: leftWidth+rightWidth, - height: 100, - child: Row( - children: [ - SizedBox( - width: leftWidth, - height: 100, - child: InkWell( - key: leftKey, - onTap: () {}, - child: Center( - child: SizedBox( - width: leftWidth, - height: 50, - child: leftChild, - ), - ), - ), - ), - SizedBox( - width: rightWidth, - height: 100, - child: InkWell( - key: rightKey, - onTap: () {}, - child: Center( - child: SizedBox( - width: leftWidth, - height: 50, - child: rightChild, - ), - ), - ) - ), - ], - ), - ), - ), - ), - ); - } - - await tester.pumpWidget( - doubleInkWellRow( - leftWidth: 110, - rightWidth: 90, - leftChild: InkWell( - key: innerKey, - onTap: () {}, - ), - ), - ); - final MaterialInkController material = Material.of(tester.element(find.byKey(innerKey)))!; - - // Press inner - final TestGesture gesture = await tester.startGesture(const Offset(100, 50), pointer: 1); - await tester.pump(const Duration(milliseconds: 200)); - expect(material, paintsExactlyCountTimes(#drawCircle, 1)); - - // Switch side - await tester.pumpWidget( - doubleInkWellRow( - leftWidth: 90, - rightWidth: 110, - rightChild: InkWell( - key: innerKey, - onTap: () {}, - ), - ), - ); - expect(material, paintsExactlyCountTimes(#drawCircle, 0)); - - // A second pointer presses inner - final TestGesture gesture2 = await tester.startGesture(const Offset(100, 50), pointer: 2); - await tester.pump(const Duration(milliseconds: 200)); - expect(material, paintsExactlyCountTimes(#drawCircle, 1)); - - await gesture.up(); - await gesture2.up(); - await tester.pumpAndSettle(); - - // Press inner - await gesture.down(const Offset(100, 50)); - await tester.pump(const Duration(milliseconds: 200)); - expect(material, paintsExactlyCountTimes(#drawCircle, 1)); - - // Press left - await gesture2.down(const Offset(50, 50)); - await tester.pump(const Duration(milliseconds: 200)); - expect(material, paintsExactlyCountTimes(#drawCircle, 2)); - - await gesture.up(); - await gesture2.up(); - }); - testWidgets("Ink wells's splash starts before tap is confirmed and disappear after tap is canceled", (WidgetTester tester) async { final GlobalKey innerKey = GlobalKey(); await tester.pumpWidget( @@ -1363,4 +1251,231 @@ void main() { textDirection: TextDirection.ltr, )); }); + + // Regression test for https://github.com/flutter/flutter/issues/6751 + testWidgets('When InkWell has a GlobalKey and changes position, splash should not stop', (WidgetTester tester) async { + final GlobalKey<_TestAppState> testAppKey = GlobalKey(); + int frames; + + await tester.pumpWidget(TestApp(key: testAppKey)); + + void expectPaintedCircle(bool painted) { + final PaintPattern paintPattern = paints..circle(); + expect( + Material.of(tester.element(find.byType(InkWell)))! as RenderBox, + painted ? paintPattern : isNot(paintPattern), + ); + } + Future expectSplashContinueAfterMove(bool value) async { + await tester.pump(); + expectPaintedCircle(true); + await tester.pump(const Duration(milliseconds: 10)); + expectPaintedCircle(true); + await tester.pump(const Duration(milliseconds: 40)); + expectPaintedCircle(value); + } + + // InkWell does not have any key, so splash will stop. + testAppKey.currentState!.switchTapChangeWrap(); + await tester.pump(); + await tester.tap(find.byType(InkWell)); + await expectSplashContinueAfterMove(false); + frames = await tester.pumpAndSettle(); + expect(frames, 1); + expectPaintedCircle(false); + + // InkWell has a ValueKey, so splash will also stop. + testAppKey.currentState!.setInkWellKey(const Key('foo')); + await tester.pump(); + await tester.tap(find.byType(InkWell)); + await expectSplashContinueAfterMove(false); + frames = await tester.pumpAndSettle(); + expect(frames, 1); + expectPaintedCircle(false); + + // InkWell has a GlobalKey, so splash will continue. + testAppKey.currentState!.setInkWellKey(GlobalKey()); + await tester.pump(); + await tester.tap(find.byType(InkWell)); + await expectSplashContinueAfterMove(true); + frames = await tester.pumpAndSettle(); + expect(frames > 1, isTrue); + expectPaintedCircle(false); + + testAppKey.currentState!.switchTapDownChangeWrap(); + await tester.pump(); + final TestGesture testGesture = await tester.startGesture(tester.getRect(find.byType(InkWell)).center); + await expectSplashContinueAfterMove(true); + await testGesture.up(); + frames = await tester.pumpAndSettle(); + expect(frames > 1, isTrue); + expectPaintedCircle(false); + }); + + testWidgets('When InkWell/Ancestor has a GlobalKey and ancestor Material is replaced, splash should stop.', (WidgetTester tester) async { + final Key key = GlobalKey(); + bool replaced = false; + + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder(builder: (BuildContext context, void Function(void Function()) setState) { + Future changeReplace() async { + await Future.delayed(const Duration(milliseconds: 50)); + setState(() { + replaced = !replaced; + }); + } + return Material( + key: ValueKey(replaced), + child: InkWell( + key: key, + onTap: () { + changeReplace(); + }, + ), + ); + }), + )); + + await tester.tap(find.byType(InkWell)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 60)); + + final PaintPattern paintPattern = paints..circle(); + expect( + Material.of(tester.element(find.byType(InkWell)))! as RenderBox, + isNot(paintPattern), + ); + }); + + testWidgets('When InkWell/Ancestor has a GlobalKey and ancestor Material is replaced, highlight should always be maintained.', (WidgetTester tester) async { + const Color hoverColor = Color(0xff00ff00); + final Key key = GlobalKey(); + int onHoverCount = 0; + int callChangeReplaceCount = 0; + bool replaced = false; + int frames; + + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder(builder: (BuildContext context, void Function(void Function()) setState) { + Future changeReplace() async { + callChangeReplaceCount += 1; + await Future.delayed(const Duration(milliseconds: 50)); + setState(() { + replaced = !replaced; + }); + } + return Container( + margin: replaced ? const EdgeInsets.only(top: 1) : EdgeInsets.zero, + child: Material( + key: ValueKey(replaced), + child: InkWell( + key: key, + hoverColor: hoverColor, + onTap: () {}, + onHover: (bool hovered) { + onHoverCount += 1; + if (hovered) + changeReplace(); + }, + ), + ), + ); + }), + )); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + // When replaced is true, InkWell point at the top left is 1. + await gesture.moveTo(Offset.zero); + frames = await tester.pumpAndSettle(); + expect(frames > 1, isTrue); + RenderObject inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures'); + expect(inkFeatures, isNot(paints..rect(color: hoverColor))); + expect(onHoverCount, 2); + expect(callChangeReplaceCount, 1); + expect(replaced, true); + + await gesture.moveTo(tester.getCenter(find.byType(InkWell))); + frames = await tester.pumpAndSettle(); + expect(frames > 1, isTrue); + inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures'); + expect(inkFeatures, paints..rect(color: hoverColor)); + expect(onHoverCount, 3); + expect(callChangeReplaceCount, 2); + expect(replaced, false); + }); +} + +class TestApp extends StatefulWidget { + const TestApp({Key? key}) : super(key: key); + + @override + _TestAppState createState() => _TestAppState(); +} + +class _TestAppState extends State { + bool wrap = false; + bool tapDownChangeWrap = false; + bool tapChangeWrap = false; + Key? inkWellKey; + + @override + Widget build(BuildContext context) { + Widget child = InkWell( + key: inkWellKey, + onTap: () async { + if (tapChangeWrap) { + await changeWrap(); + } + }, + onTapDown: (_) async { + if (tapDownChangeWrap) { + await changeWrap(); + } + }, + ); + + if (wrap) { + child = Container( + child: child, + ); + } + + return Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: child, + ), + ); + } + + Future changeWrap() async { + await Future.delayed(const Duration(milliseconds: 50)); + setState(() { + wrap = !wrap; + }); + } + + void setInkWellKey(Key key) { + setState(() { + inkWellKey = key; + }); + } + + void switchTapDownChangeWrap() { + setState(() { + tapDownChangeWrap = true; + tapChangeWrap = false; + }); + } + + void switchTapChangeWrap() { + setState(() { + tapChangeWrap = true; + tapDownChangeWrap = false; + }); + } } diff --git a/packages/flutter/test/widgets/framework_test.dart b/packages/flutter/test/widgets/framework_test.dart index 972ace39b3..d33c78623d 100644 --- a/packages/flutter/test/widgets/framework_test.dart +++ b/packages/flutter/test/widgets/framework_test.dart @@ -1331,24 +1331,28 @@ void main() { expect(key.currentState, isNotNull); expect(state.didChangeDependenciesCount, 1); expect(state.deactivatedCount, 0); + expect(state.reactivatedCount, 0); /// Rebuild with updated value - should call didChangeDependencies await tester.pumpWidget(Inherited(2, child: DependentStatefulWidget(key: key))); expect(key.currentState, isNotNull); expect(state.didChangeDependenciesCount, 2); expect(state.deactivatedCount, 0); + expect(state.reactivatedCount, 0); - // reparent it - should call deactivate and didChangeDependencies + // reparent it - should call deactivate, reactivate, didChangeDependencies await tester.pumpWidget(Inherited(3, child: SizedBox(child: DependentStatefulWidget(key: key)))); expect(key.currentState, isNotNull); expect(state.didChangeDependenciesCount, 3); expect(state.deactivatedCount, 1); + expect(state.reactivatedCount, 1); - // Remove it - should call deactivate, but not didChangeDependencies + // Remove it - should call deactivate, but not reactivate or didChangeDependencies await tester.pumpWidget(const Inherited(4, child: SizedBox())); expect(key.currentState, isNull); expect(state.didChangeDependenciesCount, 3); expect(state.deactivatedCount, 2); + expect(state.reactivatedCount, 1); }); testWidgets('StatefulElement subclass can decorate State.build', (WidgetTester tester) async { @@ -1391,17 +1395,21 @@ void main() { expect(debugDoingBuildOnBuild, isTrue); }); testWidgets('StatefulWidget', (WidgetTester tester) async { + final Key key = GlobalKey(); + late bool debugDoingBuildOnBuild; late bool debugDoingBuildOnInitState; late bool debugDoingBuildOnDidChangeDependencies; late bool debugDoingBuildOnDidUpdateWidget; bool? debugDoingBuildOnDispose; bool? debugDoingBuildOnDeactivate; + bool? debugDoingBuildOnReactivate; await tester.pumpWidget( Inherited( 0, child: StatefulWidgetSpy( + key: key, onInitState: (BuildContext context) { debugDoingBuildOnInitState = context.debugDoingBuild; }, @@ -1427,6 +1435,7 @@ void main() { Inherited( 1, child: StatefulWidgetSpy( + key: key, onDidUpdateWidget: (BuildContext context) { debugDoingBuildOnDidUpdateWidget = context.debugDoingBuild; }, @@ -1442,6 +1451,9 @@ void main() { onDeactivate: (BuildContext context) { debugDoingBuildOnDeactivate = context.debugDoingBuild; }, + onReactivate: (BuildContext context) { + debugDoingBuildOnReactivate = context.debugDoingBuild; + }, ), ), ); @@ -1451,6 +1463,35 @@ void main() { expect(debugDoingBuildOnDidUpdateWidget, isFalse); expect(debugDoingBuildOnDidChangeDependencies, isFalse); expect(debugDoingBuildOnDeactivate, isNull); + expect(debugDoingBuildOnReactivate, isNull); + expect(debugDoingBuildOnDispose, isNull); + + await tester.pumpWidget( + Inherited( + 1, + child: SizedBox( + child: StatefulWidgetSpy( + key: key, + onBuild: (BuildContext context) { + debugDoingBuildOnBuild = context.debugDoingBuild; + }, + onDispose: (BuildContext context) { + debugDoingBuildOnDispose = context.debugDoingBuild; + }, + onDeactivate: (BuildContext context) { + debugDoingBuildOnDeactivate = context.debugDoingBuild; + }, + onReactivate: (BuildContext context) { + debugDoingBuildOnReactivate = context.debugDoingBuild; + }, + ), + ), + ), + ); + + expect(debugDoingBuildOnBuild, isTrue); + expect(debugDoingBuildOnDeactivate, isFalse); + expect(debugDoingBuildOnReactivate, isFalse); expect(debugDoingBuildOnDispose, isNull); await tester.pumpWidget(Container()); @@ -1705,6 +1746,7 @@ class DependentStatefulWidget extends StatefulWidget { class DependentState extends State { int didChangeDependenciesCount = 0; int deactivatedCount = 0; + int reactivatedCount = 0; @override void didChangeDependencies() { @@ -1723,6 +1765,12 @@ class DependentState extends State { super.deactivate(); deactivatedCount += 1; } + + @override + void reactivate() { + super.reactivate(); + reactivatedCount += 1; + } } class SwapKeyWidget extends StatefulWidget { @@ -1810,6 +1858,7 @@ class StatefulWidgetSpy extends StatefulWidget { this.onDidChangeDependencies, this.onDispose, this.onDeactivate, + this.onReactivate, this.onDidUpdateWidget, }) : super(key: key); @@ -1818,6 +1867,7 @@ class StatefulWidgetSpy extends StatefulWidget { final void Function(BuildContext)? onDidChangeDependencies; final void Function(BuildContext)? onDispose; final void Function(BuildContext)? onDeactivate; + final void Function(BuildContext)? onReactivate; final void Function(BuildContext)? onDidUpdateWidget; @override @@ -1837,6 +1887,12 @@ class _StatefulWidgetSpyState extends State { widget.onDeactivate?.call(context); } + @override + void reactivate() { + super.reactivate(); + widget.onReactivate?.call(context); + } + @override void dispose() { super.dispose();