Skip to content

[stable] [iOS] Preserve semantics parents after reparenting (#189686) + Fix OverlayPortal semantics bounds corruption (#192139) - #193420

Merged
auto-submit[bot] merged 2 commits into
flutter:flutter-3.47-candidate.0from
JoX23:cp-stable-ios-semantics-parents
Oct 7, 2026
Merged

auto-submit[bot] merged 2 commits into
flutter:flutter-3.47-candidate.0from
JoX23:cp-stable-ios-semantics-parents

Conversation

@JoX23

@JoX23 JoX23 commented Sep 28, 2026 •

Copy link
Copy Markdown

Cherry-picks #189686 and #192139 into flutter-3.47-candidate.0 (stable). Both applied cleanly with git cherry-pick -x; both touch only SemanticsObject.mm (+ tests).

Issue Link

#182604 · #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 fccfa97 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 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.

AfzalivE and others added 2 commits September 27, 2026 18:48
Fixes flutter#100946.

## Problem

Reordering a `ReorderableListView` on iOS could move VoiceOver focus to
the upper-left corner of the screen. The affected items could no longer
be focused at their visual positions.

During a reorder, semantics children move through temporary containers
used by the drag overlay and drop animation. A child may already have
been attached to its replacement `SemanticsObject` when its previous
parent is updated or deallocated.

The previous parent unconditionally cleared the child's `parent`
property. This overwrote the new parent relationship and disconnected
the child from the native semantics hierarchy.

`SemanticsObject` calculates its global accessibility frame by walking
this parent chain and composing each ancestor's transform. Once the
chain was disconnected, the element retained only its local
physical-pixel rectangle. On a 3x simulator, the expected frame:

```
{{0, 286}, {402, 56}}
```

became:

```
{{0, 0}, {134, 18.67}}
```

This accounts for both the missing translation and the frame being
reduced by the device pixel ratio.

## Fix

Only clear a child's parent when the `SemanticsObject` performing
cleanup still owns that parent relationship:

```objc
child.parent == self
```

This guard is applied when:

- A `SemanticsObject` is deallocated.
- Its traversal-order children are replaced.
- Its hit-test-order children are replaced.

A previous parent can now release its stale child references without
detaching a child that has already moved under a replacement container.
The complete ancestor transform remains available when calculating the
native accessibility frame.

## Testing

Added An iOS XCUITest that performs the default mobile long-press
reorder gesture, verifies the resulting native semantic traversal order,
and asserts that the reordered item retains the expected accessibility
frame.

The integration app also includes a deterministic direct-drag fixture
for exercising XCUITest drag synthesis.

Validation performed:

- `testReorderableListAccessibilityHierarchyUpdates` passes with the
locally built engine.
- `packages/flutter/test/widgets/reorderable_list_test.dart` passes.
- `packages/flutter/test/material/reorderable_list_test.dart` passes.
- `ios_test_flutter` builds successfully.
- Engine formatting and Dart analysis pass.

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [AI contribution guidelines] and understand my
responsibilities, or I am not using AI tools.
- [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.
- [ ] All existing and new tests are passing.

---------

Co-authored-by: LongCatIsLooong <[email protected]>
(cherry picked from commit 03ebdcd)
fixes flutter#182604

Historically, the configuration/order of children and parents for
widgets is identical across both traversal and hit test order. However,
with introduction over OverlayPortal,
flutter@fccfa97,
this is no longer always the case.

For example when traversing, the actual portal child, eg, a floating
drop down box, is a child of the `OverlayPortal`. However when hit
testing, the portal child is a child of the `Overlay`.

Prior to this pr, both setChildrenInHitTestOrder and setChildren have
identical implementations.

```
  for (SemanticsObject* child in _childrenInHitTestOrder) {
    if (child.parent == self) {
      child.parent = nil;
    }
  }
  _childrenInHitTestOrder = [childrenInHitTestOrder copy];
  for (SemanticsObject* child in _childrenInHitTestOrder) {
    child.parent = self;
  }
```

On every frame, setChildren and setChildrenInHitTestOrder is run
sequentially on every widget.
So first, when setChildren is run for `OverlayPortal`, it sets
`portalChild.parent = overlayPortal`. This is the correct traversal
order.
Then, sometime later, it runs setChildrenInHitTestOrder for `Overlay`,
which sets portalChild.parent = overlay`. This is incorrect.

This is why in the bug the accessibility nodes still exist, just weirdly
offset - because its now anchored to 0, 0 of `Overlay` instead of the
the position over `OverlayPortal`.

Because hittest order strictly walks top down (parent isnt ever
accessed), we don't actually have to do any mutation to the parents.

https://github.com/user-attachments/assets/df093797-7482-42ba-ba4c-b9a172484031
(cherry picked from commit 20c755d)
@JoX23
JoX23 requested a review from a team as a code owner September 28, 2026 00:50
@flutter-dashboard

Copy link
Copy Markdown

This pull request was opened from and to a release candidate branch. This should only be done as part of the official Flutter release process. If you are attempting to make a regular contribution to the Flutter project, please close this PR and follow the instructions at Tree Hygiene for detailed instructions on contributing to Flutter.

Reviewers: Use caution before merging pull requests to release branches. Ensure the proper procedure has been followed.

@github-actions github-actions Bot added platform-ios iOS applications specifically engine flutter/engine related. See also e: labels. a: accessibility Accessibility, e.g. VoiceOver or TalkBack. (aka a11y) team-ios Owned by iOS platform team labels Sep 28, 2026
@google-cla

google-cla Bot commented Sep 28, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request modifies iOS platform view semantics handling to prevent an old parent from detaching a reparented child, and introduces integration tests for reorderable list semantics and dragging. The review feedback correctly identifies a potential runtime crash in the Objective-C test helper if element.label is nil, as well as logic errors in the Dart reordering implementations where newIndex is not decremented when oldIndex < newIndex.

Comment on lines +9 to +15
static NSArray<NSString *> *ElementLabels(XCUIElementQuery *query) {
NSMutableArray<NSString *> *labels = [[NSMutableArray alloc] init];
for (XCUIElement *element in query.allElementsBoundByIndex) {
[labels addObject:element.label];
}
return labels;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The element.label property can potentially return nil. Attempting to add a nil object to an NSMutableArray will cause a runtime crash. To prevent this, check if the label is non-nil before adding it to the array.

static NSArray<NSString *> *ElementLabels(XCUIElementQuery *query) {
  NSMutableArray<NSString *> *labels = [[NSMutableArray alloc] init];
  for (XCUIElement *element in query.allElementsBoundByIndex) {
    NSString *label = element.label;
    if (label) {
      [labels addObject:label];
    }
  }
  return labels;
}

Comment on lines +22 to +27
void _onReorderItem(int oldIndex, int newIndex) {
setState(() {
final int item = items.removeAt(oldIndex);
items.insert(newIndex, item);
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When reordering items in a list, if oldIndex < newIndex, the newIndex is reported as if the item has already been removed, which shifts the remaining items' indices down by 1. To avoid out-of-bounds or incorrect insertions, you must decrement newIndex by 1 when oldIndex < newIndex.

  void _onReorderItem(int oldIndex, int newIndex) {
    setState(() {
      if (oldIndex < newIndex) {
        newIndex -= 1;
      }
      final int item = items.removeAt(oldIndex);
      items.insert(newIndex, item);
    });
  }

Comment on lines +33 to +37
onReorderItem: (int oldIndex, int newIndex) {
setState(() {
_items.insert(newIndex, _items.removeAt(oldIndex));
});
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When reordering items in a list, if oldIndex < newIndex, the newIndex is reported as if the item has already been removed, which shifts the remaining items' indices down by 1. To avoid out-of-bounds or incorrect insertions, you must decrement newIndex by 1 when oldIndex < newIndex.

        onReorderItem: (int oldIndex, int newIndex) {
          setState(() {
            if (oldIndex < newIndex) {
              newIndex -= 1;
            }
            _items.insert(newIndex, _items.removeAt(oldIndex));
          });
        },

@JoX23

JoX23 commented Sep 28, 2026

Copy link
Copy Markdown
Author

@googlebot I signed it!

@cbracken
cbracken requested a review from vashworth October 1, 2026 21:48
@cbracken cbracken added cp: beta cherry pick this pull request to beta release candidate branch cp: review Cherry-picks in the review queue and removed cp: beta cherry pick this pull request to beta release candidate branch labels Oct 1, 2026
@vashworth vashworth added the CICD Run CI/CD label Oct 2, 2026
@JoX23

JoX23 commented Oct 4, 2026

Copy link
Copy Markdown
Author

Thanks — all three suggestions are on files that this PR does not modify relative to the commit being cherry-picked, so I'm leaving them as-is. Verifiable:

git diff 03ebdcd6bd9 5f5aabf11a7 -- \
  dev/integration_tests/ios_platform_view_tests/lib/drag.dart \
  dev/integration_tests/ios_platform_view_tests/lib/reorderable_list_semantics.dart \
  dev/integration_tests/ios_platform_view_tests/ios/PlatformViewUITests/PlatformViewUITests.m
# empty

03ebdcd6bd9 is the upstream commit for #189686 recorded by git cherry-pick -x. Carrying a delta here that wasn't reviewed on master is exactly what a release-branch cherry-pick should not do; if any of these is worth changing, it belongs in a separate PR to master.

Two of the three would also be incorrect:

drag.dart:27 and reorderable_list_semantics.dart:37 — the suggestion adds if (oldIndex < newIndex) newIndex -= 1. That is the contract of onReorder, which is deprecated. These call sites use onReorderItem, whose contract is the opposite, per packages/flutter/lib/src/material/reorderable_list.dart:81-85:

'Use the onReorderItem callback instead. '
'The onReorderItem callback adjusts the newIndex parameter for a removed item at the oldIndex. '
'This feature was deprecated after v3.41.0-0.0.pre.'

Both constructors also assert that exactly one of the two is provided (:128-133, :213-218: "The onReorder callback is obsolete and is replaced by onReorderItem"). Applying the suggestion would adjust newIndex a second time and introduce an off-by-one in the test apps.

PlatformViewUITests.m:15 — XCUIElement.label is declared nonnull: @property (readonly, copy) NSString *label; sits inside the NS_ASSUME_NONNULL_BEGIN/NS_ASSUME_NONNULL_END region of XCUIElementAttributes.h (lines 28 / 47 / 106 in the iOS SDK shipped with Xcode). The guard contradicts the declared contract rather than hardening it — and the file is upstream-identical either way.

@JoX23

JoX23 commented Oct 4, 2026

Copy link
Copy Markdown
Author

Gentle ping on cherry-pick triage. Per docs/releases/Flutter-Cherrypick-Process.md I believe this is at step 4.1 of the lifecycle with nothing pending on my side:

  • Targets flutter-3.47-candidate.0, which is what bin/internal/release-candidate-branch.version on stable currently names. Title prefixed [stable], all description fields filled, cp: review applied — manual process steps 1-4.
  • An expert reviewer was assigned and approved on 2026-10-02 (vashworth).
  • MERGEABLE, CLA signed, checks green (8 pass / 1 skipping, 0 failing).

On the acceptance bar — the doc scopes this to "regressions from the previous release" that are "highly impactful and critical":

  • [Regression] OverlayPortal semantics bounds/coordinates corrupted after fccfa978a97 #182604 is labelled c: regression, and both linked issues are closed on master.
  • No user action is required to hit it. Slider keeps its value-indicator OverlayPortal shown from construction by design — packages/flutter/lib/src/material/slider.dart:679-683 on this very branch: "Always keep the ValueIndicator visible on the Overlay" with OverlayPortalController(...)..show(). So on any route containing a Material Slider, every element publishes an accessibility frame scaled by 1/devicePixelRatio from the first frame. On a 3x device a CTA laid out at {{32, 834}, {376, 56}} is published as {{10.7, 278.0}, {125.3, 18.7}}.
  • It breaks VoiceOver touch exploration and XCUIElement.tap(), so it also breaks XCUITest and Maestro suites, not only end-user accessibility.
  • Risk is confined to parent bookkeeping, and both upstream PRs carry their own tests (SemanticsObjectTest.mm; testReorderableListAccessibilityHierarchyUpdates).

3.47.6 shipped on 2026-09-30, two days after this was opened, so it missed that hotfix. Happy to retarget or to split the two picks into separate PRs if either makes this easier to take.

@justinmc justinmc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CP LGTM, thanks! 👍

@justinmc

justinmc commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

@JoX23

JoX23 commented Oct 7, 2026

Copy link
Copy Markdown
Author

@justinmc Added under Changelog Description in the PR description:

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.

@justinmc

justinmc commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

Thank you! Let's land this.

@justinmc justinmc added the autosubmit Merge PR when tree becomes green via auto submit App label Oct 7, 2026
@auto-submit
auto-submit Bot merged commit deb2874 into flutter:flutter-3.47-candidate.0 Oct 7, 2026
14 checks passed
auto-submit Bot pushed a commit that referenced this pull request Oct 7, 2026
…193959)

Updates the changelog for the 3.47.7 stable hotfix release. The only cherry pick was #193420.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

a: accessibility Accessibility, e.g. VoiceOver or TalkBack. (aka a11y) autosubmit Merge PR when tree becomes green via auto submit App CICD Run CI/CD cp: review Cherry-picks in the review queue engine flutter/engine related. See also e: labels. platform-ios iOS applications specifically team-ios Owned by iOS platform team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants