Skip to content

Commit 392bb79

Browse files
authored
[ios][platform_view]Reland hitTest approach (with a few 2026 update) (#183484)
This PR tries to re-land [the original PR](#179659), which was reverted since we found a quick fix. However, hitTest is still important so we should land it. See ([go/pv-touch-gesture-blocking-changes-2026 (internal golink)](http://goto.google.com/pv-touch-gesture-blocking-changes-2026)), or the [public design doc](https://goto.google.com/ios-platform-view-touch-gesture-blocking). For ease of review, I have split this PR into 2 commits: the 1st commit is the [originally landed/reverted PR from last year](#179659); the 2nd commit is the changes in 2026 that we'd like to make. The changes are: 1. Enable hitTest directly (regardless of policies) 2. Rename `hitTestOnly` to `doNotBlockGesture` policy If you have reviewed the original PR, feel free to only review the 2nd commit. Next up: I will work on an integration test for [drawing website fall-through issue](#179916), which will be fixed by this PR. The integration test likely be a 3rd commit in this PR, or a separate PR afterwards (depending on PR review timing) *List which issues are fixed by this PR. You must list at least one issue. An issue is not required if the PR fixes something trivial like a typo.* Fixes #179916 Fixes #175099 *If you had to change anything in the [flutter/tests] repo, include a link to the migration guide as per the [breaking change policy].* ## 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. 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 [AI contribution guidelines]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#ai-contribution-guidelines [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
1 parent 745bd2c commit 392bb79

50 files changed

Lines changed: 1512 additions & 156 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,38 @@ - (void)testPlatformViewWebViewLinkTappableForContextMenuScenario {
123123
XCTAssertTrue([successText waitForExistenceWithTimeout:60]);
124124
}
125125

126+
127+
- (void)testPlatformViewDrawingWebViewCannotBeDrawnWhenContextMenuIsShown {
128+
XCUIElement *entranceButton = self.app.buttons[@"drawing web view behind context menu test"];
129+
XCTAssertTrue([entranceButton waitForExistenceWithTimeout:kStandardTimeOut]);
130+
[entranceButton tap];
131+
132+
XCUIElement *platformView = self.app.webViews[@"platform_view[0]"];
133+
XCTAssertTrue([platformView waitForExistenceWithTimeout:kStandardTimeOut]);
134+
135+
// expand the context menu.
136+
XCUIElement *showMenuButton = self.app.buttons[@"Show menu"];
137+
XCTAssertTrue([showMenuButton waitForExistenceWithTimeout:kStandardTimeOut]);
138+
[showMenuButton tap];
139+
XCUIElement *menuItem = self.app.buttons[@"menu button 1"];
140+
XCTAssertTrue([menuItem waitForExistenceWithTimeout:kStandardTimeOut]);
141+
142+
// draw on the canvas
143+
XCUICoordinate *center = [self.app coordinateWithNormalizedOffset:CGVectorMake(0.5, 0.5)];
144+
XCUICoordinate *end = [center coordinateWithOffset:CGVectorMake(0.0, 100.0)];
145+
[center pressForDuration:0.1 thenDragToCoordinate:end];
146+
147+
// Dismiss the context menu (so that we can find web view under the accessibility tree).
148+
// Since context menu is on top right, we tap on the bottom left corner to avoid tapping within the menu area.
149+
XCUICoordinate *bottomLeft = [self.app coordinateWithNormalizedOffset:CGVectorMake(0.05, 0.95)];
150+
[bottomLeft tap];
151+
XCTAssertTrue([menuItem waitForNonExistenceWithTimeout:kStandardTimeOut]);
152+
153+
// Verify that the web view does not have any pixels drawn
154+
XCUIElement *pixelCountLabel = self.app.staticTexts[@"pixel count: 0"];
155+
XCTAssertTrue([pixelCountLabel waitForExistenceWithTimeout:kStandardTimeOut]);
156+
}
157+
126158
- (void)testPlatformViewFakeAdMobBannerTappableForScrollableListScenario {
127159
XCUIElement *entranceButton = self.app.buttons[@"admob banner in scrollable list test"];
128160
XCTAssertTrue([entranceButton waitForExistenceWithTimeout:kStandardTimeOut]);

dev/integration_tests/ios_platform_view_tests/ios/Runner.xcodeproj/project.pbxproj

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
E09898DE2853DBE800064317 /* ViewFactory.m in Sources */ = {isa = PBXBuildFile; fileRef = E09898DD2853DBE800064317 /* ViewFactory.m */; };
2222
E09898E12853DC3C00064317 /* TextFieldFactory.m in Sources */ = {isa = PBXBuildFile; fileRef = E09898E02853DC3C00064317 /* TextFieldFactory.m */; };
2323
E09898E92853E9F000064317 /* PlatformViewUITests.m in Sources */ = {isa = PBXBuildFile; fileRef = E09898E82853E9F000064317 /* PlatformViewUITests.m */; };
24+
E0DC4BCE2F69D8FA00CBE9CF /* drawing_website.html in Resources */ = {isa = PBXBuildFile; fileRef = E0DC4BCD2F69D8FA00CBE9CF /* drawing_website.html */; };
25+
E0DC4BD12F69D91800CBE9CF /* DrawingWebViewFactory.m in Sources */ = {isa = PBXBuildFile; fileRef = E0DC4BD02F69D91800CBE9CF /* DrawingWebViewFactory.m */; };
2426
/* End PBXBuildFile section */
2527

2628
/* Begin PBXContainerItemProxy section */
@@ -75,6 +77,9 @@
7577
E09898E02853DC3C00064317 /* TextFieldFactory.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = TextFieldFactory.m; sourceTree = "<group>"; };
7678
E09898E62853E9F000064317 /* PlatformViewUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PlatformViewUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
7779
E09898E82853E9F000064317 /* PlatformViewUITests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = PlatformViewUITests.m; sourceTree = "<group>"; };
80+
E0DC4BCD2F69D8FA00CBE9CF /* drawing_website.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = drawing_website.html; sourceTree = "<group>"; };
81+
E0DC4BCF2F69D90D00CBE9CF /* DrawingWebViewFactory.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = DrawingWebViewFactory.h; sourceTree = "<group>"; };
82+
E0DC4BD02F69D91800CBE9CF /* DrawingWebViewFactory.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DrawingWebViewFactory.m; sourceTree = "<group>"; };
7883
/* End PBXFileReference section */
7984

8085
/* Begin PBXFrameworksBuildPhase section */
@@ -138,10 +143,13 @@
138143
E09898DD2853DBE800064317 /* ViewFactory.m */,
139144
E090076F2F3A6FE400B349AE /* WebViewFactory.h */,
140145
E09007702F3A6FE400B349AE /* WebViewFactory.m */,
146+
E0DC4BCF2F69D90D00CBE9CF /* DrawingWebViewFactory.h */,
147+
E0DC4BD02F69D91800CBE9CF /* DrawingWebViewFactory.m */,
141148
E090082E2F47BE8500B349AE /* LinkNavigationWebView.h */,
142149
E090082F2F47BE8500B349AE /* LinkNavigationWebView.m */,
143150
E09008282F47BAFB00B349AE /* FakeAdMobBannerFactory.h */,
144151
E09008292F47BAFB00B349AE /* FakeAdMobBannerFactory.m */,
152+
E0DC4BCD2F69D8FA00CBE9CF /* drawing_website.html */,
145153
97C146FA1CF9000F007C117D /* Main.storyboard */,
146154
97C146FD1CF9000F007C117D /* Assets.xcassets */,
147155
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
@@ -252,6 +260,7 @@
252260
isa = PBXResourcesBuildPhase;
253261
buildActionMask = 2147483647;
254262
files = (
263+
E0DC4BCE2F69D8FA00CBE9CF /* drawing_website.html in Resources */,
255264
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
256265
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
257266
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
@@ -314,6 +323,7 @@
314323
E090082A2F47BAFB00B349AE /* FakeAdMobBannerFactory.m in Sources */,
315324
E0440002299C782300D02513 /* ButtonFactory.m in Sources */,
316325
E09898E12853DC3C00064317 /* TextFieldFactory.m in Sources */,
326+
E0DC4BD12F69D91800CBE9CF /* DrawingWebViewFactory.m in Sources */,
317327
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
318328
E09898DE2853DBE800064317 /* ViewFactory.m in Sources */,
319329
);

dev/integration_tests/ios_platform_view_tests/ios/Runner/AppDelegate.m

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
#import "TextFieldFactory.h"
99
#import "ButtonFactory.h"
1010
#import "WebViewFactory.h"
11+
#import "DrawingWebViewFactory.h"
1112
#import "FakeAdMobBannerFactory.h"
1213

1314
@implementation AppDelegate
@@ -21,6 +22,7 @@ - (BOOL)application:(UIApplication *)application
2122
[registrar registerViewFactory:[[TextFieldFactory alloc] init] withId:@"platform_text_field"];
2223
[registrar registerViewFactory:[[ButtonFactory alloc] init] withId:@"platform_button"];
2324
[registrar registerViewFactory:[[WebViewFactory alloc] init] withId:@"platform_web_view"];
25+
[registrar registerViewFactory:[[DrawingWebViewFactory alloc] init] withId:@"platform_drawing_web_view"];
2426
[registrar registerViewFactory:[[FakeAdMobBannerFactory alloc] init] withId:@"platform_fake_admob_banner"];
2527
return [super application:application didFinishLaunchingWithOptions:launchOptions];
2628
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
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 <Flutter/Flutter.h>
6+
#import <UIKit/UIKit.h>
7+
8+
NS_ASSUME_NONNULL_BEGIN
9+
10+
@interface DrawingWebViewFactory: NSObject<FlutterPlatformViewFactory>
11+
12+
@end
13+
14+
NS_ASSUME_NONNULL_END
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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 "DrawingWebViewFactory.h"
6+
#import <WebKit/WebKit.h>
7+
8+
@interface DrawingWebViewPlatformView: NSObject<FlutterPlatformView>
9+
@property (strong, nonatomic) WKWebView *platformView;
10+
@end
11+
12+
@implementation DrawingWebViewPlatformView
13+
14+
- (instancetype)init
15+
{
16+
self = [super init];
17+
if (self) {
18+
_platformView = [[WKWebView alloc] init];
19+
NSString *htmlPath = [[NSBundle mainBundle] pathForResource:@"drawing_website" ofType:@"html"];
20+
NSURL *htmlURL = [NSURL fileURLWithPath:htmlPath];
21+
[self.platformView loadFileURL:htmlURL allowingReadAccessToURL:[htmlURL URLByDeletingLastPathComponent]];
22+
}
23+
return self;
24+
}
25+
26+
- (UIView *)view {
27+
return self.platformView;
28+
}
29+
30+
@end
31+
32+
@implementation DrawingWebViewFactory
33+
34+
- (NSObject<FlutterPlatformView> *)createWithFrame:(CGRect)frame viewIdentifier:(int64_t)viewId arguments:(id)args {
35+
return [[DrawingWebViewPlatformView alloc] init];
36+
}
37+
38+
@end
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<!DOCTYPE HTML>
2+
<!-- Copyright 2014 The Flutter Authors. All rights reserved.
3+
Use of this source code is governed by a BSD-style license that can be
4+
found in the LICENSE file. -->
5+
<html>
6+
<head>
7+
<meta name='viewport' content='width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no'>
8+
<style>
9+
body { margin: 0; overflow: hidden; background-color: white; touch-action: none; }
10+
canvas { display: block; width: 100vw; height: 100vh; }
11+
#label { position: absolute; top: 10px; left: 10px; font-size: 20px; font-family: sans-serif; pointer-events: none; }
12+
</style>
13+
</head>
14+
<body>
15+
<div id='label'>pixel count: 0</div>
16+
<canvas id='canvas'></canvas>
17+
<script>
18+
const canvas = document.getElementById('canvas');
19+
const ctx = canvas.getContext('2d');
20+
const label = document.getElementById('label');
21+
let pixelCount = 0;
22+
function resize() {
23+
canvas.width = window.innerWidth;
24+
canvas.height = window.innerHeight;
25+
}
26+
window.addEventListener('resize', resize);
27+
resize();
28+
function drawPixel(x, y) {
29+
ctx.fillStyle = 'black';
30+
ctx.fillRect(x, y, 2, 2);
31+
pixelCount++;
32+
label.innerText = 'pixel count: ' + pixelCount;
33+
}
34+
function handleTouch(e) {
35+
e.preventDefault();
36+
for (let i = 0; i < e.changedTouches.length; i++) {
37+
const touch = e.changedTouches[i];
38+
drawPixel(touch.clientX, touch.clientY);
39+
}
40+
}
41+
canvas.addEventListener('touchstart', handleTouch, {passive: false});
42+
canvas.addEventListener('touchmove', handleTouch, {passive: false});
43+
</script>
44+
</body>
45+
</html>

dev/integration_tests/ios_platform_view_tests/lib/main.dart

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,19 @@ class _MyHomePageState extends State<MyHomePage> {
102102
);
103103
},
104104
),
105+
TextButton(
106+
key: const ValueKey<String>('drawing_web_view_behind_context_menu_test'),
107+
child: const Text('drawing web view behind context menu test'),
108+
onPressed: () {
109+
Navigator.push(
110+
context,
111+
MaterialPageRoute<DrawingWebViewBehindContextMenuTestPage>(
112+
builder: (BuildContext context) =>
113+
const DrawingWebViewBehindContextMenuTestPage(),
114+
),
115+
);
116+
},
117+
),
105118
TextButton(
106119
key: const ValueKey<String>('admob_banner_in_scrollable_list_test'),
107120
child: const Text('admob banner in scrollable list test'),
@@ -282,6 +295,52 @@ class _WebViewBehindContextMenuTestPageState extends State<WebViewBehindContextM
282295
}
283296
}
284297

298+
/// A page to test a web view with drawing website cannot be drawn when a context menu is shown.
299+
/// See [this issue](https://github.com/flutter/flutter/issues/179916).
300+
class DrawingWebViewBehindContextMenuTestPage extends StatefulWidget {
301+
const DrawingWebViewBehindContextMenuTestPage({super.key});
302+
303+
@override
304+
State<DrawingWebViewBehindContextMenuTestPage> createState() =>
305+
_DrawingWebViewBehindContextMenuTestPageState();
306+
}
307+
308+
class _DrawingWebViewBehindContextMenuTestPageState
309+
extends State<DrawingWebViewBehindContextMenuTestPage> {
310+
@override
311+
Widget build(BuildContext context) {
312+
return Scaffold(
313+
appBar: AppBar(
314+
title: const Text('Drawing web view behind context menu test'),
315+
actions: <Widget>[
316+
PopupMenuButton<String>(
317+
icon: const Icon(Icons.more_vert),
318+
onSelected: (String value) {},
319+
itemBuilder: (BuildContext context) {
320+
return <PopupMenuEntry<String>>[
321+
const PopupMenuItem<String>(value: 'button 1', child: Text('menu button 1')),
322+
const PopupMenuItem<String>(value: 'button 2', child: Text('menu button 2')),
323+
const PopupMenuItem<String>(value: 'button 3', child: Text('menu button 3')),
324+
const PopupMenuItem<String>(value: 'button 4', child: Text('menu button 4')),
325+
const PopupMenuItem<String>(value: 'button 5', child: Text('menu button 5')),
326+
];
327+
},
328+
),
329+
],
330+
),
331+
body: const Center(
332+
child: SizedBox.square(
333+
dimension: 500.0,
334+
child: UiKitView(
335+
viewType: 'platform_drawing_web_view',
336+
creationParamsCodec: StandardMessageCodec(),
337+
),
338+
),
339+
),
340+
);
341+
}
342+
}
343+
285344
/// A page to test AdMob banners are still tappable in a scrollable list.
286345
/// See [this issue](https://github.com/flutter/flutter/issues/165787).
287346
class AdMobBannerInScrollableListTestPage extends StatefulWidget {

engine/src/flutter/lib/ui/BUILD.gn

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ source_set("ui") {
135135
"text/paragraph_builder.h",
136136
"ui_dart_state.cc",
137137
"ui_dart_state.h",
138+
"window/hit_test_response.h",
138139
"window/key_data.cc",
139140
"window/key_data.h",
140141
"window/key_data_packet.cc",
@@ -151,6 +152,7 @@ source_set("ui") {
151152
"window/platform_message_response_dart.h",
152153
"window/platform_message_response_dart_port.cc",
153154
"window/platform_message_response_dart_port.h",
155+
"window/point_data.h",
154156
"window/pointer_data.cc",
155157
"window/pointer_data.h",
156158
"window/pointer_data_packet.cc",

engine/src/flutter/lib/ui/hooks.dart

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,24 @@ void _dispatchPointerDataPacket(ByteData packet) {
374374
PlatformDispatcher.instance._dispatchPointerDataPacket(packet);
375375
}
376376

377+
@pragma('vm:entry-point')
378+
class _HitTestResponse {
379+
_HitTestResponse({required this.isPlatformView});
380+
381+
@pragma('vm:entry-point')
382+
final bool isPlatformView;
383+
}
384+
385+
@pragma('vm:entry-point')
386+
_HitTestResponse _hitTest(int viewId, double x, double y) {
387+
assert(PlatformDispatcher.instance._views.containsKey(viewId), 'View $viewId does not exist.');
388+
final FlutterView view = PlatformDispatcher.instance._views[viewId]!;
389+
final offset = Offset(x, y);
390+
final request = HitTestRequest(view: view, offset: offset);
391+
final HitTestResponse response = PlatformDispatcher.instance._hitTest(request);
392+
return _HitTestResponse(isPlatformView: response.isPlatformView);
393+
}
394+
377395
@pragma('vm:entry-point')
378396
void _dispatchSemanticsAction(int viewId, int nodeId, int action, ByteData? args) {
379397
PlatformDispatcher.instance._dispatchSemanticsAction(viewId, nodeId, action, args);
@@ -484,6 +502,30 @@ void _invoke3<A1, A2, A3>(
484502
}
485503
}
486504

505+
/// Invokes [callback] inside the given [zone] passing it [arg1],
506+
/// and returns a nullable result of the specified type.
507+
///
508+
/// The 1 in the name refers to the number of arguments expected by
509+
/// the callback (and thus passed to this function, in addition to the
510+
/// callback itself and the zone in which the callback is executed).
511+
R? _invoke1WithReturn<A1, R>(R Function(A1 a1)? callback, Zone zone, A1 arg1) {
512+
if (callback == null) {
513+
return null;
514+
}
515+
if (identical(zone, Zone.current)) {
516+
return callback(arg1);
517+
} else {
518+
return runZonedGuarded(
519+
() {
520+
return callback(arg1);
521+
},
522+
(e, s) {
523+
zone.handleUncaughtError(e, s);
524+
},
525+
);
526+
}
527+
}
528+
487529
bool _isLoopback(String host) {
488530
if (host.isEmpty) {
489531
return false;

0 commit comments

Comments
 (0)