diff --git a/packages/flutter/lib/src/widgets/scrollable.dart b/packages/flutter/lib/src/widgets/scrollable.dart index 027819e938..d3d0067ed9 100644 --- a/packages/flutter/lib/src/widgets/scrollable.dart +++ b/packages/flutter/lib/src/widgets/scrollable.dart @@ -1659,6 +1659,7 @@ class _RenderScrollSemantics extends RenderProxyBox { @override void assembleSemanticsNode(SemanticsNode node, SemanticsConfiguration config, Iterable children) { if (children.isEmpty || !children.first.isTagged(RenderViewport.useTwoPaneSemantics)) { + _innerNode = null; super.assembleSemanticsNode(node, config, children); return; } diff --git a/packages/flutter/test/widgets/scrollable_test.dart b/packages/flutter/test/widgets/scrollable_test.dart index 8210293f5c..0a7501062b 100644 --- a/packages/flutter/test/widgets/scrollable_test.dart +++ b/packages/flutter/test/widgets/scrollable_test.dart @@ -11,6 +11,8 @@ import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'semantics_tester.dart'; + Future pumpTest( WidgetTester tester, TargetPlatform? platform, { @@ -1643,6 +1645,51 @@ void main() { await tester.pump(const Duration(milliseconds: 4800)); expect(getScrollOffset(tester), closeTo(333.2944, 0.0001)); }); + + testWidgets('Swapping viewports in a scrollable does not crash', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + final GlobalKey key = GlobalKey(); + final GlobalKey key1 = GlobalKey(); + Widget buildScrollable(bool withViewPort) { + return Scrollable( + key: key, + viewportBuilder: (BuildContext context, ViewportOffset position) { + if (withViewPort) { + return Viewport( + slivers: [ + SliverToBoxAdapter(child: Semantics(key: key1, container: true, child: const Text('text1'))) + ], + offset: ViewportOffset.zero(), + ); + } + return Semantics(key: key1, container: true, child: const Text('text1')); + }, + ); + } + // This should cache the inner node in Scrollable with the children text1. + await tester.pumpWidget( + MaterialApp( + home: buildScrollable(true), + ), + ); + expect(semantics, includesNodeWith(tags: {RenderViewport.useTwoPaneSemantics})); + // This does not use two panel, this should clear cached inner node. + await tester.pumpWidget( + MaterialApp( + home: buildScrollable(false), + ), + ); + expect(semantics, isNot(includesNodeWith(tags: {RenderViewport.useTwoPaneSemantics}))); + // If the inner node was cleared in the previous step, this should not crash. + await tester.pumpWidget( + MaterialApp( + home: buildScrollable(true), + ), + ); + expect(semantics, includesNodeWith(tags: {RenderViewport.useTwoPaneSemantics})); + expect(tester.takeException(), isNull); + semantics.dispose(); + }); } // ignore: must_be_immutable diff --git a/packages/flutter/test/widgets/semantics_tester.dart b/packages/flutter/test/widgets/semantics_tester.dart index de460112f3..0d18f240df 100644 --- a/packages/flutter/test/widgets/semantics_tester.dart +++ b/packages/flutter/test/widgets/semantics_tester.dart @@ -487,6 +487,7 @@ class SemanticsTester { TextDirection? textDirection, List? actions, List? flags, + Set? tags, double? scrollPosition, double? scrollExtentMax, double? scrollExtentMin, @@ -536,6 +537,12 @@ class SemanticsTester { return false; } } + if (tags != null) { + final Set? actualTags = node.getSemanticsData().tags; + if (!setEquals(actualTags, tags)) { + return false; + } + } if (scrollPosition != null && !nearEqual(node.scrollPosition, scrollPosition, 0.1)) { return false; } @@ -796,6 +803,7 @@ class _IncludesNodeWith extends Matcher { this.textDirection, this.actions, this.flags, + this.tags, this.scrollPosition, this.scrollExtentMax, this.scrollExtentMin, @@ -806,6 +814,7 @@ class _IncludesNodeWith extends Matcher { value != null || actions != null || flags != null || + tags != null || scrollPosition != null || scrollExtentMax != null || scrollExtentMin != null || @@ -821,6 +830,7 @@ class _IncludesNodeWith extends Matcher { final TextDirection? textDirection; final List? actions; final List? flags; + final Set? tags; final double? scrollPosition; final double? scrollExtentMax; final double? scrollExtentMin; @@ -839,6 +849,7 @@ class _IncludesNodeWith extends Matcher { textDirection: textDirection, actions: actions, flags: flags, + tags: tags, scrollPosition: scrollPosition, scrollExtentMax: scrollExtentMax, scrollExtentMin: scrollExtentMin, @@ -865,6 +876,7 @@ class _IncludesNodeWith extends Matcher { if (textDirection != null) ' (${textDirection!.name})', if (actions != null) 'actions "${actions!.join(', ')}"', if (flags != null) 'flags "${flags!.join(', ')}"', + if (tags != null) 'tags "${tags!.join(', ')}"', if (scrollPosition != null) 'scrollPosition "$scrollPosition"', if (scrollExtentMax != null) 'scrollExtentMax "$scrollExtentMax"', if (scrollExtentMin != null) 'scrollExtentMin "$scrollExtentMin"', @@ -889,6 +901,7 @@ Matcher includesNodeWith({ TextDirection? textDirection, List? actions, List? flags, + Set? tags, double? scrollPosition, double? scrollExtentMax, double? scrollExtentMin, @@ -905,6 +918,7 @@ Matcher includesNodeWith({ textDirection: textDirection, actions: actions, flags: flags, + tags: tags, scrollPosition: scrollPosition, scrollExtentMax: scrollExtentMax, scrollExtentMin: scrollExtentMin,