targetSdk を 36 に上げてビルドし、いつもの壁紙プレビュー画面を開いた瞬間、上部のツールバーがステータスバーの時計に重なっていました。これまで windowOptOutEdgeToEdgeEnforcement を true にして逃げていた箇所が、Android 16 では効かなくなっていたのです。
私は2014年から個人で iOS / Android のアプリを運用していて、壁紙や癒し系のアプリが中心です。累計5,000万ダウンロードと言うと数だけは大きく聞こえますが、その実態は「同じような構造のアプリを何本も並行で抱えている」という状態で、OS のこうした仕様変更が来るたびに、全アプリを一斉に直す作業が発生します。今回の edge-to-edge 強制は、まさにその典型でした。この記事は手順の解説というより、複数アプリを横断して直すときに Antigravity のエージェントをどこで使い、どこで自分の手で確かめたか、という運用の記録です。
なぜ「あとで」では済まないのか
Android 15(API 35)の時点で、targetSdk 35 のアプリはデフォルトで edge-to-edge になっていました。ただしこのときは windowOptOutEdgeToEdgeEnforcement を true にすれば従来の挙動に戻せたので、私は正直この opt-out に頼って先送りしていました。
Android 16(API 36)で状況が変わります。targetSdk 36 のアプリでは、この属性が deprecated かつ無効化され、edge-to-edge を拒否できなくなりました。挙動を正確に書くと、targetSdk 36 のアプリでも「Android 15 の実機で動いている間」は opt-out がまだ効きますが、「Android 16 の実機」では無効になります。つまり端末が更新されれば、こちらが何もしなくても全画面化が降ってくるということです。
先送りできないと判断した理由はもう一つあります。壁紙アプリのプレビュー画面は、本来 edge-to-edge と相性が良いのです。画像を画面の隅まで見せたいのに、システムバーの下に余白を作っていたのは、むしろ機会損失でした。「いつか対応する課題」ではなく「今やると体験が良くなる改善」だと捉え直したことで、ようやく手が動きました。
まず opt-out を全アプリから消す
最初の作業は、逃げ道だった windowOptOutEdgeToEdgeEnforcement を全部消すことです。ここで Antigravity の Agent Mode に「リポジトリ横断でこの属性を使っている箇所を列挙して」と頼みました。themes.xml だけでなく、一部のアプリでは Activity 個別のテーマで上書きしていたので、自分の記憶だけでは取りこぼすところでした。
<!-- 削除前: これに頼っていた -->
<style name="Theme.WallpaperApp" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>
</style>
<!-- 削除後: 属性ごと取り除く -->
<style name="Theme.WallpaperApp" parent="Theme.Material3.DayNight.NoActionBar">
<!-- edge-to-edge を受け入れる前提に切り替える -->
</style>合わせて、android:statusBarColor と android:navigationBarColor も targetSdk 36 では deprecated です。色で塗って隠すという発想自体を捨て、バーは透過させてコンテンツを下に敷く、という方針に切り替えました。
insets を「受け取る側」に書き換える
opt-out を消すと、当然あちこちでコンテンツがシステムバーに潜り込みます。ここからが本題で、各画面が insets(システムバーの占有領域)を受け取って自分でパディングを当てる必要があります。
View ベースの古い Activity では、enableEdgeToEdge() を呼んだ上で、ルートビューに inset リスナーを付けるのが確実でした。
import androidx.activity.enableEdgeToEdge
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
class PreviewActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_preview)
// ツールバーだけは必ずシステムバーを避ける。
// 壁紙そのものは全画面のまま、操作 UI に上下パディングを当てる。
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.toolbar)) { v, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(v.paddingLeft, bars.top, v.paddingRight, v.paddingBottom)
insets
}
}
}ここでつまずいたのは、fitsSystemWindows="true" を XML に残したまま inset リスナーも書いてしまい、パディングが二重に入って逆に余白が広がった画面があったことです。Android 16 環境では fitsSystemWindows の指定は外し、insets の処理は一箇所に寄せるのが結局いちばん読みやすくなりました。
Jetpack Compose に移行済みの新しいアプリでは、もっと素直でした。Scaffold の innerPadding を内側に渡すか、全画面の壁紙レイヤーには当てず、操作レイヤーにだけ safeDrawingPadding() を当てます。
Box(Modifier.fillMaxSize()) {
// 壁紙は画面の隅まで描く(ここには insets を当てない)
WallpaperImage(modifier = Modifier.fillMaxSize())
// 操作 UI はシステムバーを避ける
PreviewControls(
modifier = Modifier
.align(Alignment.TopCenter)
.safeDrawingPadding(),
)
}「画像には当てず、UI にだけ当てる」というこの線引きが、壁紙アプリにおける edge-to-edge 対応の勘どころだと感じています。全部に safeDrawingPadding() を付けると、せっかくの全画面プレビューが従来と変わらなくなってしまいます。
Antigravity をどこで使い、どこで使わなかったか
複数アプリを横断する作業で、Antigravity のエージェントが効いたのは「機械的に拾える対象の洗い出し」でした。opt-out 属性の列挙、statusBarColor の使用箇所の特定、insets リスナーが付いていない Activity の候補出しまでは、エージェントに任せると確実で速かったです。
一方で、最終的なパディングの当たり具合は、必ず実機とエミュレータの両方で自分の目で確認しました。insets は端末によって(特にジェスチャーナビとボタンナビで)値が変わるので、コードが正しく見えても見た目がずれることがあります。宮大工だった祖父たちは、図面が合っていても最後は必ず手で触れて確かめていたそうですが、この種の仕上げの確認だけは人がやるべき部分だと、画面を一枚ずつ見ながら思いました。エージェントに任せる範囲と、自分で確かめる範囲を分けておくことが、横断作業では一番の時短になります。
これから取り組むこと
いまは壁紙アプリ群の対応を一通り終え、3辺ジェスチャーナビ端末での表示確認を実機で詰めている段階です。次は、同じ insets 処理が各アプリにコピペで散らばっているのを、共通モジュールに切り出して一本化したいと考えています。OS の仕様変更が来るたびに全アプリを直す今の構造そのものを、少しずつ軽くしていくのが当面の目標です。
同じように複数アプリを抱えて targetSdk 36 への移行を控えている方は、まず opt-out の使用箇所を横断で洗い出すところから始めると、作業量の見通しが立てやすいと思います。お読みいただきありがとうございました。