mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
Fixes https://github.com/flutter/flutter/issues/163576 Fixes https://github.com/flutter/flutter/issues/175184 This PR refactored the grafting part on `OverlayPortal`. Originally, the semantics tree of `OverlayPortal` was constructed/grafted in render object phase to make sure the correctness of the traversal order. However this resulted wrong hit-test order and the issue surfaced on web. With the fact that on web we are not able to graft/correct hit-test order tree, this PR: * Reverts the original grafting of the `OverlayPortal` so the hit-test order is always correct. * Then, we adds the grafting and updates the traversal order when we send `childrenInTraversalOrder` to engine. * Updating `childrenInTraversalOrder` causes it have different length from the length of `childrenInHitTestOrder` and wrong hit-test transform of the `OverlayPortal` children because when the transform is calculated, it assumes a correct traversal order. To fix these issues, this PR also: * recalculates the transform for `OverlayPortal` children. * adds `hitTestTransform` property and pass it to Android engine. * skip grafting for web because it assumes the same length of `childrenInTraversalOrder` and `childrenInHitTestOrder`. * added grafting by using `ARIA-owns` in web engine to fix the traversal order. ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing.
278 lines
10 KiB
Dart
278 lines
10 KiB
Dart
// 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 'dart:typed_data';
|
|
import 'dart:ui' as ui;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/semantics.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.
|
|
phase: 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();
|
|
});
|
|
|
|
testWidgets('Semantics update receives attributed text', (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.
|
|
phase: 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: Semantics(
|
|
attributedLabel: AttributedString(
|
|
'label',
|
|
attributes: <StringAttribute>[
|
|
SpellOutStringAttribute(range: const TextRange(start: 0, end: 5)),
|
|
],
|
|
),
|
|
attributedValue: AttributedString(
|
|
'value',
|
|
attributes: <StringAttribute>[
|
|
LocaleStringAttribute(
|
|
range: const TextRange(start: 0, end: 5),
|
|
locale: const Locale('en', 'MX'),
|
|
),
|
|
],
|
|
),
|
|
attributedHint: AttributedString(
|
|
'hint',
|
|
attributes: <StringAttribute>[
|
|
SpellOutStringAttribute(range: const TextRange(start: 1, end: 2)),
|
|
],
|
|
),
|
|
child: const Placeholder(),
|
|
),
|
|
),
|
|
);
|
|
|
|
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, 'label');
|
|
expect(SemanticsUpdateBuilderSpy.observations[1]!.labelAttributes!.length, 1);
|
|
expect(
|
|
SemanticsUpdateBuilderSpy.observations[1]!.labelAttributes![0] is SpellOutStringAttribute,
|
|
isTrue,
|
|
);
|
|
expect(
|
|
SemanticsUpdateBuilderSpy.observations[1]!.labelAttributes![0].range,
|
|
const TextRange(start: 0, end: 5),
|
|
);
|
|
|
|
expect(SemanticsUpdateBuilderSpy.observations[1]!.value, 'value');
|
|
expect(SemanticsUpdateBuilderSpy.observations[1]!.valueAttributes!.length, 1);
|
|
expect(
|
|
SemanticsUpdateBuilderSpy.observations[1]!.valueAttributes![0] is LocaleStringAttribute,
|
|
isTrue,
|
|
);
|
|
final LocaleStringAttribute localeAttribute =
|
|
SemanticsUpdateBuilderSpy.observations[1]!.valueAttributes![0] as LocaleStringAttribute;
|
|
expect(localeAttribute.range, const TextRange(start: 0, end: 5));
|
|
expect(localeAttribute.locale, const Locale('en', 'MX'));
|
|
|
|
expect(SemanticsUpdateBuilderSpy.observations[1]!.hint, 'hint');
|
|
expect(SemanticsUpdateBuilderSpy.observations[1]!.hintAttributes!.length, 1);
|
|
expect(
|
|
SemanticsUpdateBuilderSpy.observations[1]!.hintAttributes![0] is SpellOutStringAttribute,
|
|
isTrue,
|
|
);
|
|
expect(
|
|
SemanticsUpdateBuilderSpy.observations[1]!.hintAttributes![0].range,
|
|
const TextRange(start: 1, end: 2),
|
|
);
|
|
|
|
expect(
|
|
tester.widget(find.byType(Semantics)).toString(),
|
|
'Semantics('
|
|
'container: false, '
|
|
'properties: SemanticsProperties, '
|
|
'attributedLabel: "label" [SpellOutStringAttribute(TextRange(start: 0, end: 5))], '
|
|
'attributedValue: "value" [LocaleStringAttribute(TextRange(start: 0, end: 5), en-MX)], '
|
|
'attributedHint: "hint" [SpellOutStringAttribute(TextRange(start: 1, end: 2))]' // ignore: missing_whitespace_between_adjacent_strings
|
|
')',
|
|
);
|
|
|
|
SemanticsUpdateBuilderSpy.observations.clear();
|
|
handle.dispose();
|
|
});
|
|
}
|
|
|
|
class SemanticsUpdateTestBinding extends AutomatedTestWidgetsFlutterBinding {
|
|
@override
|
|
ui.SemanticsUpdateBuilder createSemanticsUpdateBuilder() {
|
|
return SemanticsUpdateBuilderSpy();
|
|
}
|
|
}
|
|
|
|
class SemanticsUpdateBuilderSpy extends Fake implements ui.SemanticsUpdateBuilder {
|
|
final SemanticsUpdateBuilder _builder = ui.SemanticsUpdateBuilder();
|
|
|
|
static Map<int, SemanticsNodeUpdateObservation> observations =
|
|
<int, SemanticsNodeUpdateObservation>{};
|
|
|
|
@override
|
|
void updateNode({
|
|
required int id,
|
|
required SemanticsFlags 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 int? traversalParent,
|
|
required double scrollPosition,
|
|
required double scrollExtentMax,
|
|
required double scrollExtentMin,
|
|
required Rect rect,
|
|
required String identifier,
|
|
required String label,
|
|
List<StringAttribute>? labelAttributes,
|
|
required String value,
|
|
List<StringAttribute>? valueAttributes,
|
|
required String increasedValue,
|
|
List<StringAttribute>? increasedValueAttributes,
|
|
required String decreasedValue,
|
|
List<StringAttribute>? decreasedValueAttributes,
|
|
required String hint,
|
|
List<StringAttribute>? hintAttributes,
|
|
String? tooltip,
|
|
TextDirection? textDirection,
|
|
required Float64List transform,
|
|
required Float64List hitTestTransform,
|
|
required Int32List childrenInTraversalOrder,
|
|
required Int32List childrenInHitTestOrder,
|
|
required Int32List additionalActions,
|
|
int headingLevel = 0,
|
|
String? linkUrl,
|
|
SemanticsRole role = SemanticsRole.none,
|
|
required List<String>? controlsNodes,
|
|
SemanticsValidationResult validationResult = SemanticsValidationResult.none,
|
|
ui.SemanticsHitTestBehavior hitTestBehavior = ui.SemanticsHitTestBehavior.defer,
|
|
required ui.SemanticsInputType inputType,
|
|
required ui.Locale? locale,
|
|
}) {
|
|
// Makes sure we don't send the same id twice.
|
|
assert(!observations.containsKey(id));
|
|
observations[id] = SemanticsNodeUpdateObservation(
|
|
label: label,
|
|
labelAttributes: labelAttributes,
|
|
hint: hint,
|
|
hintAttributes: hintAttributes,
|
|
value: value,
|
|
valueAttributes: valueAttributes,
|
|
childrenInTraversalOrder: childrenInTraversalOrder,
|
|
);
|
|
}
|
|
|
|
@override
|
|
void updateCustomAction({required int id, String? label, String? hint, int overrideId = -1}) =>
|
|
_builder.updateCustomAction(id: id, label: label, hint: hint, overrideId: overrideId);
|
|
|
|
@override
|
|
ui.SemanticsUpdate build() => _builder.build();
|
|
}
|
|
|
|
class SemanticsNodeUpdateObservation {
|
|
const SemanticsNodeUpdateObservation({
|
|
required this.label,
|
|
this.labelAttributes,
|
|
required this.value,
|
|
this.valueAttributes,
|
|
required this.hint,
|
|
this.hintAttributes,
|
|
required this.childrenInTraversalOrder,
|
|
});
|
|
|
|
final String label;
|
|
final List<StringAttribute>? labelAttributes;
|
|
final String value;
|
|
final List<StringAttribute>? valueAttributes;
|
|
final String hint;
|
|
final List<StringAttribute>? hintAttributes;
|
|
final Int32List childrenInTraversalOrder;
|
|
}
|