Safariブラウザ(iOS)のセーフエリア問題を解決：ShikakuPassプロジェクト実践

# はじめに

こんにちは、Gakken LEAPのフロントエンドエンジニアのkouです。
ShikakuPassプロジェクト（Vue 3、TypeScript、Nuxt 3、Tailwind CSSを使用）の開発過程で、iOS Safariの互換性問題に直面しました。

iOSデバイスでSafariブラウザの「シングルタブ」モードを使用すると、固定された下部ボタンをタップしてもボタンのアクションがトリガーされず、代わりにSafariのメニューが表示されてしまい、ユーザー体験に悪影響を与えていました。この問題の解決過程は複雑で、複数回の試行と改善を経て、最終的に優れた解決策を見つけることができました。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/242020/e143ed53-c21d-49b5-97f4-976eecd8b99f.png" alt="image.png" style="zoom:25%;" />

## 問題の分析

iPhoneなどの全面ディスプレイを備えたiOSデバイスでは、画面下部にジェスチャー操作用のセーフエリアが存在します。Appleは`env(safe-area-inset-bottom)`という変数を提供していますが、これだけでは不十分です。

- Safariのメニューを引き出す領域はセーフエリアより広い
- シングルタブモードではこの領域の感度がさらに高まる
- 他のブラウザでは必要ない余白まで設けると、逆にUXが悪化する

## 解決過程

### 1. 最初の試み：基本的なセーフエリアの追加

Tailwind CSSの設定でセーフエリア分の余白を追加しました：

```javascript
// tailwind.config.ts
spacing: {
  'safe-bottom': 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
},
```

この設定を下部ナビゲーションコンポーネントに適用しました：

```html
<div class="pb-safe-bottom fixed bottom-0 right-0 flex w-full justify-center">
  <!-- ナビゲーションボタンの内容 -->
</div>
```

しかし、この解決策は実際の使用では効果が不十分でした。16pxの追加スペースを加えても、ユーザーが下部ボタンをタップすると、特に「シングルタブ」モードでは、依然としてSafariメニューが表示されてしまいました。

### 2. 改善：iOS Safari向けにより大きなセーフエリアを追加

iOS Safariにだけより大きな余白を取る必要があると判断し、Tailwindに新たな余白を追加しました：

```javascript
// tailwind.config.ts
spacing: {
  'safe-bottom': 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
  'ios-safe-bottom': 'calc(env(safe-area-inset-bottom, 0px) + 44px)',
},
```

続いて、ブラウザ判定ユーティリティを作成：

```typescript
// utils/useBrowser.ts
const isIOSSafari = (): boolean => {
  if (typeof window === 'undefined') return false;

  const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream;
  const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);

  return isIOS && isSafari;
};

const useBrowser = () => ({
  isIOSSafari,
});

export default useBrowser;
```

そして、デバイスタイプに応じて異なるクラスをコンポーネントに適用しました：

```vue
<script setup>
import { ref, onMounted } from 'vue';
import useBrowser from '~/utils/useBrowser';

const isIOSSafariBrowser = ref(false);

onMounted(() => {
  const { isIOSSafari } = useBrowser();
  isIOSSafariBrowser.value = isIOSSafari();
});
</script>

<template>
  <div
    :class="[isIOSSafariBrowser ? 'pb-ios-safe-bottom' : 'pb-safe-bottom']"
    class="fixed bottom-0 right-0 flex w-full justify-center">
    <!-- ナビゲーションボタンの内容 -->
  </div>
</template>
```

### 3. 新たな問題：Copyrightコンテンツが隠れる

このソリューションはボタンの誤操作問題を解決しましたが、新たな問題が発生しました：セーフエリアを広げたことで、固定されたナビゲーションが下部のコピーライトを覆ってしまいました。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/242020/e3454243-be0f-4b31-baba-973cbb0d1232.png" alt="image.png" style="zoom:33%;" />

そのため、空のプレースホルダーdivを追加して調整：

```vue
<template>
  <div>
    <!-- ページコンテンツ -->
    <div class="wrapper-white mb-5 py-8">
      <!-- コンテンツ -->
    </div>

    <!-- Copyright情報 -->
    <Copyright class="mb-16" />

    <!-- プレースホルダーdivを追加 -->
    <div :class="[
      isIOSSafariBrowser
        ? 'h-[calc(env(safe-area-inset-bottom,0px)+44px)]'
        : 'h-[calc(env(safe-area-inset-bottom,0px)+16px)]'
    ]"></div>

    <!-- 下部ナビゲーションバー -->
    <div :class="[isIOSSafariBrowser ? 'pb-ios-safe-bottom' : 'pb-safe-bottom']"
         class="fixed bottom-0 right-0 flex w-full justify-center">
      <!-- ナビゲーションボタン -->
    </div>
  </div>
</template>
```

### 4. 最適化：グローバルでのデバイス判定とCSSによるスタイル制御

複数のコンポーネントで毎回デバイス判定をするのは非効率なので、Nuxtプラグインとして一度だけ判定する仕組みに変更：

```typescript
// plugins/browserDetection.client.ts
import { defineNuxtPlugin } from '#imports';

export default defineNuxtPlugin(() => {
  if (typeof window !== 'undefined') {

    const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream;
    const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);

    // iOS Safariの場合、HTML要素にdata属性を追加
    if (isIOS && isSafari) {
      document.documentElement.setAttribute('data-device', 'ios-safari');
    }
  }
});
```

さらに、グローバルCSSにて条件分岐：

```css
/* プレースホルダーdivの高さ - 通常のデバイス */
.h-safe-bottom {
  height: calc(env(safe-area-inset-bottom) + 16px);
}

/* プレースホルダーdivの高さ - iOS Safari */
html[data-device="ios-safari"] .h-safe-bottom {
  height: calc(env(safe-area-inset-bottom) + 44px);
}

/* 下部ナビゲーションバーのパディング - 通常のデバイス */
.pb-safe-bottom {
  padding-bottom: calc(env(safe-area-inset-bottom) + 16px);
}

/* 下部ナビゲーションバーのパディング - iOS Safari */
html[data-device="ios-safari"] .pb-safe-bottom {
  padding-bottom: calc(env(safe-area-inset-bottom) + 44px);
}
```

### 5. 最終形：シンプルなコンポーネントコード

これにより、コンポーネントでは判定不要となり、統一クラスを使用するだけで済むようになりました：

1. 共通の`h-safe-bottom`クラスを使用したプレースホルダーdivを追加
2. 下部ナビゲーションバーに共通の`pb-safe-bottom`クラスを追加

```vue
<template>
  <div>
    <!-- ページコンテンツ -->
    <div class="wrapper-white mb-5 py-8">
      <!-- メインコンテンツ -->
    </div>

    <!-- Copyright情報 -->
    <Copyright class="mb-16" />

    <!-- プレースホルダーdiv、固定ナビゲーションバーによるコンテンツの隠れを防止 -->
    <div class="h-safe-bottom"></div>

    <!-- 固定下部ナビゲーションバー -->
    <div class="pb-safe-bottom fixed bottom-0 right-0 flex w-full justify-center bg-white bg-opacity-50 py-2 shadow">
      <ChapterNavigationButton
        :paging="answerPaging"
        :next-chapter-navigator="nextChapterNavigator"
        :is-processing="isProcessing"
        button-style="narrow"
        @handle-to-next-question="handleToNextQuestion"
        @handle-to-next-chapter="handleToNextChapter"
        @handle-to-current-chapter="handleToCurrentChapter" />
    </div>
  </div>
</template>
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/242020/16ebedb6-114f-4f78-91bb-a75545277666.png" alt="image.png" style="zoom: 50%;" />

## 解決策のメリット

- **グローバル判定**：アプリ起動時に1回だけ実行、重複処理なし
- **スタイルとロジックの分離**：CSSにデバイス依存処理を委任
- **体験の一貫性**：全デバイスで同様のUI/UXを保証
- **保守性の向上**：余白調整も一箇所で完結

# まとめ

開発において、iOS Safariのシングルタブモードでは特に注意が必要です。この方法は、iOS Safariだけでなく、他の端末やブラウザ特有のレイアウト調整にも再利用可能な柔軟性を持っています。

# エンジニア募集中

Gakken LEAP では教育をアップデートしていきたいエンジニアを絶賛大募集しています！！
ぜひお気軽にカジュアル面談へお越しください！！

https://gakken-leap.co.jp/recruit/