[ios] Fix safe area padding not updating when SystemUiMode changes on iOS 26.0 and iOS 26.0.1#186028
[ios] Fix safe area padding not updating when SystemUiMode changes on iOS 26.0 and iOS 26.0.1#186028Akhrameev wants to merge 5 commits into
Conversation
|
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. |
There was a problem hiding this comment.
Code Review
This pull request addresses an issue where safe area insets remain stale on non-notch iOS devices after the status bar is hidden. Targeting newer iOS versions, the implementation captures the status bar height before hiding, subtracts it from the top padding in viewport metrics, and schedules a layout pass to ensure the UI updates correctly. It also includes regression tests for both notch and non-notch scenarios. Review feedback identifies a redundant ivar declaration that is already synthesized by an existing property.
4f05b24 to
df5b886
Compare
|
I was unable to test the fix in true iPad Split View (side-by-side multitasking) without a physical iPad. In Split View, UIKit may ignore Could a reviewer with a physical iPad verify whether this case needs an additional guard? (or it's better to add it in advance without checks on my side?) |
|
Note: Mac mac_unopt |
|
@Akhrameev Can you provide a screenshot/video in the pr description for future reference? |
Just to be clear: do you want a video of original issue (it is already inside linked issue description) or a video or current (fixed) behavior? I can do both (even iPad Split View current behavior) but better have a list of states to cover to make you satisfied. |
|
Here is flutter code I used to launch: import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
void main() {
runApp(const MyApp());
}
// ── Device catalogue ─────────────────────────────────────────────────────────
enum _Category { dynamicIsland, notch, nonNotch, unknown }
class _DeviceInfo {
const _DeviceInfo(this.name, this.category);
final String name;
final _Category category;
}
// Add new devices here. modelIdentifier → (display name, category).
// Obtain the identifier via:
// xcrun simctl getenv <UDID> SIMULATOR_MODEL_IDENTIFIER
const _knownDevices = <String, _DeviceInfo>{
// ── Dynamic Island ────────────────────────────────────────────────────────
'iPhone15,2': _DeviceInfo('iPhone 14 Pro', _Category.dynamicIsland),
'iPhone15,3': _DeviceInfo('iPhone 14 Pro Max', _Category.dynamicIsland),
'iPhone16,1': _DeviceInfo('iPhone 15 Pro', _Category.dynamicIsland),
'iPhone16,2': _DeviceInfo('iPhone 15 Pro Max', _Category.dynamicIsland),
'iPhone17,1': _DeviceInfo('iPhone 16 Pro', _Category.dynamicIsland),
'iPhone17,2': _DeviceInfo('iPhone 16 Pro Max', _Category.dynamicIsland),
'iPhone17,3': _DeviceInfo('iPhone 16', _Category.dynamicIsland),
'iPhone17,4': _DeviceInfo('iPhone 16 Plus', _Category.dynamicIsland),
'iPhone18,1': _DeviceInfo('iPhone 17 Pro', _Category.dynamicIsland),
'iPhone18,2': _DeviceInfo('iPhone 17 Pro Max', _Category.dynamicIsland),
'iPhone18,3': _DeviceInfo('iPhone 17', _Category.dynamicIsland),
'iPhone18,4': _DeviceInfo('iPhone 17 Plus', _Category.dynamicIsland),
// ── Notch ─────────────────────────────────────────────────────────────────
'iPhone10,3': _DeviceInfo('iPhone X', _Category.notch),
'iPhone10,6': _DeviceInfo('iPhone X', _Category.notch),
'iPhone11,2': _DeviceInfo('iPhone XS', _Category.notch),
'iPhone11,4': _DeviceInfo('iPhone XS Max', _Category.notch),
'iPhone11,6': _DeviceInfo('iPhone XS Max', _Category.notch),
'iPhone11,8': _DeviceInfo('iPhone XR', _Category.notch),
'iPhone12,1': _DeviceInfo('iPhone 11', _Category.notch),
'iPhone12,3': _DeviceInfo('iPhone 11 Pro', _Category.notch),
'iPhone12,5': _DeviceInfo('iPhone 11 Pro Max', _Category.notch),
'iPhone12,8': _DeviceInfo('iPhone SE (2nd gen)', _Category.notch),
'iPhone13,1': _DeviceInfo('iPhone 12 mini', _Category.notch),
'iPhone13,2': _DeviceInfo('iPhone 12', _Category.notch),
'iPhone13,3': _DeviceInfo('iPhone 12 Pro', _Category.notch),
'iPhone13,4': _DeviceInfo('iPhone 12 Pro Max', _Category.notch),
'iPhone14,2': _DeviceInfo('iPhone 13 Pro', _Category.notch),
'iPhone14,3': _DeviceInfo('iPhone 13 Pro Max', _Category.notch),
'iPhone14,4': _DeviceInfo('iPhone 13 mini', _Category.notch),
'iPhone14,5': _DeviceInfo('iPhone 13', _Category.notch),
'iPhone14,6': _DeviceInfo('iPhone SE (3rd gen)', _Category.notch),
'iPhone14,7': _DeviceInfo('iPhone 14', _Category.notch),
'iPhone14,8': _DeviceInfo('iPhone 14 Plus', _Category.notch),
'iPhone15,4': _DeviceInfo('iPhone 15', _Category.notch),
'iPhone15,5': _DeviceInfo('iPhone 15 Plus', _Category.notch),
'iPhone17,5': _DeviceInfo('iPhone 16e', _Category.notch),
// ── Non-notch (iPad Face ID, home-button-less) ─────────────────────────────
'iPad13,4': _DeviceInfo('iPad Pro 11-inch (3rd gen)', _Category.nonNotch),
'iPad13,5': _DeviceInfo('iPad Pro 11-inch (3rd gen)', _Category.nonNotch),
'iPad13,6': _DeviceInfo('iPad Pro 11-inch (3rd gen)', _Category.nonNotch),
'iPad13,7': _DeviceInfo('iPad Pro 11-inch (3rd gen)', _Category.nonNotch),
'iPad14,3': _DeviceInfo('iPad Pro 11-inch (M2)', _Category.nonNotch),
'iPad14,4': _DeviceInfo('iPad Pro 11-inch (M2)', _Category.nonNotch),
'iPad16,3': _DeviceInfo('iPad Pro 11-inch (M4)', _Category.nonNotch),
'iPad16,4': _DeviceInfo('iPad Pro 11-inch (M4)', _Category.nonNotch),
'iPad16,5': _DeviceInfo('iPad Pro 13-inch (M4)', _Category.nonNotch),
'iPad16,6': _DeviceInfo('iPad Pro 13-inch (M4)', _Category.nonNotch),
};
String _categoryLabel(_Category c) => switch (c) {
_Category.dynamicIsland => 'Hardware cutout (Dynamic Island)',
_Category.notch => 'Hardware cutout (Notch)',
_Category.nonNotch => 'Non-notch',
_Category.unknown => 'Unknown',
};
String _expectedBehavior(_Category c) => switch (c) {
_Category.dynamicIsland =>
'Top padding stays at Dynamic Island clearance height\n'
'(hardware cutout always requires clearance)',
_Category.notch =>
'Top padding stays at notch clearance height\n'
'(hardware cutout always requires clearance)',
_Category.nonNotch =>
'Top padding drops to 0 in immersive mode\n'
'(no hardware cutout, only status bar required clearance)',
_Category.unknown =>
'Add this device to one of the three lists\n'
'in main.dart to see adjusted expectations',
};
// ─────────────────────────────────────────────────────────────────────────────
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Status Bar Demo',
debugShowCheckedModeBanner: false,
home: const MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key});
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
bool _isImmersive = false;
Timer? _autoTimer;
late final String _modelId;
late final _DeviceInfo _deviceInfo;
@override
void initState() {
super.initState();
_modelId = const String.fromEnvironment('MODEL_ID');
_deviceInfo = _knownDevices[_modelId] ??
const _DeviceInfo('Unknown', _Category.unknown);
_autoTimer = Timer.periodic(const Duration(seconds: 2), (_) => _toggle());
}
@override
void dispose() {
_autoTimer?.cancel();
super.dispose();
}
void _toggle() {
if (_isImmersive) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
} else {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
}
setState(() => _isImmersive = !_isImmersive);
}
@override
Widget build(BuildContext context) {
final padding = MediaQuery.of(context).padding;
final osVersion = Platform.operatingSystemVersion;
final category = _categoryLabel(_deviceInfo.category);
final expected = _expectedBehavior(_deviceInfo.category);
return Scaffold(
body: SafeArea(
child: Container(
width: double.infinity,
height: double.infinity,
color: Colors.grey[300],
child: Column(
spacing: 8,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Immersive mode: $_isImmersive',
style: const TextStyle(fontSize: 18),
),
Text(
'Safe area padding top: ${padding.top}',
style: const TextStyle(fontSize: 18),
),
Text(
'Safe area padding bottom: ${padding.bottom}',
style: const TextStyle(fontSize: 18),
),
FilledButton(onPressed: _toggle, child: const Text('Toggle')),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(
'Device: ${_deviceInfo.name} ($_modelId)',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 13, color: Colors.black54),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(
'iOS $osVersion',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 13, color: Colors.black54),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(
'Category: $category',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 13, color: Colors.black54),
),
),
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(
'Expected: $expected',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 13,
color: Colors.black87,
fontStyle: FontStyle.italic,
),
),
),
],
),
),
),
);
}
}
|
|
iOS 18.6, match earlier behavior of stable flutter (recorded using fixed flutter in branch - almost no code changes here because of All three device categories behave as expected on iOS 18:
iphone16pro_ios18_status_bar_safe_area_fix.mp4iphone16e_ios18_status_bar_safe_area_fix.mp4ipadpro11m4_ios18_status_bar_safe_area_fix.mp4 |
|
iOS 26.0 - fixed behavior (fixes non-notch iPad, keeping other devices still correct) Same correct behavior is now reproduced on iOS 26:
iphone17pro_ios26_status_bar_safe_area_fix.mp4iphone16e_ios26_status_bar_safe_area_fix.mp4ipadpro11m4_ios26_status_bar_safe_area_fix.mp4 |
|
@okorohelijah is that what you asked for? |
|
Is there anything I can help to move the fix forward? "Update branch"? |
|
An existing Git SHA, To re-trigger presubmits after closing or re-opeing a PR, or pushing a HEAD commit (i.e. with |
3f6e51b to
5a4921c
Compare
|
rebased on top of |
LouiseHsu
left a comment
There was a problem hiding this comment.
this looks really good! i just have some minor nits/questions
| // are <=32pt. 40pt sits safely in the gap between them. | ||
| // See: https://github.com/flutter/flutter/issues/175520 | ||
| constexpr CGFloat kNotchStatusBarThreshold = 40.0; | ||
| if (self.flutterPrefersStatusBarHidden && _statusBarHeightBeforeHiding > 0 && |
There was a problem hiding this comment.
you noted in one of the comments that youre not sure about iPad Split View, and UIKit might ignore prefersStatusBarHidden. What do you think about checking statusbarHidden first?https://developer.apple.com/documentation/uikit/uistatusbarmanager/isstatusbarhidden?language=objc
| [self setNeedsStatusBarAppearanceUpdate]; | ||
| if (@available(iOS 26.0, *)) { | ||
| // On iOS 26+, setNeedsStatusBarAppearanceUpdate no longer triggers | ||
| // viewSafeAreaInsetsDidChange. Schedule a layout pass so |
There was a problem hiding this comment.
nit:
// Schedule a layout pass so that viewDidLayoutSubviews invokes
// setViewportMetricsPaddings, which will then read the updated
// flutterPrefersStatusBarHidden state.
| // updated flutterPrefersStatusBarHidden state. | ||
| // See: https://github.com/flutter/flutter/issues/175520 | ||
| dispatch_async(dispatch_get_main_queue(), ^{ | ||
| [self.view setNeedsLayout]; |
There was a problem hiding this comment.
can this be invoked before the view is loaded? maybe
[self.viewIfLoaded setNeedsLayout];
There was a problem hiding this comment.
nice catch! I applied it twice and covered with a unit test (only sync way is tested - this dispatched flow change is not covered)
5a4921c to
d3f957b
Compare
8b65684 to
639699d
Compare
|
I saw a fluttergithubbot comment and 151 commits in master branch. I've locally rebased on top of them - but did not yet publish updated version. So now it has all current master changes. |
|
@LouiseHsu please re-add CICD tag (if needed) |
Mac mac_unoptFailing after 49m — Mac mac_unopt is not related to my code changes |
| // All known notch/Dynamic Island bars are >=44pt; non-notch (iPad, iPhone SE) | ||
| // are <=32pt. 40pt sits safely in the gap between them. | ||
| // See: https://github.com/flutter/flutter/issues/175520 | ||
| constexpr CGFloat kNotchStatusBarThreshold = 40.0; |
There was a problem hiding this comment.
nit: this should be at the top of the file
There was a problem hiding this comment.
commented below: fix is ready to push
| // causes UIKit to process the change. On iOS 26+, statusBarFrame.size.height | ||
| // returns 0 once the bar is hidden, so this is the only reliable moment. | ||
| UIWindowScene* scene = (UIWindowScene*)self.viewIfLoaded.window.windowScene; | ||
| _statusBarHeightBeforeHiding = scene.statusBarManager.statusBarFrame.size.height; |
There was a problem hiding this comment.
nit: use self.statusBarHeightBeforeHiding
There was a problem hiding this comment.
commented below: fix is ready to push
|
Retested locally after rebasing on current
Ready to push these nit fixes to the branch whenever needed. Regarding the "Smoke tests failed" entry - it appeared before these nit fixes were pushed (as these cahnges are not yet pushed at all), and the failure description says "Internal smoke tests failed. Please contact a Google developer to help investigate". Nothing to do from my side, right? @LouiseHsu Should I push updated branch with fixes and ask for CICD tag being set again? |
|
@Akhrameev Yes could you please push then ping me or louise and we will add CI/CD label. Thanks! |
639699d to
522b7db
Compare
|
@hellohuanlin @LouiseHsu pushed it. Now your turn to add |
|
60 commits in diff (exist on master branch, missing here in feature branch). Still locally-rebased branch checked: @hellohuanlin @LouiseHsu please notify me if I can be helpful to move this MR forward |
|
from triage: cc @hellohuanlin |


On iOS 26, calling
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive)nolonger updates
MediaQuery.padding.topso the safe-area top padding stays at thestatus-bar height even after the bar is hidden, so
SafeArealeaves an unnecessarygap at the top of the screen. The only previous workaround was to trigger a
rotation or resize.
Fixes #175520
Root cause
iOS 26 changed UIKit behavior: hiding the status bar via
prefersStatusBarHiddenno longer triggers
viewSafeAreaInsetsDidChange, andview.safeAreaInsets.topisnot reduced after the bar disappears. On earlier iOS versions UIKit updated the inset
synchronously; on iOS 26 the value is left stale.
Fix
setPrefersStatusBarHidden:capturestatusBarManager.statusBarFrame.size.heightimmediately before setting
prefersStatusBarHidden = YES, while the frame is stillvalid (it returns
CGRectZeroonce the bar is gone). Then defer one run-loop viadispatch_asyncso UIKit has committed the visibility change beforesetViewportMetricsPaddingsre-readssafeAreaInsets.setViewportMetricsPaddingswhen the bar is hidden on iOS 26+, subtract thecaptured height from
safeAreaInsets.topto recover the true usable-area inset.The correction is guarded by a 40 pt threshold that keeps it off notch/Dynamic Island
devices:
The entire new code path is inside
if (@available(iOS 26.0, *))so zero behaviorchange on iOS 25 and below.
Tests
Two new ObjC unit tests in
FlutterViewControllerTest:testSetViewportMetricsPaddings_subtractsStatusBarHeightOnNonNotchDevicephysical_padding_topcorrected to 0testSetViewportMetricsPaddings_preservesStatusBarPaddingOnNotchDeviceManual end-to-end verification on iOS 26.0 simulators:
Pre-launch Checklist
///).