Contents
Outline
In iPad multitasking environments (Split View · Slide Over · Stage Manager), the system displays a grab handle at the top center of the window. This is a system control users use to grab the handle and move or resize the window.
The problem is that the left area of the grab handle overlaps with the top-left area of the app’s AppBar. If you bring the iPhone AppBar design over as-is, the back button in the top-left visually clashes with the handle area, and the touch area is partially obscured — breaking the user experience.
This post walks through how I fixed this issue:
- Diagnosing the issue: Why the existing screen-size based
isTabletdetection can’t catch this case - Building the foundation: Hardware-based iPad detection and a boot-time static cache pattern
- Applying the fix: A subtle AppBar leading alignment pitfall I discovered while adding a top offset to AppHeader
Why Screen-Size Based iPad Detection Fails
To fix the overlap, we need to apply a safe offset “only on iPad”. iPhone has no multitasking, so applying the same offset would break the design. The most straightforward idea is screen-size based detection.
// Screen-size based detection (simple, but fails in multi-window scenarios)
bool get isTablet => MediaQuery.of(context).size.shortestSide >= 600;
This works fine in a single-window environment, but in iPad multitasking, the window size differs from the device size.
- Slide Over: A narrow ~320pt overlay window.
shortestSideis 320pt, so it’s misidentified as iPhone. - Narrow Split View (1/3): ~320–375pt wide. Same misidentification.
- Stage Manager: The user can freely resize windows. Shrink it narrow and the same misidentification occurs.
The result is a contradiction: the offset isn’t applied precisely in the narrow-window scenarios where the system grab handle is most visible.
Another problem is that window resizing triggers rebuilds through MediaQuery. If isTablet flips between true → false → true, the AppBar’s preferredSize changes each time, which can cause layout jumps.
The direction of the fix is clear. The way we check “is this device physically an iPad” must be independent of the window size.
Hardware-Based iPad Detection
device_info_plus returns hardware information through IosDeviceInfo, not screen information. By combining two fields, we can reliably identify an iPad.
final plugin = DeviceInfoPlugin();
final ios = await plugin.iosInfo;
final model = ios.model; // e.g., 'iPad'
final machine = ios.utsname.machine; // e.g., 'iPad14,1'
final isIPad =
model.toLowerCase().contains('ipad') || machine.startsWith('iPad');
modelis a human-readable model name like “iPad” / “iPhone”utsname.machineis a hardware identifier like “iPad14,1” / “iPhone15,2”
We combine the two fields with OR so that even if iOS changes the format of model in the future, we can fall back to utsname.machine. If either field contains the ‘iPad’ clue, we confirm it as iPad.
This detection is independent of window size, so it returns true consistently in Slide Over / narrow Split / Stage Manager — every scenario.
Cache Once at Boot + Idempotent Init
iosInfo is async and goes through the platform channel, so we shouldn’t call it on every build. Call it once at boot and cache the result in a static variable.
class DeviceInfo {
static bool _isIPadCached = false;
static bool _isIPadInitialized = false;
static Future<void>? _initializeFuture;
/// Hardware-based iPad detection result (static cache computed once at boot).
/// Returns false before `initialize()` is called (safe default).
static bool get isIPad => _isIPadCached;
/// Call once at app boot (just before `runApp`) to cache the iPad detection result.
/// Idempotent: the plugin is called only once even with duplicate / parallel calls.
static Future<void> initialize() {
if (_isIPadInitialized) {
return _initializeFuture ?? Future.value();
}
return _initializeFuture ??= _doInitialize();
}
static Future<void> _doInitialize() async {
try {
if (defaultTargetPlatform != TargetPlatform.iOS) {
_isIPadCached = false;
return;
}
try {
final plugin = DeviceInfoPlugin();
final ios = await plugin.iosInfo;
final model = ios.model;
final machine = ios.utsname.machine;
_isIPadCached =
model.toLowerCase().contains('ipad') ||
machine.startsWith('iPad');
} catch (e, s) {
// Fallback: on device_info_plus failure, default to safe value (false = not iPad).
_isIPadCached = false;
try {
CrashlyticsHelper.recordError(
e,
s,
reason: 'DeviceInfo.initialize iosInfo lookup failed',
);
} catch (_) {
// Ignore logging failure — the false fallback for iPad detection is already applied.
}
}
} finally {
_isIPadInitialized = true;
}
}
}
The key design points:
- Cache the
Futureitself — the plugin is called only once even with duplicate / parallel calls. If two places awaitinitialize()simultaneously, they share the same Future, eliminating race conditions. - Exit immediately on non-iOS platforms — avoid unnecessary plugin calls on Android / Web.
- Double try/catch fallback — on
iosInfofailure, fall back to false and log to Crashlytics. Wrap once more to handle cases whererecordErroritself throws (e.g., Firebase not initialized). - Pre-init calls return the safe default — since
_isIPadCachedstarts as false, even with call-order bugs, the app safely behaves as “not iPad” (the offset just isn’t applied — no visual issue).
The call goes just before runApp, after Firebase initialization.
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
// Initialize the static cache used for hardware-based detection such as
// the iPad multitasking safe offset, once at boot.
// Must be called after `Firebase.initializeApp(...)` because the fallback
// path may call `CrashlyticsHelper.recordError`.
await DeviceInfo.initialize();
runApp(const MyApp());
}
Applying the Safe Offset to AppHeader
With the detection foundation in place, we apply an additional top offset to the AppBar only on iPad. There should be zero impact on the widget tree or appearance of other OSes / devices.
const double _kIPadTopSafeOffset = 32.0;
class AppHeader extends StatelessWidget implements PreferredSizeWidget {
// ...
/// Apply the safe offset only on iPad.
/// DeviceInfo.isIPad is a static value cached once at boot, so there's no
/// per-build cost, and it's not affected by window size changes (slide over, etc.).
double get _topOffset => DeviceInfo.isIPad ? _kIPadTopSafeOffset : 0.0;
@override
Size get preferredSize => Size.fromHeight(kToolbarHeight + _topOffset);
@override
Widget build(BuildContext context) {
final offset = _topOffset;
// On iPhone/Android (offset == 0), avoid adding the Padding wrapper itself,
// to block widget-tree / appearance changes at the code level.
final hasOffset = offset > 0;
final topPadding = hasOffset ? EdgeInsets.only(top: offset) : null;
Widget wrapTop(Widget child) =>
topPadding == null ? child : Padding(padding: topPadding, child: child);
// ...
}
}
One discipline worth highlighting here is “don’t add the wrapper itself when the offset is 0”.
Padding(padding: EdgeInsets.zero, child: ...) is visually identical, but it adds a node to the widget tree. Over 99% of iPhone / Android users are outside the scope of this change, so the widget tree itself must be identical for there to be no noise when visualizing changes in Flutter inspector. Golden tests on the iPhone side can also reuse existing goldens.
Explicitly branching with if (hasOffset) makes it immediately obvious in code review that “nothing changes if not iPad.”
preferredSize must be increased by +offset as well. The AppBar’s toolbarHeight must be increased the same way; if you don’t increase both together, the AppBar’s outer box and the parent Container’s outer box become misaligned, causing a visual bug where the shadow (BoxShadow) appears to float detached from the AppBar body.
return Container(
decoration: getAppBarBoxShadow(),
child: AppBar(
// iPad: toolbarHeight grows by 32pt, so the visible area and the shadow grow together.
// iPhone/Android: offset == 0, so behavior / appearance are completely identical.
toolbarHeight: kToolbarHeight + offset,
title: wrapTop(titleWidget ?? _buildDefaultTitle()),
// ...
),
);
The Pitfall — Auto BackButton vs Explicit Leading Alignment
This is the most interesting part of the fix. If you stop at just applying the offset above, you’ll hit an issue where only the auto BackButton on iPad ends up misaligned with the title’s baseline.
The cause is Flutter AppBar’s leading handling rules.
- Auto BackButton (AppBar auto-generates when
leading == nullandcanPop == true) → vertically center-aligned in the toolbar - Explicitly passed leading widget → by default, positioned top-left
This difference usually isn’t visible. When toolbarHeight is the standard value, the BackButton’s area equals the entire toolbar, so center alignment and top alignment look identical visually.
But the moment you increase toolbarHeight by +offset, the difference surfaces.
- Wrapping the title with
wrapTop(Padding(top: offset, child: ...))moves the title down by +offset - The auto BackButton is center-aligned, so when the toolbar grows by 32pt, it moves down by only +offset/2 (= 16pt)
- Explicit leading is top-left aligned, so it stays at the top of the toolbar → must be wrapped with wrapTop to move down with the title
So even on the same screen, the alignment differs depending on the type of leading.
[Only the auto BackButton floats 16pt above]
┌─────────────────────────────┐
│ ← (16pt down) │ ← Auto BackButton (center-aligned)
│ │
│ [Title] (32pt down) │ ← moved +offset by wrapTop
└─────────────────────────────┘
There are two ways to fix this:
- Find a way to push the toolbar top down further without disabling the auto BackButton (tricky)
- When canPop, create the BackButton directly and wrap it with wrapTop the same way as the title (clear)
We chose option 2. However, when canPop == false (first page, drawer-as-leading cases, etc.), we must NOT create the BackButton directly. In that case, leaving leading as null lets AppBar handle the default leading (drawer hamburger, etc.) on its own.
// On iPad (hasOffset == true), the auto BackButton must be adjusted by +offset
// the same way as explicit leading, so its baseline aligns with the title.
// Therefore, even when leading == null, if canPop is true we create the BackButton
// directly and wrap it with wrapTop. When canPop == false we pass null
// to preserve AppBar's default leading behavior (drawer hamburger, etc.).
// On iPhone/Android (offset == 0), we pass null as-is when leading == null
// to keep AppBar's auto BackButton mechanism.
Widget? resolvedLeading;
if (leading != null) {
resolvedLeading = wrapTop(leading!);
} else if (hasOffset) {
final canPop = ModalRoute.of(context)?.canPop ?? false;
if (canPop) {
resolvedLeading = wrapTop(const BackButton());
}
}
return AppBar(
toolbarHeight: kToolbarHeight + offset,
leading: resolvedLeading,
title: wrapTop(titleWidget ?? _buildDefaultTitle()),
actions: [
// On offset == 0 (iPhone/Android), pass actions through as-is so the
// widget tree doesn't get padding noise.
if (hasOffset)
...actions.map((action) => wrapTop(action))
else
...actions,
const SizedBox(width: 16),
],
);
The branching matrix:
| iPad? | leading specified? | canPop | Handling |
|---|---|---|---|
| No | - | - | Original (auto BackButton / null / leading all use AppBar default) |
| Yes | Yes | - | wrapTop(leading!) |
| Yes | null | true | Create wrapTop(BackButton()) directly |
| Yes | null | false | null (preserve AppBar defaults like drawer hamburger) |
The key is “apply the rule that on iPad, leading must stick to the top of the toolbar — to the auto BackButton case as well”. Forgetting the canPop branch causes an issue where an unintended BackButton appears on the first page, so it must be handled together.
Wrap-Up
A summary of what we covered to fix the AppBar / system control overlap in iPad multitasking:
- Screen-size based iPad detection fails in multi-window environments. In narrow windows of Slide Over / narrow Split / Stage Manager, iPad gets misidentified as iPhone.
- Use hardware-based detection. Combining
IosDeviceInfo.modelandutsname.machineyields detection that’s independent of window size. - Cache once at boot + idempotent init to bring per-build cost to zero. Duplicate / parallel calls share the same Future, so there’s no race condition. The fallback defaults to safe (false = not iPad), so even call-order bugs don’t cause visual issues.
- Limit offset application to iPad by branching, guaranteeing at the code level that the iPhone / Android widget tree is unchanged. Use
if (hasOffset)to avoid adding the Padding wrapper itself. - AppBar leading alignment pitfall: the auto BackButton is center-aligned in the toolbar while explicit leading is top-left aligned, so increasing
toolbarHeightmisaligns the baselines. When canPop, create the BackButton directly and wrap it with wrapTop to match the title’s alignment. When canPop == false, pass null to preserve AppBar’s default behavior.
To users, iPad multitasking just looks like “the window got smaller”, but new visual elements like the system grab handle creep in — so bringing the iPhone design over as-is causes clashes. The key is to separate device identity (hardware) from screen state (window size). With that separation in place, other multitasking issues can be handled with the same pattern.
References
- Flutter — AppBar class
- device_info_plus — iosInfo
- Apple Human Interface Guidelines — Multitasking on iPad
Was my blog helpful? Please leave a comment at the bottom. it will be a great help to me!
App promotion
Deku.Deku created the applications with Flutter.If you have interested, please try to download them for free.