[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 3 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
|
ping me if I shall push rebased branch again (I've checked it: not merge conflicts, no tests get failed after rebase) - I am here to provide it when necessary |
|
@vashworth is there anything I can do to move this MR forward? (I can rebase if needed - all local tests pass I've checked it recently) |
|
@LouiseHsu @vashworth I see a green checkmark near "tree-status". Is it a great time to move my task forward? (rebased version was already checked by me and it passes all tests - ready to push if it is helpful). |
|
just keeping you know that I am here and still checking a rebased version locally: it passes all tests and ready to push |
|
tree-status is red again. 223 commits behind (no conflicts, locally rebased and re-tested again, still fine) - I guess it's time to push rebased version again |
2928968 to
8b65684
Compare
|
@LouiseHsu @vashworth let's add |
|
@LouiseHsu am I right that "requested change" is not actual for this MR? ay least I have nothing obvious to do as "changes" here. P.S. Ready to push rebased local retested branch as always. |
|
@Akhrameev did you notice any change in this behavior for iOS 27? |
|
@leolabs I re-verified this with video captures on official (published) Flutter 3.35.4 and Flutter 3.45.0-1.0.pre-590 (Framework revision f95820bde2 Behavior does not change and complete work correctly on iOS 27 for all types of devices. On iOS 27.0 (tested: iPad Pro 13" M5 simulator, iPhone 17 Pro simulator, iPhone 17e simulator), I do not see this issue. For the tested iOS 27.0 devices, behavior is correct even without my fix. From my investigation, the problem appears specific to iOS 26.0 / 26.0.1. I also tested iOS 26.1, 26.2, 26.4.1, and 27.0 — these behave correctly on official Flutter (and on patched as well). |
|
Here are videos of unpatched flutter (I've recordered iPad non-notch only):
iPad_NonNotch_iOS26_0_1_official.mov
iPad_NonNotch_iOS26_0_official.movTHEY BOTH HAVE THIS ISSUE
iPad_NonNotch_iOS26_1_official.mov
iPad_Pro_13_M5_iOS26_4_1_official.mov
iPad_Pro_13_M5_iOS27_0_official.movLAST THREE iOS VERSIONS BEHAVE CORRECT EVEN ON UNPATCHED FLUTTER The same for Dynamic Island devices: they work correct on both versions. |
|
Now patched version videos:
iPad_NonNotch_iOS26_0_1_patched.mov
iPad_NonNotch_iOS26_0_patched.mov
iPad_NonNotch_iOS26_1_patched.mov
iPad_Pro_13_M5_iOS26_4_1_patched.mov
iPad_Pro_13_M5_iOS27_0_patched.movNOW THEY ALL WORK CORRECTLY Bonus:
iPhone_17_Pro_patched.mov
iPhone_17e_patched.movALSO CORRECT |
|
updated MR title to be more precise about the scope of the issue |
|
Mentioned Simulator is unavailable: |
|
An existing Git SHA, To re-trigger presubmits after closing or re-opeing a PR, or pushing a HEAD commit (i.e. with |
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) |

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
///).