Visitar URL original
[stable] [iOS] Preserve semantics parents after reparenting (#189686)… · Boriken-Dev/flutter@0f6f799 · GitHub
Skip to content

Commit 0f6f799

Browse files
authored
[stable] [iOS] Preserve semantics parents after reparenting (#189686) + Fix OverlayPortal semantics bounds corruption (#192139) (#193420)
Cherry-picks flutter/flutter#189686 and flutter/flutter#192139 into `flutter-3.47-candidate.0` (stable). Both applied cleanly with `git cherry-pick -x`; both touch only `SemanticsObject.mm` (+ tests). ### Issue Link flutter/flutter#182604 · flutter/flutter#100946 ### Impacted Users End users of iOS apps built with stable who rely on VoiceOver / Switch Control, and teams driving XCUITest / Maestro against Flutter apps. Any route that mounts a Material `Slider` is affected out of the box: `Slider` keeps its value-indicator `OverlayPortal` shown from construction (`slider.dart`: `OverlayPortalController(...)..show()`), so no popover, menu or reorder is needed to trigger it. ### Impact Description Since 26e693c an `OverlayPortal` child has a different parent in traversal order and in hit-test order. The iOS accessibility bridge overwrote the native `SemanticsObject.parent` with the hit-test one (and cleared parents of moved children), so the parent chain used to compute `accessibilityFrame` no longer reaches the root: every element of the route reports a frame scaled by 1/devicePixelRatio (exactly 1/3 on a 3x device) and anchored top-left. VoiceOver touch exploration focuses the wrong place; `XCUIElement.tap()` misses the visible control. Measured on iPhone 16 Pro Max / iOS 26.7 with Flutter 3.44.8 (stable 3.47.5 has the same `SemanticsObject.mm`): a full-width CTA laid out at `{{32, 834}, {376, 56}}` is published as `{{10.7, 278.0}, {125.3, 18.7}}`; a player route with a `Slider` is 1/3 from its first frame. The corruption persists after the portal unmounts and after background/foreground. ### Changelog Description [flutter/182604](flutter/flutter#182604) When a route shows an `OverlayPortal` on iOS (including any Material `Slider`), accessibility frames are shrunk by the device pixel ratio and anchored to the top-left, so VoiceOver focus and XCUITest taps miss the visible controls. ### Workaround Replace Material `Slider` with `CupertinoSlider` (no `OverlayPortal`) on affected routes and avoid `OverlayPortal` altogether. Not viable for apps using Material menus / dropdowns or third-party popovers. ### Risk Low. Parent bookkeeping only: #189686 clears `child.parent` only when the object still owns it; #192139 stops the hit-test-order setter from mutating `parent`. No behaviour change when traversal and hit-test parents coincide. ### Cherry-pick diff composition The two upstream PRs touch the same function, so the composed diff on `SemanticsObject.mm` (`+6 -8`) matches neither of them individually — upstream, #189686 is `+9 -3` on this file and #192139 is `+0 -8`. Net against the `flutter-3.47-candidate.0` base: | site | net effect vs. base | |---|---| | `dealloc` | `+3 -1` — guard becomes `if (child.parent == self) { child.parent = nil; }` | | `setChildren` | `+3 -1` — same guard | | `setChildrenInHitTestOrder` | `-6` — #189686 adds the guard here too, then #192139 deletes both parent loops outright, so those 3 added lines never survive and the block's 6 original lines go away | Additions `3 + 3 = 6`; deletions `1 + 1 + 6 = 8`. The three "missing" additions are the ones the second commit removes from the first. No conflict was resolved by hand — both applied cleanly with `git cherry-pick -x`. ### Test Coverage Yes. #189686 adds an iOS XCUITest (`testReorderableListAccessibilityHierarchyUpdates`) asserting the accessibility frame after reparenting; #192139 adds `SemanticsObjectTest.mm` coverage. Both have been on master since 2026-09-01/03 and ship in beta 3.49.0-0.1.pre. ### Validation Steps 1. On a 3x iOS device, push a route (e.g. `MaterialApp.router` + `GoRoute`) whose body contains a Material `Slider`. 2. Print `XCUIApplication().debugDescription` from an XCUITest, or inspect with Accessibility Inspector. 3. Before: every element of the route reports a frame at 1/3 of its logical size near (0,0). After: frames match the visible layout, and `XCUIElement.tap()` / VoiceOver touch exploration hit the visible controls.
1 parent 3b450ee commit 0f6f799

6 files changed

Lines changed: 311 additions & 8 deletions

File tree

‎dev/integration_tests/ios_platform_view_tests/ios/PlatformViewUITests/PlatformViewUITests.m‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@
66

77
static const CGFloat kStandardTimeOut = 60.0;
88

9+
static NSArray<NSString *> *ElementLabels(XCUIElementQuery *query) {
10+
NSMutableArray<NSString *> *labels = [[NSMutableArray alloc] init];
11+
for (XCUIElement *element in query.allElementsBoundByIndex) {
12+
[labels addObject:element.label];
13+
}
14+
return labels;
15+
}
16+
917
@interface XCUIElement(Test)
1018
@property (nonatomic, readonly) BOOL flt_hasKeyboardFocus;
1119
- (void)flt_forceTap;
@@ -68,6 +76,101 @@ - (void)testPlatformViewFocus {
6876
XCTAssertFalse(flutterTextField.flt_hasKeyboardFocus);
6977
}
7078

79+
- (void)testReorderableListAccessibilityHierarchyUpdates {
80+
// Regression test for https://github.com/flutter/flutter/issues/100946.
81+
XCUIElement *entranceButton =
82+
self.app.buttons[@"reorderable list semantics test"];
83+
XCTAssertTrue([entranceButton waitForExistenceWithTimeout:kStandardTimeOut],
84+
@"The element tree is %@", self.app.debugDescription);
85+
[entranceButton tap];
86+
87+
XCUIElement *item2 = self.app.staticTexts[@"Item 2"];
88+
XCUIElement *item4 = self.app.staticTexts[@"Item 4"];
89+
XCTAssertTrue([item2 waitForExistenceWithTimeout:kStandardTimeOut],
90+
@"The element tree is %@", self.app.debugDescription);
91+
XCTAssertTrue([item4 waitForExistenceWithTimeout:kStandardTimeOut],
92+
@"The element tree is %@", self.app.debugDescription);
93+
94+
XCUIElementQuery *items = [self.app.staticTexts
95+
matchingPredicate:[NSPredicate
96+
predicateWithFormat:@"label MATCHES 'Item [1-4]'"]];
97+
NSArray<NSString *> *initialOrder =
98+
@[ @"Item 1", @"Item 2", @"Item 3", @"Item 4" ];
99+
XCTAssertEqualObjects(ElementLabels(items), initialOrder,
100+
@"The element tree is %@", self.app.debugDescription);
101+
102+
CGRect item2FrameBeforeReorder = item2.frame;
103+
CGRect expectedItem2FrameAfterReorder = item4.frame;
104+
XCUICoordinate *dragStart =
105+
[item2 coordinateWithNormalizedOffset:CGVectorMake(0.5, 0.5)];
106+
XCUICoordinate *dragEnd =
107+
[item4 coordinateWithNormalizedOffset:CGVectorMake(0.5, 0.5)];
108+
[dragStart pressForDuration:1.0 thenDragToCoordinate:dragEnd];
109+
110+
NSArray<NSString *> *reorderedItems =
111+
@[ @"Item 1", @"Item 3", @"Item 4", @"Item 2" ];
112+
NSPredicate *hierarchyUpdated =
113+
[NSPredicate predicateWithBlock:^BOOL(XCUIElementQuery *query,
114+
NSDictionary *bindings) {
115+
return [ElementLabels(query) isEqualToArray:reorderedItems];
116+
}];
117+
XCTNSPredicateExpectation *hierarchyExpectation =
118+
[[XCTNSPredicateExpectation alloc] initWithPredicate:hierarchyUpdated
119+
object:items];
120+
XCTWaiterResult result =
121+
[XCTWaiter waitForExpectations:@[ hierarchyExpectation ]
122+
timeout:kStandardTimeOut];
123+
CGRect item2FrameAfterReorder = item2.frame;
124+
XCTAssertEqual(result, XCTWaiterResultCompleted,
125+
@"Item 2 frame before: %@; after: %@. The element tree is %@",
126+
NSStringFromCGRect(item2FrameBeforeReorder),
127+
NSStringFromCGRect(item2FrameAfterReorder),
128+
self.app.debugDescription);
129+
XCTAssertEqualObjects(ElementLabels(items), reorderedItems,
130+
@"The element tree is %@", self.app.debugDescription);
131+
XCTAssertTrue(
132+
CGRectEqualToRect(item2FrameAfterReorder, expectedItem2FrameAfterReorder),
133+
@"Expected Item 2 frame %@ after reorder, but found %@. The element tree "
134+
@"is %@",
135+
NSStringFromCGRect(expectedItem2FrameAfterReorder),
136+
NSStringFromCGRect(item2FrameAfterReorder), self.app.debugDescription);
137+
}
138+
139+
- (void)testDragScreen {
140+
XCUIElement *entranceButton = self.app.buttons[@"drag test"];
141+
XCTAssertTrue([entranceButton waitForExistenceWithTimeout:kStandardTimeOut],
142+
@"The element tree is %@", self.app.debugDescription);
143+
[entranceButton tap];
144+
145+
XCUIElement *item3 = self.app.staticTexts[@"Item 3"];
146+
XCUIElement *item1 = self.app.staticTexts[@"Item 1"];
147+
XCTAssertTrue([item3 waitForExistenceWithTimeout:kStandardTimeOut],
148+
@"The element tree is %@", self.app.debugDescription);
149+
XCTAssertTrue([item1 waitForExistenceWithTimeout:kStandardTimeOut],
150+
@"The element tree is %@", self.app.debugDescription);
151+
152+
XCUICoordinate *dragStart =
153+
[item3 coordinateWithNormalizedOffset:CGVectorMake(0.5, 0.5)];
154+
XCUICoordinate *dragEnd =
155+
[item1 coordinateWithNormalizedOffset:CGVectorMake(0.5, 0.5)];
156+
[dragStart pressForDuration:1.0 thenDragToCoordinate:dragEnd];
157+
158+
XCUIElement *item2 = self.app.staticTexts[@"Item 2"];
159+
XCUIElement *item5 = self.app.staticTexts[@"Item 5"];
160+
XCTAssertTrue([item2 waitForExistenceWithTimeout:kStandardTimeOut],
161+
@"The element tree is %@", self.app.debugDescription);
162+
XCTAssertTrue([item5 waitForExistenceWithTimeout:kStandardTimeOut],
163+
@"The element tree is %@", self.app.debugDescription);
164+
165+
dragStart = [item2 coordinateWithNormalizedOffset:CGVectorMake(0.5, 0.5)];
166+
dragEnd = [item5 coordinateWithNormalizedOffset:CGVectorMake(0.5, 0.9)];
167+
[dragStart pressForDuration:1.0 thenDragToCoordinate:dragEnd];
168+
169+
XCUIElement *success = self.app.staticTexts[@"Drag Success"];
170+
XCTAssertTrue([success waitForExistenceWithTimeout:kStandardTimeOut],
171+
@"The element tree is %@", self.app.debugDescription);
172+
}
173+
71174
- (void)testPlatformViewZOrder {
72175
XCUIElement *entranceButton = self.app.buttons[@"platform view z order test"];
73176
XCTAssertTrue([entranceButton waitForExistenceWithTimeout:kStandardTimeOut], @"The element tree is %@", self.app.debugDescription);
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/foundation.dart';
6+
import 'package:flutter/material.dart';
7+
8+
class DragScreen extends StatefulWidget {
9+
const DragScreen({super.key});
10+
11+
@override
12+
State<DragScreen> createState() => _DragScreenState();
13+
}
14+
15+
class _DragScreenState extends State<DragScreen> {
16+
List<int> items = <int>[1, 2, 3, 4, 5];
17+
18+
static const List<int> targetOrder = <int>[3, 1, 4, 5, 2];
19+
20+
bool get isCorrectOrder => listEquals(items, targetOrder);
21+
22+
void _onReorderItem(int oldIndex, int newIndex) {
23+
setState(() {
24+
final int item = items.removeAt(oldIndex);
25+
items.insert(newIndex, item);
26+
});
27+
}
28+
29+
@override
30+
Widget build(BuildContext context) {
31+
return Scaffold(
32+
appBar: AppBar(
33+
title: const Text('Drag Test'),
34+
leading: IconButton(
35+
icon: const Icon(Icons.arrow_back),
36+
onPressed: () => Navigator.of(context).pop(),
37+
),
38+
),
39+
body: Column(
40+
children: <Widget>[
41+
Padding(
42+
padding: const EdgeInsets.all(16.0),
43+
child: Text(
44+
'Reorder items to: ${targetOrder.join(", ")}',
45+
style: Theme.of(context).textTheme.titleMedium,
46+
),
47+
),
48+
Padding(
49+
padding: const EdgeInsets.symmetric(horizontal: 16.0),
50+
child: Text(
51+
'Current order: ${items.join(", ")}',
52+
style: Theme.of(context).textTheme.bodyMedium,
53+
),
54+
),
55+
const SizedBox(height: 16),
56+
Expanded(
57+
child: ReorderableListView.builder(
58+
itemCount: items.length,
59+
onReorderItem: _onReorderItem,
60+
buildDefaultDragHandles: false,
61+
proxyDecorator: (Widget child, int index, Animation<double> animation) {
62+
return Material(elevation: 4, color: Colors.transparent, child: child);
63+
},
64+
itemBuilder: (BuildContext context, int index) {
65+
final int item = items[index];
66+
return ReorderableDragStartListener(
67+
key: ValueKey<int>(item),
68+
index: index,
69+
child: Container(
70+
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
71+
child: ListTile(
72+
leading: const Icon(Icons.drag_handle),
73+
title: Text(
74+
'Item $item',
75+
style: const TextStyle(fontSize: 18),
76+
semanticsLabel: 'Item $item',
77+
),
78+
),
79+
),
80+
);
81+
},
82+
),
83+
),
84+
if (isCorrectOrder)
85+
Container(
86+
padding: const EdgeInsets.all(24),
87+
color: Colors.green,
88+
width: double.infinity,
89+
child: const Text(
90+
'Drag Success',
91+
textAlign: TextAlign.center,
92+
style: TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold),
93+
),
94+
),
95+
],
96+
),
97+
);
98+
}
99+
}

‎dev/integration_tests/ios_platform_view_tests/lib/main.dart‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import 'package:flutter/material.dart';
66
import 'package:flutter/services.dart';
77
import 'package:flutter_driver/driver_extension.dart';
88

9+
import 'drag.dart';
10+
import 'reorderable_list_semantics.dart';
11+
912
void main() {
1013
// enableFlutterDriverExtension() will disable keyboard,
1114
// which is required for flutter_driver tests
@@ -127,6 +130,28 @@ class _MyHomePageState extends State<MyHomePage> {
127130
);
128131
},
129132
),
133+
TextButton(
134+
child: const Text('reorderable list semantics test'),
135+
onPressed: () {
136+
Navigator.push(
137+
context,
138+
MaterialPageRoute<ReorderableListSemanticsScreen>(
139+
builder: (BuildContext context) => const ReorderableListSemanticsScreen(),
140+
),
141+
);
142+
},
143+
),
144+
TextButton(
145+
child: const Text('drag test'),
146+
onPressed: () {
147+
Navigator.push(
148+
context,
149+
MaterialPageRoute<DragScreen>(
150+
builder: (BuildContext context) => const DragScreen(),
151+
),
152+
);
153+
},
154+
),
130155
],
131156
),
132157
);
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/material.dart';
6+
7+
/// A mobile [ReorderableListView] fixture that starts a drag after a long press.
8+
class ReorderableListSemanticsScreen extends StatefulWidget {
9+
const ReorderableListSemanticsScreen({super.key});
10+
11+
@override
12+
State<ReorderableListSemanticsScreen> createState() => _ReorderableListSemanticsScreenState();
13+
}
14+
15+
class _ReorderableListSemanticsScreenState extends State<ReorderableListSemanticsScreen> {
16+
final List<String> _items = <String>['Item 1', 'Item 2', 'Item 3', 'Item 4'];
17+
18+
@override
19+
Widget build(BuildContext context) {
20+
return Scaffold(
21+
appBar: AppBar(title: const Text('Reorderable list semantics test')),
22+
body: ReorderableListView.builder(
23+
itemCount: _items.length,
24+
itemBuilder: (BuildContext context, int index) {
25+
final String item = _items[index];
26+
return Semantics(
27+
container: true,
28+
explicitChildNodes: true,
29+
key: ValueKey<String>(item),
30+
child: ListTile(title: Text(item)),
31+
);
32+
},
33+
onReorderItem: (int oldIndex, int newIndex) {
34+
setState(() {
35+
_items.insert(newIndex, _items.removeAt(oldIndex));
36+
});
37+
},
38+
),
39+
);
40+
}
41+
}

‎engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject.mm‎

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,9 @@ - (void)dealloc {
290290
// See https://github.com/flutter/engine/pull/4602 and
291291
// https://github.com/flutter/engine/pull/27786.
292292
for (SemanticsObject* child in _children) {
293-
child.parent = nil;
293+
if (child.parent == self) {
294+
child.parent = nil;
295+
}
294296
}
295297
[_children removeAllObjects];
296298

@@ -302,7 +304,9 @@ - (void)dealloc {
302304

303305
- (void)setChildren:(NSArray<SemanticsObject*>*)children {
304306
for (SemanticsObject* child in _children) {
305-
child.parent = nil;
307+
if (child.parent == self) {
308+
child.parent = nil;
309+
}
306310
}
307311
_children = [children mutableCopy];
308312
for (SemanticsObject* child in _children) {
@@ -311,13 +315,7 @@ - (void)setChildren:(NSArray<SemanticsObject*>*)children {
311315
}
312316

313317
- (void)setChildrenInHitTestOrder:(NSArray<SemanticsObject*>*)childrenInHitTestOrder {
314-
for (SemanticsObject* child in _childrenInHitTestOrder) {
315-
child.parent = nil;
316-
}
317318
_childrenInHitTestOrder = [childrenInHitTestOrder copy];
318-
for (SemanticsObject* child in _childrenInHitTestOrder) {
319-
child.parent = self;
320-
}
321319
}
322320

323321
- (BOOL)hasChildren {

‎engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObjectTest.mm‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,43 @@ - (void)testSetChildren {
6969
XCTAssertNil(child.parent);
7070
}
7171

72+
- (void)testOldParentDoesNotDetachReparentedChild {
73+
fml::WeakPtrFactory<flutter::AccessibilityBridgeIos> factory(
74+
new flutter::testing::MockAccessibilityBridge());
75+
fml::WeakPtr<flutter::AccessibilityBridgeIos> bridge = factory.GetWeakPtr();
76+
SemanticsObject* oldParent = [[SemanticsObject alloc] initWithBridge:bridge uid:0];
77+
SemanticsObject* newParent = [[SemanticsObject alloc] initWithBridge:bridge uid:1];
78+
SemanticsObject* child = [[SemanticsObject alloc] initWithBridge:bridge uid:2];
79+
80+
oldParent.children = @[ child ];
81+
oldParent.childrenInHitTestOrder = @[ child ];
82+
newParent.children = @[ child ];
83+
newParent.childrenInHitTestOrder = @[ child ];
84+
85+
oldParent.children = @[];
86+
oldParent.childrenInHitTestOrder = @[];
87+
88+
XCTAssertEqual(child.parent, newParent);
89+
}
90+
91+
- (void)testSetChildrenInHitTestOrderDoesNotModifyParent {
92+
fml::WeakPtrFactory<flutter::AccessibilityBridgeIos> factory(
93+
new flutter::testing::MockAccessibilityBridge());
94+
fml::WeakPtr<flutter::AccessibilityBridgeIos> bridge = factory.GetWeakPtr();
95+
SemanticsObject* traversalParent = [[SemanticsObject alloc] initWithBridge:bridge uid:0];
96+
SemanticsObject* hitTestParent = [[SemanticsObject alloc] initWithBridge:bridge uid:1];
97+
SemanticsObject* child = [[SemanticsObject alloc] initWithBridge:bridge uid:2];
98+
99+
traversalParent.children = @[ child ];
100+
XCTAssertEqual(child.parent, traversalParent);
101+
102+
hitTestParent.childrenInHitTestOrder = @[ child ];
103+
XCTAssertEqual(child.parent, traversalParent);
104+
105+
hitTestParent.childrenInHitTestOrder = @[];
106+
XCTAssertEqual(child.parent, traversalParent);
107+
}
108+
72109
- (void)testAccessibilityHitTestFocusAtLeaf {
73110
fml::WeakPtrFactory<flutter::AccessibilityBridgeIos> factory(
74111
new flutter::testing::MockAccessibilityBridge());

0 commit comments

Comments
 (0)