[iPadOS 26] Clicking the top edge of the screen is recognized twice
Steps to reproduce
- Run any Flutter app on iPad (iOS 26). (I couldn't reproduce on iPhone)
- Change SystemUiMode to
immersiveby runningSystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);. - You might need to rotate the screen to apply
immersivemode due to this issue. #175520 - Click the top edge of the screen, and you can notice that the click is recognized twice.
Expected results
A single click should be recognized once.
Actual results
A single click is recognized twice.
https://github.com/user-attachments/assets/fb48a0b0-ce38-419f-8894-d466c1f955b2
Code sample
Code sample
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
debugShowCheckedModeBanner: false,
home: const MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key});
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _count = 0;
@override
void initState() {
super.initState();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: GestureDetector(
onTap: () {
setState(() {
_count++;
});
},
child: Container(
width: double.infinity,
height: double.infinity,
color: Colors.grey,
alignment: Alignment.topCenter,
child: Text(
'$_count',
style: const TextStyle(
fontSize: 40,
fontWeight: FontWeight.bold,
color: Colors.black,
),
),
),
),
),
);
}
}
Screenshots or Video
No response
Logs
No response
Flutter Doctor output
Doctor output
[✓] Flutter (Channel stable, 3.35.4, on macOS 26.0 25A354 darwin-arm64, locale en-KR) [243ms]
• Flutter version 3.35.4 on channel stable at /Users/seunghyun/fvm/versions/stable
• Upstream repository https://github.com/flutter/flutter.git
• Framework revision d693b4b9db (2 days ago), 2025-09-16 14:27:41 +0000
• Engine revision c298091351
• Dart version 3.9.2
• DevTools version 2.48.0
• Feature flags: enable-web, no-enable-linux-desktop, no-enable-macos-desktop, no-enable-windows-desktop,
enable-android, enable-ios, cli-animations, enable-lldb-debugging
[✓] Android toolchain - develop for Android devices (Android SDK version 35.0.0) [997ms]
• Android SDK at /Users/seunghyun/Library/Android/Sdk
• Emulator version 35.2.10.0 (build_id 12414864) (CL:N/A)
• Platform android-35, build-tools 35.0.0
• ANDROID_HOME = /Users/seunghyun/Library/Android/Sdk
• Java binary at: /Users/seunghyun/Applications/Android Studio.app/Contents/jbr/Contents/Home/bin/java
This is the JDK bundled with the latest Android Studio installation on this machine.
To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`.
• Java version OpenJDK Runtime Environment (build 21.0.6+-13368085-b895.109)
• All Android licenses accepted.
[✓] Xcode - develop for iOS and macOS (Xcode 26.0) [625ms]
• Xcode at /Applications/Xcode.app/Contents/Developer
• Build 17A324
• CocoaPods version 1.16.2
[✓] Chrome - develop for the web [18ms]
• Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome
[✓] Android Studio (version 2024.3) [18ms]
• Android Studio at /Users/seunghyun/Applications/Android Studio.app/Contents
• Flutter plugin can be installed from:
🔨 https://plugins.jetbrains.com/plugin/9212-flutter
• Dart plugin can be installed from:
🔨 https://plugins.jetbrains.com/plugin/6351-dart
• Java version OpenJDK Runtime Environment (build 21.0.6+-13368085-b895.109)
[✓] IntelliJ IDEA Ultimate Edition (version 2025.1.2) [17ms]
• IntelliJ at /Users/seunghyun/Applications/IntelliJ IDEA Ultimate.app
• Flutter plugin can be installed from:
🔨 https://plugins.jetbrains.com/plugin/9212-flutter
• Dart plugin can be installed from:
🔨 https://plugins.jetbrains.com/plugin/6351-dart
[✓] VS Code (version 1.101.1) [5ms]
• VS Code at /Applications/Visual Studio Code.app/Contents
• Flutter extension version 3.112.0
[✓] Connected device (5 available) [5.8s]
• Seunghyun’s iPad (wireless) (mobile) • 00008020-001B59413A83002E • ios • iOS 18.6.2
22G100
• iPhone 14 Pro (mobile) • 3794BC81-1DEA-4AF4-B5FA-3DA2FCF87C6F • ios •
com.apple.CoreSimulator.SimRuntime.iOS-26-0 (simulator)
• iPad Pro (11-inch) (4th generation) (mobile) • 36564CCB-3F65-4FBF-8956-495C518CA3DE • ios •
com.apple.CoreSimulator.SimRuntime.iOS-26-0 (simulator)
• iPad Pro (11-inch) (4th generation) (mobile) • 1E2A25BD-1080-47CF-82C6-831C4928805A • ios •
com.apple.CoreSimulator.SimRuntime.iOS-18-6 (simulator)
• Chrome (web) • chrome • web-javascript • Google Chrome
140.0.7339.185
[✓] Network resources [352ms]
• All expected network resources are available.
• No issues found!
Thanks for the report. Seeing the same behaviour with latest SDK versions on iPad with iPadOS 26.
stable : 3.35.4
master : 3.37.0-1.0.pre-198
https://github.com/user-attachments/assets/f0611764-4541-4e1f-b0ef-38a6365d1c06
Can I know when or in what version it will be fixed?
I found a workaround.
The second tap's position always seems to be (0, 0), so putting a 1x1 AbsorbPointer absorbs the second tap.
It's a bit annoying but it works.
https://github.com/user-attachments/assets/6912a6b0-c0bc-4ba2-a83c-d0413bb80f5e
Code sample
Code sample
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
debugShowCheckedModeBanner: false,
home: const MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key});
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _count = 0;
@override
void initState() {
super.initState();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Stack(
children: [
FilledButton(
onPressed: () {
setState(() {
print('Button pressed. count: $_count');
_count++;
});
},
child: Text('$_count'),
),
GestureDetector(
onTapDown: (details) {
print('Tap absorbed. position: ${details.globalPosition}');
},
child: AbsorbPointer(child: SizedBox(width: 1, height: 1)),
),
],
),
),
);
}
}
I suspect this may be related to the new iPadOS 26 feature where double tapping the top of an app will toggle between fullscreen & windowed mode.
This workaround seems to do the trick until it's fixed properly:
class FilteringFlutterBinding extends WidgetsFlutterBinding {
@override
void handlePointerEvent(PointerEvent event) {
if (event.position == Offset.zero) {
return;
}
super.handlePointerEvent(event);
}
}
void main() {
FilteringFlutterBinding();
runApp(MyApp());
}
This issue is getting worse because the double tapping area @Jon-Salmon mentioned seems bigger on iPadOS 26.1.
@Jon-Salmon's workaround is working for me. Thanks.
This is a breaking changes for all of the existing iPad apps already in store. Any app that has hamburger menu on top header are running into issue where a single tap is recognized as double tap and it opens and shuts the menu right away. Same thing with any dialogs or side sheets. Button clicks unless debounced are registered as double clicks and then the action happens twice. This is not an inconvenience. This is a major flaw that affects all the apps existing on the store for the customers upgrading to iPad OS 26.0 and gets worse in 26.1.
Edit: @Jon-Salmon's solution didn't work for me. However this worked:
// Top level global
bool _zeroOffsetPointerGuardInstalled = false;
void _installZeroOffsetPointerGuard() {
if (_zeroOffsetPointerGuardInstalled) return;
GestureBinding.instance.pointerRouter
.addGlobalRoute(_absorbZeroOffsetPointerEvent);
_zeroOffsetPointerGuardInstalled = true;
}
void _absorbZeroOffsetPointerEvent(PointerEvent event) {
if (event.position == Offset.zero) {
GestureBinding.instance.cancelPointer(event.pointer);
}
}
And on the main method:
void main(){
WidgetsFlutterBinding.ensureInitialized();
_installZeroOffsetPointerGuard();
runApp(const App());
}
The offending code is in: https://github.com/flutter/engine/blob/master_archived/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm
- (BOOL)scrollViewShouldScrollToTop:(UIScrollView*)scrollView {
if (!_engine) {
return NO;
}
CGPoint statusBarPoint = CGPointZero;
UIScreen* screen = [self flutterScreenIfViewLoaded];
if (screen) {
SendFakeTouchEvent(screen, _engine.get(), statusBarPoint, flutter::PointerData::Change::kDown);
SendFakeTouchEvent(screen, _engine.get(), statusBarPoint, flutter::PointerData::Change::kUp);
}
return NO;
}
It looks like iPadOS 26.1 is triggering scrollViewShouldScrollToTop further down into the navigation bar.
This issue even affects the latest version of Chrome on iPadOS 26.1 according to this reddit post.
@Jon-Salmon's workaround is good but stops the legitimate scroll to top behavior when tapping the actual status bar. I've been playing around with a variation that tries to distinguish these fake taps and ignore them. It might be useful for someone:
class FilteringFlutterBinding extends WidgetsFlutterBinding {
// Track timestamp of last real (non-zero) pointer up event
DateTime? _lastRealPointerUpTime;
static const int _fakeTapThreshold = 400;
@override
void handlePointerEvent(PointerEvent event) {
// Track when real taps (non-zero position) complete
if (event is PointerUpEvent && event.position != Offset.zero) {
_lastRealPointerUpTime = DateTime.now();
}
// Filter fake taps: Offset.zero down events that follow shortly after a real tap
if (event.position == Offset.zero && event is PointerDownEvent) {
if (_lastRealPointerUpTime != null) {
final timeSinceRealTap = DateTime.now().difference(_lastRealPointerUpTime!).inMilliseconds;
if (timeSinceRealTap < _fakeTapThreshold) {
return;
}
}
}
// Also block the matching PointerUpEvent for a blocked PointerDownEvent
if (event.position == Offset.zero && event is PointerUpEvent) {
if (_lastRealPointerUpTime != null) {
final timeSinceRealTap = DateTime.now().difference(_lastRealPointerUpTime!).inMilliseconds;
if (timeSinceRealTap < _fakeTapThreshold) {
return;
}
}
}
super.handlePointerEvent(event);
}
}
Cross-listing https://github.com/flutter/flutter/issues/35050, let's make sure that isn't regressed with this fix.
I am affected by this too, should we workaround or wait for Apple to fix this? any thoughts or ideas?
Since this seems to have the same root cause as https://github.com/flutter/flutter/issues/177992, I'll post my updates in the other issue (Although I did not try to reproduce this issue on master, it's a bit strange that UIKit actually dispatches the touch events since I would expect it be consumed by the scroll view).
I chose the workaround suggested by @Jon-Salmon here (https://github.com/flutter/flutter/issues/175606#issuecomment-3453392532) It worked for me!
I can confirm that my production app is also affected as it has a leading hamburger PopUpMenuButton in its appBar. The solution proposed @ssbigyan here seems to work for me for now (https://github.com/flutter/flutter/issues/175606#issuecomment-3576240885).