diff --git a/packages/flutter/lib/src/semantics/binding.dart b/packages/flutter/lib/src/semantics/binding.dart index 0ed6c51b12d..1b002d20694 100644 --- a/packages/flutter/lib/src/semantics/binding.dart +++ b/packages/flutter/lib/src/semantics/binding.dart @@ -4,7 +4,7 @@ // @dart = 2.8 -import 'dart:ui' as ui show AccessibilityFeatures; +import 'dart:ui' as ui show AccessibilityFeatures, SemanticsUpdateBuilder; import 'package:flutter/foundation.dart'; @@ -34,6 +34,16 @@ mixin SemanticsBinding on BindingBase { _accessibilityFeatures = window.accessibilityFeatures; } + /// Creates an empty semantics update builder. + /// + /// The caller is responsible for filling out the semantics node updates. + /// + /// This method is used by the [SemanticsOwner] to create builder for all its + /// semantics updates. + ui.SemanticsUpdateBuilder createSemanticsUpdateBuilder() { + return ui.SemanticsUpdateBuilder(); + } + /// The currently active set of [AccessibilityFeatures]. /// /// This is initialized the first time [runApp] is called and updated whenever diff --git a/packages/flutter/lib/src/semantics/semantics.dart b/packages/flutter/lib/src/semantics/semantics.dart index 3baf444cc01..3f24f931554 100644 --- a/packages/flutter/lib/src/semantics/semantics.dart +++ b/packages/flutter/lib/src/semantics/semantics.dart @@ -2624,13 +2624,15 @@ class SemanticsOwner extends ChangeNotifier { if (node.isPartOfNodeMerging) { assert(node.mergeAllDescendantsIntoThisNode || node.parent != null); // if we're merged into our parent, make sure our parent is added to the dirty list - if (node.parent != null && node.parent.isPartOfNodeMerging) + if (node.parent != null && node.parent.isPartOfNodeMerging) { node.parent._markDirty(); // this can add the node to the dirty list + node._dirty = false; // We don't want to send update for this node. + } } } } visitedNodes.sort((SemanticsNode a, SemanticsNode b) => a.depth - b.depth); - final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder(); + final ui.SemanticsUpdateBuilder builder = SemanticsBinding.instance.createSemanticsUpdateBuilder(); for (final SemanticsNode node in visitedNodes) { assert(node.parent?._dirty != true); // could be null (no parent) or false (not dirty) // The _serialize() method marks the node as not dirty, and diff --git a/packages/flutter/test/semantics/semantics_update_test.dart b/packages/flutter/test/semantics/semantics_update_test.dart new file mode 100644 index 00000000000..e6a5ae6a051 --- /dev/null +++ b/packages/flutter/test/semantics/semantics_update_test.dart @@ -0,0 +1,218 @@ +// 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. + +// @dart = 2.8 + +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + SemanticsUpdateTestBinding(); + + testWidgets('Semantics update does not send update for merged nodes.', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + // Pumps a placeholder to trigger the warm up frame. + await tester.pumpWidget( + const Placeholder(), + // Stops right after the warm up frame. + null, + EnginePhase.build + ); + // The warm up frame will send update for an empty semantics tree. We + // ignore this one time update. + SemanticsUpdateBuilderSpy.observations.clear(); + + // Builds the real widget tree. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MergeSemantics( + child: Semantics( + label: 'outer', + // This semantics node should not be part of the semantics update + // because it is under another semantics container. + child: Semantics( + label: 'inner', + container: true, + child: const Text('text'), + ), + ), + ), + ), + ); + + expect(SemanticsUpdateBuilderSpy.observations.length, 2); + + expect(SemanticsUpdateBuilderSpy.observations.containsKey(0), isTrue); + expect(SemanticsUpdateBuilderSpy.observations[0].childrenInTraversalOrder.length, 1); + expect(SemanticsUpdateBuilderSpy.observations[0].childrenInTraversalOrder[0], 1); + + expect(SemanticsUpdateBuilderSpy.observations.containsKey(1), isTrue); + expect(SemanticsUpdateBuilderSpy.observations[1].childrenInTraversalOrder.length, 0); + expect(SemanticsUpdateBuilderSpy.observations[1].label, 'outer\ninner\ntext'); + + SemanticsUpdateBuilderSpy.observations.clear(); + + // Updates the inner semantics label and verifies it only sends update for + // the merged parent. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MergeSemantics( + child: Semantics( + label: 'outer', + // This semantics node should not be part of the semantics update + // because it is under another semantics container. + child: Semantics( + label: 'inner-updated', + container: true, + child: const Text('text'), + ), + ), + ), + ), + ); + expect(SemanticsUpdateBuilderSpy.observations.length, 1); + + expect(SemanticsUpdateBuilderSpy.observations.containsKey(1), isTrue); + expect(SemanticsUpdateBuilderSpy.observations[1].childrenInTraversalOrder.length, 0); + expect(SemanticsUpdateBuilderSpy.observations[1].label, 'outer\ninner-updated\ntext'); + + SemanticsUpdateBuilderSpy.observations.clear(); + handle.dispose(); + }); +} + +class SemanticsUpdateTestBinding extends AutomatedTestWidgetsFlutterBinding { + @override + ui.SemanticsUpdateBuilder createSemanticsUpdateBuilder() { + return SemanticsUpdateBuilderSpy(); + } +} + +class SemanticsUpdateBuilderSpy extends ui.SemanticsUpdateBuilder { + static Map observations = {}; + + @override + void updateNode({ + @required int id, + @required int flags, + @required int actions, + @required int maxValueLength, + @required int currentValueLength, + @required int textSelectionBase, + @required int textSelectionExtent, + @required int platformViewId, + @required int scrollChildren, + @required int scrollIndex, + @required double scrollPosition, + @required double scrollExtentMax, + @required double scrollExtentMin, + @required double elevation, + @required double thickness, + @required Rect rect, + @required String label, + @required String hint, + @required String value, + @required String increasedValue, + @required String decreasedValue, + TextDirection textDirection, + @required Float64List transform, + @required Int32List childrenInTraversalOrder, + @required Int32List childrenInHitTestOrder, + @required Int32List additionalActions, + }) { + // Makes sure we don't send the same id twice. + assert(!observations.containsKey(id)); + observations[id] = SemanticsNodeUpdateObservation( + id: id, + flags: flags, + actions: actions, + maxValueLength: maxValueLength, + currentValueLength: currentValueLength, + textSelectionBase: textSelectionBase, + textSelectionExtent: textSelectionExtent, + platformViewId: platformViewId, + scrollChildren: scrollChildren, + scrollIndex: scrollIndex, + scrollPosition: scrollPosition, + scrollExtentMax: scrollExtentMax, + scrollExtentMin: scrollExtentMin, + elevation: elevation, + thickness: thickness, + rect: rect, + label: label, + hint: hint, + value: value, + increasedValue: increasedValue, + decreasedValue: decreasedValue, + textDirection: textDirection, + transform: transform, + childrenInTraversalOrder: childrenInTraversalOrder, + childrenInHitTestOrder: childrenInHitTestOrder, + additionalActions: additionalActions, + ); + } +} + +class SemanticsNodeUpdateObservation { + const SemanticsNodeUpdateObservation({ + @required this.id, + @required this.flags, + @required this.actions, + @required this.maxValueLength, + @required this.currentValueLength, + @required this.textSelectionBase, + @required this.textSelectionExtent, + @required this.platformViewId, + @required this.scrollChildren, + @required this.scrollIndex, + @required this.scrollPosition, + @required this.scrollExtentMax, + @required this.scrollExtentMin, + @required this.elevation, + @required this.thickness, + @required this.rect, + @required this.label, + @required this.hint, + @required this.value, + @required this.increasedValue, + @required this.decreasedValue, + this.textDirection, + @required this.transform, + @required this.childrenInTraversalOrder, + @required this.childrenInHitTestOrder, + @required this.additionalActions, + }); + + final int id; + final int flags; + final int actions; + final int maxValueLength; + final int currentValueLength; + final int textSelectionBase; + final int textSelectionExtent; + final int platformViewId; + final int scrollChildren; + final int scrollIndex; + final double scrollPosition; + final double scrollExtentMax; + final double scrollExtentMin; + final double elevation; + final double thickness; + final Rect rect; + final String label; + final String hint; + final String value; + final String increasedValue; + final String decreasedValue; + final TextDirection textDirection; + final Float64List transform; + final Int32List childrenInTraversalOrder; + final Int32List childrenInHitTestOrder; + final Int32List additionalActions; +}