Skip to content

[iOS] Give hidden semantics nodes in scrollables a non-empty accessib… - #193723

Open
uditswaroopa09 wants to merge 1 commit into
flutter:masterfrom
uditswaroopa09:ios27-voiceover-scroll-hidden-frame
Open

uditswaroopa09 wants to merge 1 commit into
flutter:masterfrom
uditswaroopa09:ios27-voiceover-scroll-hidden-frame

Conversation

@uditswaroopa09

Copy link
Copy Markdown

Since iOS 27, VoiceOver no longer treats elements whose accessibilityFrame
is CGRectZero as candidates for the next focusable element. Flutter's
scrollables flag off-screen children as isHidden and report CGRectZero
for their frame, causing single-finger swipe to stop at the last visible
item instead of triggering accessibilityScrollToVisible and scrolling
implicitly.

Hidden nodes inside a FlutterScrollableSemanticsObject now report a 1pt
strip at the leading or trailing edge of the scrollable's viewport (depending
on which side the node lives on). VoiceOver treats these like clipped elements
and fires the existing showOnScreen path to scroll them into view.

Vertical and horizontal scrollables are both handled. Hidden nodes with no
scrollable ancestor retain their CGRectZero frame — no behaviour change for
those.

Related Issues

Fixes #192774

Tests

Three new XCTest cases in SemanticsObjectTest.mm:

  • testHiddenNodeInVerticalScrollableHasNonEmptyFrameAtViewportEdge
  • testHiddenNodeInHorizontalScrollableHasNonEmptyFrameAtViewportEdge
  • testHiddenNodeOutsideScrollableKeepsEmptyFrame

Checklist

…ility frame

Since iOS 27, VoiceOver no longer treats elements whose accessibilityFrame
is CGRectZero as candidates for the next element. Off-screen children of a
scrollable are flagged isHidden and reported CGRectZero, so a one-finger
swipe stopped at the last visible item instead of triggering
accessibilityScrollToVisible and an implicit scroll.
Hidden nodes inside a FlutterScrollableSemanticsObject now report a 1pt
strip at the leading or trailing edge of the scrollable's viewport,
depending on which side the node lives on. VoiceOver handles them like
clipped elements, and the existing showOnScreen path scrolls them in.
Fixes flutter#192774
@uditswaroopa09
uditswaroopa09 requested a review from a team as a code owner October 2, 2026 19:37
@github-actions github-actions Bot added platform-ios iOS applications specifically engine flutter/engine related. See also e: labels. team-ios Owned by iOS platform team labels Oct 2, 2026

@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 updates SemanticsObject.mm to calculate and return a non-empty frame for hidden nodes inside scrollable viewports, preventing VoiceOver from skipping them, and adds corresponding tests in SemanticsObjectTest.mm. The review feedback highlights memory leaks in the new tests due to manual heap allocation of MockAccessibilityBridge and suggests using std::unique_ptr for lifetime management. It also recommends replacing XCTAssertTrue with XCTAssertEqual in assertions to provide clearer failure messages.

Comment on lines +443 to +445
fml::WeakPtrFactory<flutter::testing::MockAccessibilityBridge> factory(
new flutter::testing::MockAccessibilityBridge());
fml::WeakPtr<flutter::testing::MockAccessibilityBridge> bridge = factory.GetWeakPtr();

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.

medium

The test allocates MockAccessibilityBridge on the heap using new but never deletes it, leading to a memory leak. Since fml::WeakPtrFactory does not take ownership of the pointer, we should manage its lifetime using std::unique_ptr to prevent memory leaks.

  auto bridge_impl = std::make_unique<flutter::testing::MockAccessibilityBridge>();\n  fml::WeakPtrFactory<flutter::testing::MockAccessibilityBridge> factory(bridge_impl.get());\n  fml::WeakPtr<flutter::testing::MockAccessibilityBridge> bridge = factory.GetWeakPtr();
References
  1. C++ code should follow the Google C++ Style Guide, which recommends using std::unique_ptr to manage ownership of dynamically allocated objects. (link)

Comment on lines +507 to +509
XCTAssertTrue(bridge->observations.size() == 1);
XCTAssertTrue(bridge->observations[0].id == 3);
XCTAssertTrue(bridge->observations[0].action == flutter::SemanticsAction::kShowOnScreen);

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.

medium

Using XCTAssertEqual instead of XCTAssertTrue with an equality operator provides much better failure messages because it prints both the expected and actual values when the assertion fails.

  XCTAssertEqual(bridge->observations.size(), 1U);\n  XCTAssertEqual(bridge->observations[0].id, 3);\n  XCTAssertEqual(bridge->observations[0].action, flutter::SemanticsAction::kShowOnScreen);

Comment on lines +513 to +515
fml::WeakPtrFactory<flutter::AccessibilityBridgeIos> factory(
new flutter::testing::MockAccessibilityBridge());
fml::WeakPtr<flutter::AccessibilityBridgeIos> bridge = factory.GetWeakPtr();

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.

medium

The test allocates MockAccessibilityBridge on the heap using new but never deletes it, leading to a memory leak. Since fml::WeakPtrFactory does not take ownership of the pointer, we should manage its lifetime using std::unique_ptr to prevent memory leaks.

  auto bridge_impl = std::make_unique<flutter::testing::MockAccessibilityBridge>();\n  fml::WeakPtrFactory<flutter::AccessibilityBridgeIos> factory(bridge_impl.get());\n  fml::WeakPtr<flutter::AccessibilityBridgeIos> bridge = factory.GetWeakPtr();
References
  1. C++ code should follow the Google C++ Style Guide, which recommends using std::unique_ptr to manage ownership of dynamically allocated objects. (link)

Comment on lines +547 to +549
fml::WeakPtrFactory<flutter::AccessibilityBridgeIos> factory(
new flutter::testing::MockAccessibilityBridge());
fml::WeakPtr<flutter::AccessibilityBridgeIos> bridge = factory.GetWeakPtr();

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.

medium

The test allocates MockAccessibilityBridge on the heap using new but never deletes it, leading to a memory leak. Since fml::WeakPtrFactory does not take ownership of the pointer, we should manage its lifetime using std::unique_ptr to prevent memory leaks.

  auto bridge_impl = std::make_unique<flutter::testing::MockAccessibilityBridge>();\n  fml::WeakPtrFactory<flutter::AccessibilityBridgeIos> factory(bridge_impl.get());\n  fml::WeakPtr<flutter::AccessibilityBridgeIos> bridge = factory.GetWeakPtr();
References
  1. C++ code should follow the Google C++ Style Guide, which recommends using std::unique_ptr to manage ownership of dynamically allocated objects. (link)

@uditswaroopa09

Copy link
Copy Markdown
Author

Code Review

This pull request updates SemanticsObject.mm to calculate and return a non-empty frame for hidden nodes inside scrollable viewports, preventing VoiceOver from skipping them, and adds corresponding tests in SemanticsObjectTest.mm. The review feedback highlights memory leaks in the new tests due to manual heap allocation of MockAccessibilityBridge and suggests using std::unique_ptr for lifetime management. It also recommends replacing XCTAssertTrue with XCTAssertEqual in assertions to provide clearer failure messages.

Thanks. These follow the existing convention in SemanticsObjectTest.mm —
MockAccessibilityBridge is constructed with new at ~57 existing call
sites in this file, and XCTAssertTrue(... == ...) is the prevailing assert
style here. I kept the new tests consistent with surrounding code rather
than introducing a mixed pattern. Happy to switch all of them in a separate
cleanup if the team prefers std::unique_ptr / XCTAssertEqual broadly.

@FrancescoAbbondo

Copy link
Copy Markdown

Hi @LongCatIsLooong @LouiseHsu Do you have an estimated timeline for approving this pull request? We urgently need this fix. Thanks!

@LongCatIsLooong

Copy link
Copy Markdown
Contributor

Hi @LongCatIsLooong @LouiseHsu Do you have an estimated timeline for approving this pull request? We urgently need this fix. Thanks!

I looked at this PR but I'm still debugging the issue to figure out if there's a way to fix the root cause. The plan is to put a fix in the upcoming 3.50 release. If you need a fix right now the fastest way is to apply the patch locally (https://github.com/flutter/flutter/blob/master/docs/releases/Flutter-build-release-channels.md#will-a-particular-bug-fix-be-provided-in-a-hotfix-release).

@john-madaras

Copy link
Copy Markdown

Hi, any news on this ticket? This is causing significant issues for impaired iOS 27 users who leverage Voiceover. Thanks!

@LongCatIsLooong

Copy link
Copy Markdown
Contributor

(keeping this open in case the other PR isn't a good cherry-pick candidate).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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.

[iOS 27] VoiceOver's implicit-scroll-into-view no longer works - single-finger swipe never advances past the visible boundary of a Scrollable

4 participants