目次
概要
iPad のマルチタスキング(Split View · Slide Over · Stage Manager)環境では、システムがウィンドウ上部中央に grab handle を表示します。ユーザーがこのハンドルを掴んでウィンドウを移動したりサイズ変更したりするために使うシステムコントロールです。
問題は、grab handle の左側エリアがアプリの AppBar 左上エリアと重なることです。iPhone の AppBar デザインをそのまま持ってくると、左上の 戻るボタン がハンドル領域と視覚的に衝突し、タッチエリアも一部隠されてユーザー体験が損なわれます。
このブログでは、この問題を解決した過程を整理します。
- 問題診断: なぜ従来の画面サイズベースの
isTablet判定ではこのケースを捉えられないのか - 基盤整備: ハードウェアベースの iPad 判定と起動時の静的キャッシュパターン
- 適用: AppHeader に top offset を追加する過程で発見した AppBar leading 配置の微妙な落とし穴
なぜ画面サイズベースの iPad 判定が失敗するのか
重なり問題を解決するには「iPad でだけ安全オフセットを適用」する必要があります。iPhone にはマルチタスキングがないので、同じオフセットを適用するとデザインが崩れます。最もシンプルな発想は画面サイズベースの判定です。
// 画面サイズベースの判定 (シンプルだがマルチウィンドウでは失敗)
bool get isTablet => MediaQuery.of(context).size.shortestSide >= 600;
この方式は単一ウィンドウ環境ではうまく動きますが、iPad マルチタスキングではウィンドウのサイズがデバイスのサイズと異なります。
- Slide Over: 約 320pt 幅の狭いオーバーレイウィンドウ。shortestSide が 320pt なので iPhone と誤判定される。
- 狭い Split View(1/3): 約 320〜375pt 幅。同様に iPhone と誤判定される。
- Stage Manager: ユーザーがウィンドウサイズを自由に調整可能。狭く縮めると同じ誤判定が発生する。
結果として、システム grab handle が最もはっきり見える狭いウィンドウのシナリオで、肝心のオフセットが適用されない という矛盾が発生します。
もう一つの問題は、ウィンドウサイズ変更時に MediaQuery が再ビルドをトリガーすることです。isTablet が true → false → true と揺れ動くと、その都度 AppBar の preferredSize が変わってレイアウトジャンプが起きる可能性があります。
解決の方向は明確です。 「この端末が物理的に iPad なのか」を確認する方法はウィンドウサイズと無関係でなければなりません。
ハードウェアベースの iPad 判定
device_info_plus の IosDeviceInfo は画面ではなくハードウェア情報を返します。2つのフィールドを組み合わせれば iPad を安定的に識別できます。
final plugin = DeviceInfoPlugin();
final ios = await plugin.iosInfo;
final model = ios.model; // 例: 'iPad'
final machine = ios.utsname.machine; // 例: 'iPad14,1'
final isIPad =
model.toLowerCase().contains('ipad') || machine.startsWith('iPad');
modelは “iPad” / “iPhone” のような人が読めるモデル名utsname.machineは “iPad14,1” / “iPhone15,2” のようなハードウェア識別子
2つのフィールドを OR で結合する理由は、将来 iOS が model の表記を変更しても utsname.machine でフォールバックできるためです。いずれか一方でも ‘iPad’ の手がかりを含めば iPad と確定します。
この判定は ウィンドウサイズと無関係 なので、Slide Over · 狭い Split · Stage Manager どのシナリオでも一貫して true を返します。
起動時に1回キャッシュ + 冪等な init
iosInfo 呼び出しは非同期でプラットフォームチャネルを通すため、毎回の build で呼んではいけません。起動時に1回だけ呼んで静的変数にキャッシュします。
class DeviceInfo {
static bool _isIPadCached = false;
static bool _isIPadInitialized = false;
static Future<void>? _initializeFuture;
/// ハードウェアベースの iPad 判定結果(起動時に1回計算された静的キャッシュ)。
/// `initialize()` 呼び出し前は false を返す(安全なデフォルト)。
static bool get isIPad => _isIPadCached;
/// アプリ起動時(`runApp` 直前)に1回呼び出して iPad 判定結果をキャッシュする。
/// 冪等(idempotent): 重複 / 並列呼び出しでも plugin は1回だけ呼ばれる。
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) {
// フォールバック: device_info_plus 失敗時は安全なデフォルト(false=iPad ではない)。
_isIPadCached = false;
try {
CrashlyticsHelper.recordError(
e,
s,
reason: 'DeviceInfo.initialize iosInfo 取得失敗',
);
} catch (_) {
// 記録失敗は無視 — iPad 判定 false フォールバック自体はすでに適用済み。
}
}
} finally {
_isIPadInitialized = true;
}
}
}
設計のポイントを整理します。
Future自体をキャッシュ — 重複 / 並列呼び出しでも plugin は1回だけ呼ばれます。同時に2か所でinitialize()を await しても同じ Future を共有するので race condition がありません。- iOS 以外のプラットフォームでは即終了 — Android / Web で不要な plugin 呼び出しを避けます。
- 二重 try/catch フォールバック —
iosInfo呼び出し失敗時は false にフォールバックして Crashlytics に記録します。Firebase 未初期化などでrecordError自体が throw する状況にも備えてもう一度包みます。 - 初期化前の呼び出しは安全なデフォルト —
_isIPadCachedの初期値が false なので、呼び出し順のバグがあっても「iPad ではない」として安全に動作します(オフセットが適用されないだけで視覚的な問題はなし)。
呼び出しは runApp 直前、Firebase 初期化の後に置きます。
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
// iPad マルチタスキングの安全オフセットなど、ハードウェアベースの判定に使う
// 静的キャッシュを起動時に1回初期化する。
// フォールバックパスで `CrashlyticsHelper.recordError` を呼び出す可能性があるので
// 必ず `Firebase.initializeApp(...)` の後に呼び出す必要がある。
await DeviceInfo.initialize();
runApp(const MyApp());
}
AppHeader に安全オフセットを適用
判定の基盤が整ったので、iPad でだけ AppBar の上部に追加のオフセットを適用します。他の OS / 端末のウィジェットツリーや外観には一切影響しないようにします。
const double _kIPadTopSafeOffset = 32.0;
class AppHeader extends StatelessWidget implements PreferredSizeWidget {
// ...
/// iPad の場合のみ安全オフセットを適用する。
/// DeviceInfo.isIPad は起動時に1回キャッシュされた静的値なので
/// 毎回の build に追加コストがなく、ウィンドウサイズの変化(slide over 等)
/// にも影響されない。
double get _topOffset => DeviceInfo.isIPad ? _kIPadTopSafeOffset : 0.0;
@override
Size get preferredSize => Size.fromHeight(kToolbarHeight + _topOffset);
@override
Widget build(BuildContext context) {
final offset = _topOffset;
// iPhone/Android (offset == 0) では Padding ラッパー自体を追加せず、
// ウィジェットツリー / 外観の変化をコードレベルで明確に遮断する。
final hasOffset = offset > 0;
final topPadding = hasOffset ? EdgeInsets.only(top: offset) : null;
Widget wrapTop(Widget child) =>
topPadding == null ? child : Padding(padding: topPadding, child: child);
// ...
}
}
ここで強調したいルールが一つ、「オフセット 0 のときはラッパー自体を追加しない」 という点です。
Padding(padding: EdgeInsets.zero, child: ...) は視覚的には同じですが、ウィジェットツリーにノードを追加します。iPhone / Android ユーザーは 99% 以上がこの変更の影響範囲外なので、ウィジェットツリー自体が同一でなければ Flutter inspector で変更内容を可視化したときにノイズになりません。Golden テストも iPhone 側は既存の golden をそのまま再利用できます。
if (hasOffset) の分岐で明示的に分けておくと、コードレビューでも「iPad でなければ何の変化もない」が一目で分かります。
preferredSize も合わせて +offset に拡大する必要があります。AppBar の toolbarHeight も同じく拡大しないといけませんが、この2つを一緒に拡大しないと AppBar の外側のボックスサイズと親 Container のボックスサイズがずれて、影(BoxShadow)が AppBar 本体から分離して浮いて見える視覚バグが発生します。
return Container(
decoration: getAppBarBoxShadow(),
child: AppBar(
// iPad: toolbarHeight を 32pt 拡大して、視覚領域と影が一緒に広がる。
// iPhone/Android は offset == 0 なので動作 / 外観が完全に同一。
toolbarHeight: kToolbarHeight + offset,
title: wrapTop(titleWidget ?? _buildDefaultTitle()),
// ...
),
);
落とし穴 — 自動 BackButton と明示 leading の配置の違い
ここからがこの fix の最も興味深い部分です。上のオフセットだけを適用して終わると、iPad の自動 BackButton だけが title と baseline がずれる という問題が発生します。
原因は Flutter AppBar の leading 処理ルールです。
- 自動 BackButton(
leading == nullでcanPop == trueのときに AppBar が自動生成) → toolbar の 縦方向の中央配置 - 明示的に渡した leading ウィジェット → デフォルトで top-left 基準の配置
この違いは普段は見えません。toolbarHeight が標準値のときは BackButton の領域が toolbar 全体と同じなので、中央配置でも top 配置でも視覚的な結果が同じだからです。
しかし toolbarHeight を +offset で拡大した瞬間に差が現れます。
- title を
wrapTop(Padding(top: offset, child: ...))で包むと、title は +offset 分だけ下に移動 - 自動 BackButton は中央配置なので、toolbar が 32pt 大きくなると +offset/2(=16pt)だけ下に移動
- 明示 leading は top-left 基準なのでそのまま toolbar 上端に貼り付く → wrapTop で包んで title と一緒に移動させる必要あり
つまり同じ画面でも leading の種類によって配置が変わります。
[自動 BackButton だけ 16pt 上に浮いている]
┌─────────────────────────────┐
│ ← (16pt down) │ ← 自動 BackButton (中央配置)
│ │
│ [Title] (32pt down) │ ← wrapTop で +offset 移動
└─────────────────────────────┘
解決方向は2つあります。
- 自動 BackButton を無効化せずに toolbar 上端をさらに下に引っ張る方法を探す(難しい)
- canPop のときは BackButton を直接作って title と同じ方法で wrapTop で包む(明確)
採用したのは2番です。ただし、canPop == false のケース(最初のページ、drawer が leading に入るケースなど)では直接 BackButton を作ってはいけません。この場合 leading を null にしておくと、AppBar が drawer hamburger などのデフォルト leading を適切に処理してくれます。
// iPad(hasOffset == true)では、自動 BackButton も明示 leading と同じく
// +offset で補正しないと title と baseline が合わない。
// よって leading == null の場合でも canPop なら BackButton を
// 直接生成して wrapTop で包む。canPop == false のケースは null を渡して
// AppBar のデフォルト leading 動作(drawer hamburger 等)を保持する。
// iPhone/Android(offset == 0)では leading == null のときは null をそのまま
// 渡して AppBar の自動 BackButton メカニズムを維持する。
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: [
// offset == 0 (iPhone/Android) では actions をそのまま渡して、
// ウィジェットツリーに Padding のノイズが追加されないようにする。
if (hasOffset)
...actions.map((action) => wrapTop(action))
else
...actions,
const SizedBox(width: 16),
],
);
分岐マトリックスを整理すると次の通りです。
| iPad? | leading 明示? | canPop | 処理 |
|---|---|---|---|
| No | - | - | 元のまま(自動 BackButton / null / leading すべて AppBar デフォルト動作) |
| Yes | 明示 | - | wrapTop(leading!) |
| Yes | null | true | wrapTop(BackButton()) を直接生成 |
| Yes | null | false | null (drawer hamburger 等 AppBar デフォルト動作を維持) |
ポイントは 「iPad では leading が toolbar 上端に貼り付くというルールを、自動 BackButton のケースにも同じく適用する」 という点です。canPop 分岐を忘れると最初のページに意図しない BackButton が現れる問題が発生するので、必ず一緒に扱う必要があります。
まとめ
iPad マルチタスキング環境での AppBar とシステムコントロールの重なりを解決するために整理した内容です。
- 画面サイズベースの iPad 判定はマルチウィンドウで失敗 する。Slide Over · 狭い Split · Stage Manager の狭いウィンドウで iPad が iPhone と誤判定される。
- ハードウェアベースの判定 を使う。
IosDeviceInfo.modelとutsname.machineを組み合わせれば、ウィンドウサイズと無関係な判定ができる。 - 起動時に1回キャッシュ + 冪等な init で毎回の build コストを 0 にする。重複 / 並列呼び出しは同じ Future を共有するので race がない。フォールバックは安全なデフォルト(false=iPad ではない)に置いて、呼び出し順の問題があっても視覚的な問題が出ないようにする。
- オフセット適用は iPad 限定 で分岐させ、iPhone / Android のウィジェットツリーは無変化であることをコードレベルで保証する。
if (hasOffset)の分岐で Padding 自体を追加しない。 - AppBar の leading 配置の落とし穴: 自動 BackButton は toolbar center 配置、明示 leading は top-left 配置なので、toolbarHeight を拡大すると baseline がずれる。canPop のときは BackButton を直接作って wrapTop で包み、title と同じ配置を合わせる。canPop == false のケースは null を渡して AppBar のデフォルト動作を保持する。
iPad マルチタスキングはユーザーから見れば「ウィンドウが1つ小さくなった」だけですが、システム grab handle のような新しい視覚要素が入り込み、iPhone のデザインをそのまま持ってくると衝突が生じます。鍵は 端末のアイデンティティ(ハードウェア)と画面の状態(ウィンドウサイズ)を分けて扱うこと です。この分離が整っていれば、マルチタスキング環境の他の問題も同じパターンで対処できます。
関連資料
- Flutter — AppBar class
- device_info_plus — iosInfo
- Apple Human Interface Guidelines — Multitasking on iPad
私のブログが役に立ちましたか?下にコメントを残してください。それは私にとって大きな大きな力になります!
アプリ広報
Dekuが開発したアプリを使ってみてください。Dekuが開発したアプリはFlutterで開発されています。興味がある方はアプリをダウンロードしてアプリを使ってくれると本当に助かります。