Skip to content

Étape 29 — Adaptations UI mobile (safe areas, bottom nav, status bar)

Accessible à tout le monde

Cette étape modifie uniquement du code Vue/CSS — pas de build natif requis. Vous pouvez la tester d'abord dans le navigateur (npm run dev) en réduisant la fenêtre, puis dans l'émulateur Android (et iOS si vous êtes sur Mac).

Temps estimé

Environ 1h (lecture + écriture du code + tests sur émulateur).

Objectifs

Votre app fonctionne sur iOS et Android, mais l'interface ressemble encore à la version desktop : le header prend toute la largeur avec tous les liens en haut. Sur mobile, les conventions UX sont différentes :

  • Navigation principale en bas (zone du pouce) → <v-bottom-navigation>
  • Safe areas respectées (notch iPhone, home indicator) → env(safe-area-inset-*)
  • Status bar native stylée pour matcher le thème → plugin @capacitor/status-bar

Voici le résultat sur iPhone 15 Pro (à gauche) et sur émulateur Android (à droite) après tous les ajustements de cette étape :

iOS — header compact, bottom nav, safe areasAndroid — même UI, look natif
iOS finalAndroid final

Observez :

  • Le logo pokéball rouge remplace le mot "Pokedex" dans le header (gain de place + identité visuelle)
  • Le contenu ne passe pas sous le notch / Dynamic Island sur iPhone
  • La bottom navigation est en bas (zone du pouce), avec safe area home indicator
  • Le thème sombre est appliqué jusqu'à la barre de statut système

Pré-requis

  • L'étape 27 terminée (Android fonctionnel)
  • Optionnellement l'étape 28 si vous êtes sur Mac (iOS fonctionnel)

Tâches

1. Activer les safe areas dans le viewport

Sur iPhone, le notch et la Dynamic Island masquent une partie de l'écran. Pour que CSS sache où sont les zones sûres, ajoutez viewport-fit=cover dans index.html :

html
<!-- viewport-fit=cover : indispensable pour respecter les safe areas iOS
     (notch, Dynamic Island, home indicator) via env(safe-area-inset-*) -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />

Pourquoi c'est nécessaire

Sans viewport-fit=cover, les variables CSS env(safe-area-inset-top), env(safe-area-inset-bottom), etc. retournent toujours 0. Avec, elles renvoient la vraie hauteur de la zone réservée (34 px pour le home indicator, ~50 px pour le notch).

1bis. Configurer la WebView native (capacitor.config.ts + body)

Sans configuration explicite, la WebView Capacitor sur iOS affiche un fond blanc derrière le contenu (visible au chargement et dans la zone du notch). Sur Android, c'est moins visible. Deux ajustements pour avoir un rendu propre dans le thème sombre :

Capacitor : couleur de fond WebView + extension sous status bar

ts
import type { CapacitorConfig } from '@capacitor/cli'

const config: CapacitorConfig = {
  appId: 'ch.divcom.pokedex',
  appName: 'Pokédex',
  webDir: 'dist',
  // Couleur de fond de la WebView (visible derrière le contenu pendant
  // le chargement et dans la zone du notch iOS).
  // Doit matcher le fond du thème Vuetify dark pour éviter le flash blanc.
  backgroundColor: '#121212',
  ios: {
    // 'never' : la WebView s'étend SOUS la status bar (le notch).
    // C'est la WebView qui peint l'arrière-plan derrière la status bar,
    // pas iOS — ainsi le thème sombre est visible jusqu'en haut.
    contentInset: 'never',
  },
}

export default config

CSS : forcer le fond sombre sur <html> et <body>

Capacitor sur iOS dessine la zone autour du notch en se basant sur le bg du document HTML. Sans règle CSS explicite, on a un fond blanc résiduel.

Dans src/styles/settings.scss (ou n'importe quel fichier SCSS chargé globalement) :

scss
/*
Fond sombre sur <html> et <body>.

Sur Capacitor iOS, la zone du notch / Dynamic Island est dessinée par
la WebView elle-même : sans bg explicite sur <html>, iOS affiche du
blanc/gris clair par défaut, ce qui détonne avec le thème dark Vuetify.
Cette règle garantit que toute zone non couverte par un composant
(safe areas iOS, transitions de route, splash) reste sombre.
*/
html,
body {
  background-color: #121212;
}

2. Créer la bottom navigation

Créer src/components/BottomNav.vue :

vue
<template>
  <!--
  Barre de navigation inférieure : visible uniquement sur mobile (xs, sm).
    * grow étire les boutons sur toute la largeur disponible.
    * color="primary" colore l'item actif avec la couleur primaire du thème.
    * La classe .safe-bottom ajoute le padding pour respecter la zone de
      home indicator de l'iPhone via env(safe-area-inset-bottom).
  -->
  <v-bottom-navigation
    v-if="mobile"
    v-model="activeRoute"
    class="safe-bottom"
    color="primary"
    grow
  >
    <v-btn
      v-for="link in menuItems"
      :key="link.path"
      :to="link.path"
      :value="link.path"
    >
      <v-icon>{{ link.icon }}</v-icon>
      <span>{{ link.title }}</span>
    </v-btn>
  </v-bottom-navigation>
</template>

<script setup>
  import { computed } from 'vue'
  import { useRoute } from 'vue-router'
  import { useDisplay } from 'vuetify'

  // useDisplay() expose des breakpoints réactifs. `mobile` = true en dessous
  // du breakpoint md. La bottom nav ne s'affiche QUE sur petits écrans.
  const { mobile } = useDisplay()

  // Synchronise l'item actif avec la route courante.
  const route = useRoute()
  const activeRoute = computed(() => route.path)

  // 3 à 5 items max (recommandation Apple HIG et Material Design).
  const menuItems = [
    { title: 'Accueil', path: '/', icon: 'mdi-pokeball' },
    { title: 'Favoris', path: '/favoris', icon: 'mdi-heart' },
    { title: 'Kanto', path: '/kantomap', icon: 'mdi-map' },
    { title: 'FAQ', path: '/faq', icon: 'mdi-frequently-asked-questions' },
  ]
</script>

<style scoped>
.safe-bottom {
  /* env(safe-area-inset-bottom) vaut 34px sur iPhone avec home indicator,
     0 sur les autres appareils. */
  padding-bottom: env(safe-area-inset-bottom);
  height: calc(56px + env(safe-area-inset-bottom)) !important;
}
</style>

Dans src/components/AppHeader.vue, trois changements :

  1. class="app-bar-safe" sur le v-app-bar + bloc <style scoped> pour padder le notch
  2. Logo en icône MDI au lieu d'un SVG : visible, colorisable, garantie de rendu
  3. Masquer le texte "Pokedex" sur mobile (le logo fait office d'identité)
vue
<template>
  <!--
  v-app-bar avec respect des safe areas iOS (notch / Dynamic Island).
  La classe .app-bar-safe ajoute le padding-top env(safe-area-inset-top)
  pour que le contenu ne passe pas sous la barre de statut système.
  -->
  <v-app-bar flat class="app-bar-safe">
    <v-container class="d-flex align-start align-center">
      <!--
      Logo : icône pokéball Material Design (native, garantie visible
      sur fond sombre, colorisable via le thème — contrairement à un
      PNG dont la lisibilité dépend du fond).
      Taille adaptée mobile (cible 44pt Apple HIG) vs desktop (64).
      -->
      <v-btn
        class="mr-4"
        icon="mdi-pokeball"
        color="red"
        :size="mobile ? 'large' : 'x-large'"
        variant="text"
        @click="$router.push('/')"
      />

      <!--
      Titre "Pokedex" : visible uniquement sur desktop. Sur mobile,
      le logo pokéball à gauche fait office d'identité visuelle —
      ça libère de la place pour le bouton login et évite la collision
      avec le notch / Dynamic Island iOS.
      -->
      <v-toolbar-title v-if="!mobile">Pokedex</v-toolbar-title>

      <!--
      Liens de navigation DESKTOP UNIQUEMENT. Sur mobile, ils sont
      déplacés dans BottomNav.vue (zone du pouce).
      -->
      <v-btn
        v-for="link in menuItems"
        v-show="!mobile"
        :key="link.title"
        :icon="link.icon"
        :to="link.path"
      />

      <!-- Boutons login/logout conservés sur les deux plateformes -->
      <v-btn v-if="authStore.isAuthenticated" icon="mdi-logout" @click="logout" />
      <v-btn v-else icon="mdi-login" @click="$router.push('/login')" />
    </v-container>
  </v-app-bar>
</template>

<script setup>
  import { useDisplay } from 'vuetify'
  // ... imports existants

  const { mobile } = useDisplay()
  // ... reste du script
</script>

<style scoped>
/*
Respect des safe areas iOS sur la barre d'application.

Sans ce padding, le contenu du v-app-bar passe SOUS la zone du notch
ou de la Dynamic Island sur iPhone, ce qui cache une partie du logo
et rend les boutons partiellement inaccessibles.

env(safe-area-inset-top) :
  - vaut ~47px sur iPhone avec Dynamic Island
  - vaut ~44px sur iPhone avec notch (X à 14)
  - vaut 24px sur Android (status bar)
  - vaut 0 sur desktop et anciens téléphones sans encoche
*/
.app-bar-safe {
  padding-top: env(safe-area-inset-top, 0px);
  height: calc(64px + env(safe-area-inset-top, 0px)) !important;
}
</style>

Pourquoi mdi-pokeball plutôt qu'un PNG ?

Un SVG/PNG importé via image="@/assets/pokeball.svg" peut s'afficher en couleur fixe (souvent blanc/noir), ce qui le rend invisible sur fond sombre Vuetify. L'icône MDI hérite de color="red" (ou de votre thème), reste vectorielle, et fonctionne sur toutes les WebView (iOS, Android, Edge, Safari...).

Le !important sur height est nécessaire

Vuetify applique sa propre height inline (64px par défaut). Sans le !important, le padding-top s'ajoute MAIS la hauteur visible reste 64px, ce qui écrase le contenu interne. Avec !important, on garantit 64 + safe-area de hauteur réelle.

4. Intégrer BottomNav dans App.vue

Dans src/App.vue, on importe le composant et on masque le footer desktop sur mobile :

vue
<template>
  <v-app>
    <menu-principal />
    <v-main>
      <router-view />
    </v-main>

    <!-- Footer desktop : caché sur mobile pour libérer la place à la bottom nav -->
    <v-footer v-if="!mobile">
      <div class="px-4 text-center w-100">2024 - Pokedex</div>
    </v-footer>

    <!-- Navigation mobile : s'auto-masque sur desktop via useDisplay() -->
    <bottom-nav />
  </v-app>
</template>

<script setup>
  import MenuPrincipal from '@/components/AppHeader.vue'
  import BottomNav from '@/components/BottomNav.vue'
  import { useDisplay } from 'vuetify'
  // ... autres imports

  const { mobile } = useDisplay()
  // ... onMounted existant
</script>

5. Styler la status bar native

Sur les apps natives, la barre du haut (heure, batterie) peut être stylée pour matcher votre thème. Installez le plugin Capacitor :

bash
npm install @capacitor/status-bar
npx cap sync

Dans src/App.vue, dans onMounted :

js
import { Capacitor } from '@capacitor/core'
import { StatusBar, Style } from '@capacitor/status-bar'

onMounted(async () => {
  // ... votre code existant (loadToken, init store)

  // Configure la barre de statut native uniquement sur iOS/Android.
  // Style.Dark = texte clair sur fond sombre (cohérent avec notre thème).
  // L'appel est silencieux sur le web grâce au guard isNativePlatform().
  if (Capacitor.isNativePlatform()) {
    try {
      await StatusBar.setStyle({ style: Style.Dark })
      if (Capacitor.getPlatform() === 'android') {
        await StatusBar.setBackgroundColor({ color: '#1e1e1e' })
      }
    } catch (error) {
      console.warn('StatusBar non disponible :', error)
    }
  }
})

Pourquoi Capacitor.isNativePlatform() ?

Votre code Vue tourne aussi sur le web (pendant npm run dev). Le plugin StatusBar n'existe pas dans le navigateur. Sans le guard, vous obtiendriez une erreur en console à chaque lancement web.

6. Rebuild et tester

bash
# Le plus rapide pour itérer sur le CSS / la bottom nav
npm run dev
# → ouvrir http://localhost:3000 et réduire la fenêtre sous 960px
bash
npm run build && npx cap sync ios
npx cap open ios   # puis Cmd + R dans Xcode
bash
npm run build && npx cap sync android
cd android && ./gradlew assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk
powershell
npm run build
npx cap sync android
cd android
.\gradlew.bat assembleDebug
adb install -r app\build\outputs\apk\debug\app-debug.apk

7. FAB mobile pour les actions principales (zone du pouce)

Sur mobile, les actions importantes (ajouter, éditer, créer) ne doivent pas être en haut de l'écran — la zone du pouce est en bas. Le pattern standard Material Design = FAB (Floating Action Button) flottant en bas-droite.

Dans src/pages/index.vue, le bouton "+" pour ajouter un Pokémon est désormais conditionnel :

vue
<template>
  <v-container>
    <h1 class="mb-6 text-center">
      Pokédex
      <!--
      Sur mobile, ce bouton est remplacé par un FAB en bas-droite.
      -->
      <v-btn
        v-if="!mobile"
        v-tooltip.bottom="'Ajouter un Pokémon'"
        aria-label="Ajouter un Pokémon"
        class="ml-4"
        color="primary"
        icon="mdi-plus"
        @click="$router.push('pokemons/create')"
      />
    </h1>

    <!-- ... grille des Pokémon ... -->

    <!--
    FAB mobile — visible uniquement sur mobile, position fixée en bas-droite.
    bottom = hauteur bottom nav + safe-area home indicator + marge.
    -->
    <v-btn
      v-if="mobile"
      aria-label="Ajouter un Pokémon"
      class="add-pokemon-fab"
      color="primary"
      icon="mdi-plus"
      size="large"
      @click="$router.push('pokemons/create')"
    />
  </v-container>
</template>

<script setup>
  import { useDisplay } from 'vuetify'
  const { mobile } = useDisplay()
  // ... reste du script
</script>

<style scoped>
.add-pokemon-fab {
  position: fixed;
  bottom: calc(56px + env(safe-area-inset-bottom, 0px) + 16px);
  right: 16px;
  z-index: 4;
}
</style>

Pourquoi en bas-droite ?

  • Zone du pouce : sur mobile, l'utilisateur tient le téléphone d'une main et atteint naturellement le bas-droite (pour droitiers — sur iOS le système supporte une option "miroir" pour gauchers).
  • Convention universelle : Gmail, Google Maps, WhatsApp, Twitter — tous mettent leur action principale en bas-droite.
  • Découvrabilité : la couleur primary contraste avec le fond et attire l'œil sans surcharger le contenu.

Pas trop de FAB

Un seul FAB par écran. C'est l'action principale. Si vous en mettez plusieurs, le concept perd son sens. Les actions secondaires vont dans le header, dans un menu (v-menu), ou dans la bottom nav.

Résultat attendu

iOS — header avec pokéball, safe area top respectée, bottom nav

iOS — header pokéball + bottom nav

Points à vérifier :

  • Status bar iOS (heure, Dynamic Island, wifi, batterie) visible et non chevauchée par le contenu
  • Pokéball rouge à gauche du header (à la place du mot "Pokedex")
  • Bouton login à côté de la pokéball (action critique conservée)
  • Bottom nav en bas : 4 onglets (Accueil, Favoris, Kanto, FAQ)
  • Home indicator (barre fine en bas) reste accessible — safe-area-inset-bottom respectée

Android — même UI cross-platform

Android — header pokéball + bottom nav

Android — page Kanto via bottom nav

Tests à effectuer

  • Sur mobile : header simplifié, liens disparus
  • Sur mobile : bottom nav visible avec 4 onglets
  • Cliquer sur chaque onglet navigue vers la bonne page
  • L'onglet actif est mis en évidence (couleur primary)
  • Sur iPhone : pas de chevauchement avec le notch ni le home indicator
  • Sur web (responsive desktop) : pas de bottom nav, header complet
  • Status bar native cohérente avec le thème sombre

Pièges connus

useDisplay() ne suffit pas pour distinguer mobile natif vs web responsive

useDisplay().mobile = true aussi quand on réduit la fenêtre desktop sous 960px. Pour détecter une vraie app native (Capacitor), utilisez Capacitor.isNativePlatform() — c'est ce qu'on a fait pour la status bar.

Le hot reload Vite ne marche pas dans Capacitor

Capacitor charge le bundle dist/ figé. À chaque modification de code Vue, il faut rebuilder :

bash
npm run build && npx cap copy

Pour développer plus vite, utilisez Capacitor en mode livereload : npx cap run ios --livereload --external (voir doc officielle).

Commit final

bash
git add -A
git commit -m "feat(mobile): bottom nav + safe areas iOS + status bar native"

Pour aller plus loin

  • Icônes d'app et splash screen personnalisés : @capacitor/assets génère toutes les tailles depuis un seul fichier source
  • Plugins natifs : @capacitor/camera, @capacitor/geolocation, @capacitor/share (API REST natives)
  • Push notifications : @capacitor/push-notifications + Firebase
  • Publication App Store / Play Store : configurer le signing, créer les artefacts release (.ipa et .aab), comptes développeur payants (99 USD/an Apple, 25 USD une fois Google)

Documentation pour les cours de développement web