[CP-stable] fixes a status bar tap crash#182691
[CP-stable] fixes a status bar tap crash#182691auto-submit[bot] merged 4 commits intoflutter:flutter-3.41-candidate.0from
Conversation
…on status bar tap (flutter#182391) Reland flutter#179643, with [changes](flutter@3f7d345) to fix flutter#182233. The reland closes flutter#177992, closes flutter#175606. The crash happened because `Scaffold.handleStatusBar` (and subsequently `ScrollController.animateTo`) is called on a scaffold that [has never been laid out](flutter#182233 (comment)). Before flutter#179643, the iOS embedder sends a fake touch down pointer event at `(0, 0)` to the framework, followed by a touch up pointer event at the same location. That ensured only the foreground `Scaffold` could claim the tap gesture and handle the status bar tap. The fix tries to restore the previous behavior (where the iOS behavior sends fake touches instead of platform messages) by performing a hit-test at `(0, 0)` before trying to scroll the primary controller of a `Scaffold` to the top, this should make sure that only the foreground scaffold (in theory it's still possible for more than one Scaffold to receive the hittest and handle the status bar tap event, but the foreground scaffold should block the hit-test on background ones instead of relying on gesture disambiguation). - [ ] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [ ] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [ ] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [ ] I signed the [CLA]. - [ ] I listed at least one issue that this PR fixes in the description above. - [ ] I updated/added relevant documentation (doc comments with `///`). - [ ] I added new tests to check the change I am making, or this PR is [test-exempt]. - [ ] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [ ] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. **Note**: The Flutter team is currently trialing the use of [Gemini Code Assist for GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code). Comments from the `gemini-code-assist` bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
|
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. |
There was a problem hiding this comment.
Code Review
This pull request addresses a crash on iOS when tapping the status bar by ensuring only the topmost, hit-testable scaffold responds to the tap. This also improves the user experience for apps with multiple nested scaffolds. The implementation correctly uses a hit-testable widget in the status bar area to determine if a scaffold is in the foreground. The test changes are good, with a new simulateStatusBarTap helper and tests covering the new behavior. My main feedback is on code duplication: the _HitTestableAtOrigin helper class is duplicated in both material/scaffold.dart and cupertino/page_scaffold.dart. This should be refactored into a shared utility to improve maintainability.
| final class _HitTestableAtOrigin extends StatelessWidget { | ||
| const _HitTestableAtOrigin(this.globalKey); | ||
|
|
||
| final GlobalKey globalKey; | ||
|
|
||
| /// Whether the render box of the [_HitTestableAtOrigin] widget associated | ||
| /// with the given global `key` is hit-testable at [Offset.zero]. | ||
| /// | ||
| /// This is used by the `handleStatusBarTap` implementation to avoid sending | ||
| /// status bar tap events to scroll views in offscreen subtrees. | ||
| static bool hitTestableAtOrigin(GlobalKey key) { | ||
| final context = key.currentContext as Element?; | ||
| if (context == null) { | ||
| assert( | ||
| false, | ||
| 'BuildContext associated with $key is not mounted. ' | ||
| 'If you see this in a test, this is likely because the test was trying ' | ||
| 'to simulate status bar tap on a non-iOS platform', | ||
| ); | ||
| return false; | ||
| } | ||
| final renderObject = context.renderObject! as RenderMetaData; | ||
| final int viewId = View.of(context).viewId; | ||
| final result = HitTestResult(); | ||
| WidgetsBinding.instance.hitTestInView(result, Offset.zero, viewId); | ||
| return result.path.any((HitTestEntry entry) => entry.target == renderObject); | ||
| } | ||
|
|
||
| @override | ||
| Widget build(BuildContext context) { | ||
| return MetaData( | ||
| key: globalKey, | ||
| behavior: HitTestBehavior.translucent, | ||
| child: const SizedBox.expand(), | ||
| ); | ||
| } | ||
| } |
There was a problem hiding this comment.
The _HitTestableAtOrigin class is nearly identical to the one in packages/flutter/lib/src/cupertino/page_scaffold.dart. Duplicating code like this makes maintenance harder and violates the DRY (Don't Repeat Yourself) principle.
To improve maintainability, this class should be extracted to a shared location (e.g., a new file under lib/src/widgets/) and made public. The slightly different assert messages in the two versions can be merged into a single, more descriptive one.
References
- The style guide advises to 'Avoid duplicating state', which by extension applies to code logic as well to maintain a single source of truth and improve maintainability. (link)
…l views on status bar tap (flutter#182391)" This reverts commit d2eeab6.
…9643)" (flutter#182223) This reverts commit f4c83de. See b/482565401. ## 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. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. **Note**: The Flutter team is currently trialing the use of [Gemini Code Assist for GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code). Comments from the `gemini-code-assist` bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
…on status bar tap (flutter#182391) Reland flutter#179643, with [changes](flutter@3f7d345) to fix flutter#182233. The reland closes flutter#177992, closes flutter#175606. The crash happened because `Scaffold.handleStatusBar` (and subsequently `ScrollController.animateTo`) is called on a scaffold that [has never been laid out](flutter#182233 (comment)). Before flutter#179643, the iOS embedder sends a fake touch down pointer event at `(0, 0)` to the framework, followed by a touch up pointer event at the same location. That ensured only the foreground `Scaffold` could claim the tap gesture and handle the status bar tap. The fix tries to restore the previous behavior (where the iOS behavior sends fake touches instead of platform messages) by performing a hit-test at `(0, 0)` before trying to scroll the primary controller of a `Scaffold` to the top, this should make sure that only the foreground scaffold (in theory it's still possible for more than one Scaffold to receive the hittest and handle the status bar tap event, but the foreground scaffold should block the hit-test on background ones instead of relying on gesture disambiguation). - [ ] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [ ] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [ ] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [ ] I signed the [CLA]. - [ ] I listed at least one issue that this PR fixes in the description above. - [ ] I updated/added relevant documentation (doc comments with `///`). - [ ] I added new tests to check the change I am making, or this PR is [test-exempt]. - [ ] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [ ] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. **Note**: The Flutter team is currently trialing the use of [Gemini Code Assist for GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code). Comments from the `gemini-code-assist` bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
There was a problem hiding this comment.
Code Review
The pull request addresses a critical crash on iOS related to status bar taps when a primary scroll view has not been laid out. The fix involves adding a hit-testable widget at the status bar's position to ensure that the scroll-to-top event is only dispatched to the foregrounded Scaffold or CupertinoPageScaffold. This is a good approach to prevent unintended scrolling behavior and crashes. The changes also include updates to tests to reflect this new behavior and a new simulateStatusBarTap helper in flutter_test for easier testing.
| if (primaryScrollController != null && | ||
| primaryScrollController.hasClients && | ||
| // TODO(LongCatIsLooong): the iOS embedder used to send status bar tap | ||
| // evets as fake touches at Offset.zero, such that at most one Scaffold | ||
| // (usually the foreground primary Scaffold) can handle the status bar | ||
| // tap event, thanks to hit-testing and gesture disambiguation. | ||
| // To keep that behavior, this widget performs an additional hit-test here | ||
| // to make sure the status bar tap is only handled if the scaffold is | ||
| // hit-testable (thus in the foreground) | ||
| // Switch to a better solution when available: | ||
| // https://github.com/flutter/flutter/issues/182403 | ||
| _HitTestableAtOrigin.hitTestableAtOrigin(_statusBarKey)) { |
There was a problem hiding this comment.
| 'status bar tap only scrolls the foregrounded primary controller', | ||
| (WidgetTester tester) async { | ||
| final app = MaterialApp( | ||
| initialRoute: 'a', | ||
| onGenerateInitialRoutes: (initialRoute) { | ||
| return [ | ||
| MaterialPageRoute(builder: (context) => _ScaffoldWithPrimaryScrollView()), | ||
| MaterialPageRoute(builder: (context) => _ScaffoldWithPrimaryScrollView()), | ||
| ]; | ||
| }, | ||
| onGenerateRoute: (_) => throw UnimplementedError(), | ||
| ); | ||
| } | ||
| await tester.pumpWidget(app); | ||
|
|
||
| // Finally stops at the top. | ||
| expect(scrollable.position.pixels, equals(0.0)); | ||
| }); | ||
| final Iterable<ScrollableState> scrollables = tester.stateList<ScrollableState>( | ||
| find.descendant( | ||
| of: find.byType(_ScaffoldWithPrimaryScrollView, skipOffstage: false), | ||
| matching: find.byType(Scrollable, skipOffstage: false), | ||
| skipOffstage: false, | ||
| ), | ||
| ); | ||
|
|
||
| final [ScrollableState scrollable1, ScrollableState scrollable2] = scrollables.toList(); | ||
| expect(scrollable1.position.pixels, 1000); | ||
| expect(scrollable2.position.pixels, 1000); | ||
|
|
||
| tester.simulateStatusBarTap(); | ||
| await tester.pumpAndSettle(); | ||
|
|
||
| expect(scrollable1.position.pixels, 1000); | ||
| expect(scrollable2.position.pixels, 0); | ||
| }, | ||
| variant: TargetPlatformVariant.only(TargetPlatform.iOS), | ||
| ); |
There was a problem hiding this comment.
This new test case is crucial for validating that the status bar tap only affects the foregrounded primary scroll controller in Material Design scaffolds. This directly confirms the effectiveness of the _HitTestableAtOrigin mechanism in preventing unintended scrolling behavior across multiple scaffolds.
| testWidgets('status bar tap only scrolls the foregrounded primary controller', ( | ||
| WidgetTester tester, | ||
| ) async { | ||
| final app = CupertinoApp( | ||
| initialRoute: 'a', | ||
| onGenerateInitialRoutes: (initialRoute) { | ||
| return [ | ||
| CupertinoPageRoute(builder: (context) => _ScaffoldWithPrimaryScrollView()), | ||
| CupertinoPageRoute(builder: (context) => _ScaffoldWithPrimaryScrollView()), | ||
| ]; | ||
| }, | ||
| onGenerateRoute: (_) => throw UnimplementedError(), | ||
| ); | ||
| tester.binding.defaultBinaryMessenger.handlePlatformMessage( | ||
| SystemChannels.statusBar.name, | ||
| message, | ||
| (ByteData? data) {}, | ||
| await tester.pumpWidget(app); | ||
|
|
||
| final Iterable<ScrollableState> scrollables = tester.stateList<ScrollableState>( | ||
| find.descendant( | ||
| of: find.byType(_ScaffoldWithPrimaryScrollView, skipOffstage: false), | ||
| matching: find.byType(Scrollable, skipOffstage: false), | ||
| skipOffstage: false, | ||
| ), | ||
| ); | ||
|
|
||
| final [ScrollableState scrollable1, ScrollableState scrollable2] = scrollables.toList(); | ||
| expect(scrollable1.position.pixels, 1000); | ||
| expect(scrollable2.position.pixels, 1000); | ||
|
|
||
| tester.simulateStatusBarTap(); | ||
| await tester.pumpAndSettle(); | ||
|
|
||
| expect(scrollController.offset, 0.0); | ||
| expect(scrollable1.position.pixels, 1000); | ||
| expect(scrollable2.position.pixels, 0); | ||
| }); |
| if (primaryScrollController != null && | ||
| primaryScrollController.hasClients && | ||
| // TODO(LongCatIsLooong): the iOS embedder used to send status bar tap | ||
| // evets as fake touches at Offset.zero, such that at most one Scaffold | ||
| // (usually the foreground CupertinoPageScaffold) can handle the status | ||
| // bar tap event, thanks to hit-testing and gesture disambiguation. | ||
| // To keep that behavior, this widget performs an additional hit-test here | ||
| // to make sure the status bar tap is only handled if this scaffold is | ||
| // hit-testable (thus in the foreground). | ||
| // Switch to a better solution when available: | ||
| // https://github.com/flutter/flutter/issues/182403 | ||
| _HitTestableAtOrigin.hitTestableAtOrigin(_statusBarKey)) { |
There was a problem hiding this comment.
The added condition _HitTestableAtOrigin.hitTestableAtOrigin(_statusBarKey) is crucial for preventing crashes by ensuring that the status bar tap event is only handled if the CupertinoPageScaffold is in the foreground and hit-testable. The TODO comment clearly explains the rationale behind this temporary solution, which is good for future improvements.
| import 'package:flutter/cupertino.dart'; | ||
| import 'package:flutter/services.dart' show SystemChannels; | ||
| import 'package:flutter/src/services/message_codecs.dart'; | ||
| import 'package:flutter_test/flutter_test.dart'; | ||
|
|
| const sizedBox = SizedBox(width: 200.0, height: 200.0); | ||
| await tester.pumpWidget( | ||
| MaterialApp( | ||
| home: Scaffold( | ||
| body: Center( | ||
| child: InteractiveViewer( | ||
| transformationController: transformationController, | ||
| scaleEnabled: false, | ||
| onInteractionStart: (ScaleStartDetails details) { | ||
| calledStart = true; | ||
| }, | ||
| onInteractionUpdate: (ScaleUpdateDetails details) { | ||
| calledUpdate = true; | ||
| }, | ||
| onInteractionEnd: (ScaleEndDetails details) { | ||
| calledEnd = true; | ||
| }, | ||
| child: const SizedBox(width: 200.0, height: 200.0), | ||
| ), | ||
| ), | ||
| Center( | ||
| child: InteractiveViewer( | ||
| transformationController: transformationController, | ||
| scaleEnabled: false, | ||
| onInteractionStart: (ScaleStartDetails details) { | ||
| calledStart = true; | ||
| }, | ||
| onInteractionUpdate: (ScaleUpdateDetails details) { | ||
| calledUpdate = true; | ||
| }, | ||
| onInteractionEnd: (ScaleEndDetails details) { | ||
| calledEnd = true; | ||
| }, | ||
| child: sizedBox, |
There was a problem hiding this comment.
| ); | ||
|
|
||
| final Offset childOffset = tester.getTopLeft(find.byType(SizedBox)); | ||
| final Offset childOffset = tester.getTopLeft(find.byWidget(sizedBox)); |
| await tester.pumpWidget( | ||
| MaterialApp( | ||
| home: Scaffold( | ||
| body: Center( | ||
| child: InteractiveViewer( | ||
| transformationController: transformationController, | ||
| scaleEnabled: false, | ||
| onInteractionStart: (ScaleStartDetails details) { | ||
| calledStart = true; | ||
| }, | ||
| onInteractionUpdate: (ScaleUpdateDetails details) { | ||
| calledUpdate = true; | ||
| }, | ||
| onInteractionEnd: (ScaleEndDetails details) { | ||
| calledEnd = true; | ||
| }, | ||
| child: const SizedBox(width: 200.0, height: 200.0), | ||
| ), | ||
| ), | ||
| Center( | ||
| child: InteractiveViewer( | ||
| transformationController: transformationController, | ||
| scaleEnabled: false, | ||
| onInteractionStart: (ScaleStartDetails details) { | ||
| calledStart = true; | ||
| }, | ||
| onInteractionUpdate: (ScaleUpdateDetails details) { | ||
| calledUpdate = true; | ||
| }, | ||
| onInteractionEnd: (ScaleEndDetails details) { | ||
| calledEnd = true; | ||
| }, | ||
| child: sizedBox, | ||
| ), |
| ); | ||
|
|
||
| final Offset childOffset = tester.getTopLeft(find.byType(SizedBox)); | ||
| final Offset childOffset = tester.getTopLeft(find.byWidget(sizedBox)); |
| /// Sends a 'handleScrollToTop' message to the test application via the | ||
| /// [SystemChannels.statusBar] channel, to simulate an iOS status bar tap | ||
| /// event. | ||
| void simulateStatusBarTap() { | ||
| final ByteData message = const JSONMethodCodec().encodeMethodCall( | ||
| const MethodCall('handleScrollToTop'), | ||
| ); | ||
| binding.defaultBinaryMessenger.handlePlatformMessage( | ||
| SystemChannels.statusBar.name, | ||
| message, | ||
| (ByteData? data) {}, | ||
| ); | ||
| } |
There was a problem hiding this comment.
The new simulateStatusBarTap() method is a valuable addition to WidgetController. It encapsulates the platform-specific details of sending a status bar tap event, making tests more readable and less prone to errors. This directly supports the new hit-testing logic in Scaffold and CupertinoPageScaffold.
|
The changes are all on the framework side, so reassigning to @chunhtai for review |
|
@chunhtai I need more than a rubber stamp approval since this pr had conflicts and had to be manually modified. Please review the conflicts and add your approval or comments. |
chunhtai
left a comment
There was a problem hiding this comment.
still LGTM, the merge conflict is on the test file, and the code looks fine.
449e274
into
flutter:flutter-3.41-candidate.0
Only 2 non test cherry picks for stable since 3.41.2 https://github.com/flutter/flutter/pulls?q=is%3Apr+base%3Aflutter-3.41-candidate.0+is%3Aclosed #182864 #182691
This pull request is manually created because there were merge conflicts.
Click here for the conflicts
Issue Link:
What is the link to the issue this cherry-pick is addressing?
#182233
The bug was introduced in #179643 which was cherry picked to 3.41 beta (so the crash should exist on both current beta and stable).
Impact Description:
On iOS, the app may crash when the user taps on the status bar to scroll to top, when the app has a primary scroll view that has never been laid out (for example, in a route that has been obstructed by other routes).
Changelog Description:
Additionally this patch only dispatches the "scroll to top" command to the topmost Scaffold which is generally better UX for most apps (this doesn't have to be in the changlog)
Workaround:
No known easy workarounds.
Risk:
What is the risk level of this cherry-pick?
Test Coverage:
Are you confident that your fix is well-tested by automated tests?
Validation Steps:
What are the steps to validate that this fix works?
I've manually tested the fix on iOS/iPadOS simulators. There are also unit tests.