diff --git a/packages/flutter/lib/src/rendering/paragraph.dart b/packages/flutter/lib/src/rendering/paragraph.dart index c71f8a6f84..202af72064 100644 --- a/packages/flutter/lib/src/rendering/paragraph.dart +++ b/packages/flutter/lib/src/rendering/paragraph.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:collection'; import 'dart:math' as math; import 'dart:ui' as ui show Gradient, Shader, TextBox, PlaceholderAlignment, TextHeightBehavior; @@ -844,6 +845,12 @@ class RenderParagraph extends RenderBox } } + // Caches [SemanticsNode]s created during [assembleSemanticsNode] so they + // can be re-used when [assembleSemanticsNode] is called again. This ensures + // stable ids for the [SemanticsNode]s of [TextSpan]s across + // [assembleSemanticsNode] invocations. + Queue _cachedChildNodes; + @override void assembleSemanticsNode(SemanticsNode node, SemanticsConfiguration config, Iterable children) { assert(_semanticsInfo != null && _semanticsInfo.isNotEmpty); @@ -854,6 +861,7 @@ class RenderParagraph extends RenderBox int start = 0; int placeholderIndex = 0; RenderBox child = firstChild; + final Queue newChildCache = Queue(); for (final InlineSpanSemanticsInformation info in _combineSemanticsInfo()) { final TextDirection initialDirection = currentDirection; final TextSelection selection = TextSelection( @@ -914,17 +922,27 @@ class RenderParagraph extends RenderBox assert(false); } } - newChildren.add( - SemanticsNode() - ..updateWith(config: configuration) - ..rect = currentRect, - ); + final SemanticsNode newChild = (_cachedChildNodes?.isNotEmpty == true) + ? _cachedChildNodes.removeFirst() + : SemanticsNode(); + newChild + ..updateWith(config: configuration) + ..rect = currentRect; + newChildCache.addLast(newChild); + newChildren.add(newChild); } start += info.text.length; } + _cachedChildNodes = newChildCache; node.updateWith(config: config, childrenInInversePaintOrder: newChildren); } + @override + void clearSemantics() { + super.clearSemantics(); + _cachedChildNodes = null; + } + @override List debugDescribeChildren() { return [ diff --git a/packages/flutter/test/widgets/text_semantics_test.dart b/packages/flutter/test/widgets/text_semantics_test.dart new file mode 100644 index 0000000000..dc7e0ff997 --- /dev/null +++ b/packages/flutter/test/widgets/text_semantics_test.dart @@ -0,0 +1,133 @@ +// 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/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +import 'semantics_tester.dart'; + +void main() { + testWidgets('SemanticsNode ids are stable', (WidgetTester tester) async { + // Regression test for b/151732341. + final SemanticsTester semantics = SemanticsTester(tester); + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: Text.rich( + TextSpan( + text: 'Hallo ', + recognizer: TapGestureRecognizer()..onTap = () {}, + children: [ + TextSpan( + text: 'Welt ', + recognizer: TapGestureRecognizer()..onTap = () {}, + ), + TextSpan( + text: '!!!', + recognizer: TapGestureRecognizer()..onTap = () {}, + ), + ], + ), + ), + )); + expect(find.text('Hallo Welt !!!'), findsOneWidget); + final SemanticsNode node = tester.getSemantics(find.text('Hallo Welt !!!')); + final Map labelToNodeId = {}; + node.visitChildren((SemanticsNode node) { + labelToNodeId[node.label] = node.id; + return true; + }); + expect(node.id, 1); + expect(labelToNodeId['Hallo '], 2); + expect(labelToNodeId['Welt '], 3); + expect(labelToNodeId['!!!'], 4); + expect(labelToNodeId.length, 3); + + // Rebuild semantics. + tester.renderObject(find.text('Hallo Welt !!!')).markNeedsSemanticsUpdate(); + await tester.pump(); + + final SemanticsNode nodeAfterRebuild = tester.getSemantics(find.text('Hallo Welt !!!')); + final Map labelToNodeIdAfterRebuild = {}; + nodeAfterRebuild.visitChildren((SemanticsNode node) { + labelToNodeIdAfterRebuild[node.label] = node.id; + return true; + }); + + // Node IDs are stable. + expect(nodeAfterRebuild.id, node.id); + expect(labelToNodeIdAfterRebuild['Hallo '], labelToNodeId['Hallo ']); + expect(labelToNodeIdAfterRebuild['Welt '], labelToNodeId['Welt ']); + expect(labelToNodeIdAfterRebuild['!!!'], labelToNodeId['!!!']); + expect(labelToNodeIdAfterRebuild.length, 3); + + // Remove one node. + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: Text.rich( + TextSpan( + text: 'Hallo ', + recognizer: TapGestureRecognizer()..onTap = () {}, + children: [ + TextSpan( + text: 'Welt ', + recognizer: TapGestureRecognizer()..onTap = () {}, + ), + ], + ), + ), + )); + + final SemanticsNode nodeAfterRemoval = tester.getSemantics(find.text('Hallo Welt ')); + final Map labelToNodeIdAfterRemoval = {}; + nodeAfterRemoval.visitChildren((SemanticsNode node) { + labelToNodeIdAfterRemoval[node.label] = node.id; + return true; + }); + + // Node IDs are stable. + expect(nodeAfterRemoval.id, node.id); + expect(labelToNodeIdAfterRemoval['Hallo '], labelToNodeId['Hallo ']); + expect(labelToNodeIdAfterRemoval['Welt '], labelToNodeId['Welt ']); + expect(labelToNodeIdAfterRemoval.length, 2); + + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: Text.rich( + TextSpan( + text: 'Hallo ', + recognizer: TapGestureRecognizer()..onTap = () {}, + children: [ + TextSpan( + text: 'Welt ', + recognizer: TapGestureRecognizer()..onTap = () {}, + ), + TextSpan( + text: '!!!', + recognizer: TapGestureRecognizer()..onTap = () {}, + ), + ], + ), + ), + )); + expect(find.text('Hallo Welt !!!'), findsOneWidget); + final SemanticsNode nodeAfterAddition = tester.getSemantics(find.text('Hallo Welt !!!')); + final Map labelToNodeIdAfterAddition = {}; + nodeAfterAddition.visitChildren((SemanticsNode node) { + labelToNodeIdAfterAddition[node.label] = node.id; + return true; + }); + + // New node gets a new ID. + expect(nodeAfterAddition.id, node.id); + expect(labelToNodeIdAfterAddition['Hallo '], labelToNodeId['Hallo ']); + expect(labelToNodeIdAfterAddition['Welt '], labelToNodeId['Welt ']); + expect(labelToNodeIdAfterAddition['!!!'], isNot(labelToNodeId['!!!'])); + expect(labelToNodeIdAfterAddition['!!!'], isNotNull); + expect(labelToNodeIdAfterAddition.length, 3); + + semantics.dispose(); + }); +}