Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

Android Edge-to-Edge対応 大規模アプリですべての画面を更新するための道のり

はじめに

こんにちは。技術本部 Sansan Engineering Unit Mobile Applicationグループの上野です。2025年4月に新卒で入社し、SansanのAndroidアプリ開発に携わっています。

皆さんのアプリでは、Android 16のEdge-to-Edge必須化への対応は進んでいますか?

私たちSansanのAndroidチームは、2025年7月頃からEdge-to-Edge対応プロジェクトに取り組んでいます。Sansanアプリは100を超える画面からなる大規模なアプリであり、Edge-to-Edge対応を通じて全画面での実装とレビューが必要になります。

この記事では、現在進行中のEdge-to-Edge対応プロジェクトから得られている知見を共有します。以下3点を中心に、Sansanならではの工夫もお伝えします。

  1. 大規模アプリでの実践的な対応戦略 - どのように作業を分割し、チーム全体で取り組むか
  2. 開発効率向上のための取り組み - ドキュメント整備とAI活用
  3. レビューコスト削減の工夫 - 非エンジニア向けガイドラインの作成

同じような状況にある開発者の方々にとって、少しでも参考になれば幸いです。

Edge-to-Edgeとは何か

Edge-to-Edgeとは、直訳すると「端から端まで」という意味で、Androidアプリのコンテンツを画面いっぱいに表示する方法です。

これまではシステムバーを避けるようにコンテンツが配置されていましたが、Edge-to-Edgeでは画面全体をアプリが利用できます。これにより、没入感のあるモダンなUI体験を提供できるようになります。

対応期限とオプトアウト

Edge-to-Edge対応は以下のように対応期限が設定されています。

  • API Level 35(2025年8月31日期限)
    • Android 15を搭載しているデバイスではデフォルトでEdge-to-Edge表示になる
    • ただしwindowOptOutEdgeToEdgeEnforcementによるオプトアウトが可能
    • オプトアウトすることで、従来通りシステムバーを避けてコンテンツを配置できる
<style name="Theme.App" parent="Theme.AppCompat.Light">
    <item name="android:windowOptOutEdgeToEdgeEnforcement" tools:targetApi="35">true</item>
</style>
  • API Level 36(おそらく2026年8月末期限)
    • Android 16のデバイスではオプトアウトが無効になる
    • システムバーの背後まで表示可能になるので、UIが崩れる恐れがある

Sansanでは、当初すべての画面でオプトアウトを指定していました。そしてAndroid 16でのオプトアウト無効化を見据え、2025年7月中旬から段階的な対応を開始しました。

プロジェクトの進め方

プロジェクトを開始する前に、まず影響範囲について調査しました。

影響を受ける画面の特定

全Activityを洗い出し、以下の観点を整理しました。

  • どのfeatureモジュールに属するか
  • どのActivityから呼び出されているか
  • Compose対応済みか

この調査により、実装コストを明確化し、複数画面をまとめて対応するときの見積もり精度を高めることができます。

チケット化

以前紹介したEpoxy置き換えでは、モジュール単位でチケット化して進めました。一方、今回のEdge-to-Edge対応の場合、見た目の変化によるユーザーへの影響を考慮する必要があります。

そこで、ユーザーの導線別に画面をグループ化してチケットを分割することで、一つの導線で見た目を揃える方針を採用しました。

Edge-to-Edge対応の実装

ここからは、具体的な実装方法について説明します。

システムバーとWindowInsetsの理解

まず、Edge-to-Edge対応で重要な概念を理解する必要があります。

システムバーの種類

  • ステータスバー(Status Bar) - 画面上部の、時刻やバッテリー残量などが表示されるエリア
  • ナビゲーションバー(Navigation Bar) - 画面下部のシステムナビゲーション
    • ジェスチャーナビゲーション - スワイプジェスチャーで操作
    • 3ボタンナビゲーション - 戻る・ホーム・アプリ切替ボタン
  • キャプションバー(Caption Bar) - タブレット端末などで使用

タブレットに対応していなければ、基本的にステータスバー(上部)とナビゲーションバー(下部)を考慮して対応することになります。

WindowInsetsの種類

WindowInsetsは、システムバーなどの領域情報を取得するためのAPIです。主に以下の種類があります。

  • systemBars - ステータスバー + ナビゲーションバー
  • statusBars - ステータスバーのみ
  • navigationBars - ナビゲーションバーのみ
  • ime - キーボード領域

用途に応じて適切なWindowInsetsを選択することが重要です。

基本実装の流れ

Edge-to-Edge対応の基本的な流れは以下の通りです。

Step 1: enableEdgeToEdge()の呼び出し

まず、ActivityでEdge-to-Edgeを有効化します。これにより、Android 15未満のOSでも統一的にEdge-to-Edge表示が適用されます。

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    enableEdgeToEdge()
    // ..
}

Step 2: オプトアウト指定の削除

もしEdge-to-Edgeのオプトアウトを使用しているなら、テーマファイルからwindowOptOutEdgeToEdgeEnforcementを削除します。

<!-- 削除する -->
<style name="Theme.App" parent="Theme.AppCompat.Light">
    <item name="android:windowOptOutEdgeToEdgeEnforcement" tools:targetApi="35">true</item>
</style>

これらの設定により、Edge-to-Edgeが有効化されたときのレイアウト崩れが浮き彫りになります。

Step 3: 各画面での余白修正

これが最も重要で、画面ごとに異なる対応が必要なステップです。Material 3を使っていれば簡単に対応できるのですが、Material 2やAndroid Viewでは一つひとつ対応しないといけません。

Material 2 Composeでの実装

Material 2では、手動でWindowInsetsを適用する必要があります。

画面全体に安全にパディングを適用

これは簡易的なEdge-to-Edge対応に便利です。一方で、Edge-to-Edgeの効果が最大限発揮されず、没入感が薄れてしまうことに注意が必要です。

@Composable
fun MyScreen() {
    Box(Modifier.safeDrawingPadding()) {
        // システムバー領域に余白が設定される
    }
}

スクロール領域の考慮

重要なポイントとして、内部(子)が余白を保持する設計にすることで、没入感を高めることができます。

// 外部から余白を与える(没入感が薄れる)
Box(Modifier.safeDrawingPadding()) {
    LazyColumn {
        // リストアイテムがシステムバーの背後まで広がらない
    }
}
// 内部で余白を保持する(没入感がある)
LazyColumn(
    contentPadding = WindowInsets.systemBars.asPaddingValues()
) {
    // リストアイテムがシステムバーの背後まで表示される
}

FloatingActionButtonのめり込み防止

// ナビゲーションバーの余白を設定
FloatingActionButton(
   onClick = { },
   modifier = Modifier.windowInsetsPadding(WindowInsets.navigationBars),
) {
   // ..
}

windowInsetsを使っためり込み防止

以下の要素は引数windowInsetsの指定に対応しています。

  • BottomAppBar
  • TopAppBar
  • BottomNavigation
  • NavigationRail
TopAppBar(
   windowInsets =
       AppBarDefaults.topAppBarWindowInsets,
) {
   // ..
}

Android Viewでの実装

XMLレイアウトを使用している画面では、個別にWindowInsetsを適用します。拡張関数を定義しておくと便利です。上部だけ、下部だけのように適用範囲を引数で指定するのもいいでしょう。

Marginを更新するパターン

fun View.applySystemBarsInsets() {
    ViewCompat.setOnApplyWindowInsetsListener(this) { view, windowInsets ->
        val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
        view.updateLayoutParams<ViewGroup.MarginLayoutParams> {
            leftMargin = insets.left
            topMargin = insets.top
            rightMargin = insets.right
            bottomMargin = insets.bottom
        }
        WindowInsetsCompat.CONSUMED
    }
}
// 使用例
binding.root.applySystemBarsInsets()

Paddingを更新するパターン(Toolbarなど)

fun View.applySystemBarsInsetsAsPadding() {
    ViewCompat.setOnApplyWindowInsetsListener(this) { view, windowInsets ->
        val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
        view.updatePadding(
            left = insets.left,
            top = insets.top,
            right = insets.right,
            bottom = insets.bottom
        )
        WindowInsetsCompat.CONSUMED
    }
}
// 使用例
binding.toolbar.applyStatusBarsInsetsAsPadding()

DialogFragmentの特殊対応

少し実装に詰まったものとして、DialogFragmentの対応を紹介します。DialogFragmentは、開いた状態になると半透明の背景が画面全体に広がるため、別途対応が必要です。

androidx.core 1.17.0以降の場合

最近追加されたenableEdgeToEdge()を呼び出すことで対応できます。

class MyAlertDialogFragment : DialogFragment() {
    override fun onStart() {
        super.onStart()
        // DialogのウィンドウでもEdge-to-Edgeを有効化
        dialog?.window?.let { WindowCompat.enableEdgeToEdge(it) }
    }
    
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        // Dialogのコンテンツに適切な余白を設定
        return ComposeView(requireContext()).apply {
            setContent {
                Box(Modifier.safeDrawingPadding()) {
                    // ダイアログの内容
                }
            }
        }
    }
}

androidx.core 1.17.0未満の場合

専用のテーマを定義して適用します。

<!-- res/values/styles.xml -->
<style name="EdgeToEdgeDialogTheme" parent="Theme.AppCompat.Light.Dialog">
    <item name="android:windowIsFloating">false</item>
    <item name="enableEdgeToEdge">true</item>
</style>
class MyAlertDialogFragment : DialogFragment() {
    override fun getTheme(): Int = R.style.EdgeToEdgeDialogTheme
}

Sansanならではの取り組み

大規模なEdge-to-Edge対応を限られた期間で完遂するには、技術的な実装方法だけでなく、プロセスの効率化が不可欠でした。

ドキュメント整備による開発効率の向上

対応方針や拡張関数の使い方を明文化し、Notionに集約しました。これによりMCPサーバーを活用して実装できるようになりました。

また、新たな問題や解決策が見つかれば、すぐにNotionに追記します。これにより、メンバー全員で最新の実装方針を共有でき、属人化を避けてチーム全体で取り組める環境が実現できました。先ほど紹介したDialogFragmentの対応方法もその一例です。

開発者が「この画面をEdge-to-Edge対応したい」と指示すると、とりあえずEdge-to-Edge対応が有効化された状態を実現してくれます。その上で、余白の調整方法に問題がないかを確認していきます。

レビュープロセスの最適化

100を超える画面を一つずつデザイナーにレビュー依頼すると、非常に多くの時間とコミュニケーションコストがかかります。

そこで、個別の画面ではなく全体的な対応方針をドキュメント化し、それをデザイナーに一括レビューしてもらう方式を採用しました。例外的な対応のみ追加でレビューを依頼します。これによりデザイナーレビューのコストが大幅に削減されました。

ガイドラインの概要

  1. システムバー背景色の方針

    • 原則:隣接するコンポーネントと同じ色
    • 例外:隣接要素の色が一意でない場合は透明
  2. OK/NG比較画像

    • TopAppBar, BottomNavigation, FloatingActionButtonのレイアウト
    • スクロール時の余白

また、このガイドラインはQAチームにも共有しました。これによりQAチーム側で確認すべき観点が明確になり、デグレが発生しやすい部分に集中してテストすることが可能になります。

視覚的資産の蓄積

Edge-to-Edge対応そのものからは外れますが、並行して取り組んでいるユニークな試みを紹介します。

Sansanのモバイルアプリでは、網羅的な仕様が整備されておらず、すべての画面の把握も困難な状況でした。

そんな中、Edge-to-Edge対応ではあらゆる画面に触れることになります。これは、全画面を網羅的に把握できる絶好のチャンスでもあります。

桑原さん(@kilalabu)の発案により、Edge-to-Edge対応を通じて視覚的なリファレンスを整備することにしました。各画面の実装者が、画面のスクリーンショットと画面への遷移方法を明記するようにしました。これにより、すべての画面の概要を網羅的に整備することを目指しています。

最後に

オプトアウト無効化までは少し時間があります。

しかし、全画面の対応には想像以上に時間がかかります。以下のようなステップを踏むために、早めに着手することを強くお勧めします

  • 影響範囲を洗い出す
  • 対応方針をドキュメント化し、AIを活用しながらチーム全体で取り組める体制を整える
  • プロジェクトに必要なEdge-to-Edge実装を適宜調査し、ドキュメントを更新する
  • 段階的なリリースにより、レイアウト崩れのリスクを早めに把握する

オプトアウト無効化まであと少し。一緒にEdge-to-Edge対応を乗り越えましょう!

参考資料

Sansan技術本部ではカジュアル面談を実施しています

Sansan技術本部では中途の方向けにカジュアル面談を実施しています。Sansan技術本部での働き方、仕事の魅力について、現役エンジニアの視点からお話しします。「実際に働く人の話を直接聞きたい」「どんな人が働いているのかを事前に知っておきたい」とお考えの方は、ぜひエントリーをご検討ください。

© Sansan, Inc.