Safe Area Handling

Cap-go/capgo-skills/plugins/capacitor-ui/skills/safe-area-handling

作者 Cap-go2cfb277a148832bdb1a835b01e4de323687b498b無授權條款72 個星標收錄於 2026年10月9日更新於 2026年10月9日儲存庫今天更新

Complete guide to handling safe areas in Capacitor apps for iPhone notch, Dynamic Island, home indicator, and Android cutouts. Covers CSS, JavaScript, and native solutions. Use this skill when users have layout issues on modern devices.

AI 產生的概覽

指導處理 Capacitor 應用程式中的安全區域,涵蓋 iPhone 瀏海、動態島、Home 指示列與 Android 挖孔。

功能
提供一份參考指南,用於修復 Capacitor 應用程式中因瀏海、動態島、Home 指示列與 Android 螢幕挖孔所造成的版面問題。內容涵蓋 CSS 環境變數與 viewport-fit、用於讀取內距的 JavaScript 與框架鉤子、iOS 與 Android 原生設定、Capacitor Status Bar 外掛,以及常見問題與解決方式。也包含測試與除錯視覺化方面的指引。
適用情境
當使用者回報在瀏海或挖孔裝置上出現版面問題、內容被瀏海或 Home 指示列遮住,或需要全螢幕與邊到邊版面時使用。
執行需求
不包含指令碼,僅為說明性指引。套用這些指引需要具備 Capacitor 應用程式專案及 Web、iOS 和/或 Android 程式碼,並可選用 @capacitor/status-bar 與 @capacitor/keyboard 套件。

Safe Area Handling in Capacitor

Handle iPhone notch, Dynamic Island, home indicator, and Android cutouts properly.

When to Use This Skill

  • User has layout issues on notched devices
  • User asks about safe areas
  • User sees content under the notch
  • User needs fullscreen layout
  • Content is hidden by home indicator

Understanding Safe Areas

What Are Safe Areas?

Safe areas are the regions of the screen not obscured by:

  • iPhone: Notch, Dynamic Island, home indicator, rounded corners
  • Android: Camera cutouts, navigation gestures, display cutouts

Safe Area Insets

InsetDescription
safe-area-inset-topNotch/Dynamic Island/status bar
safe-area-inset-bottomHome indicator/navigation bar
safe-area-inset-leftLeft edge (landscape)
safe-area-inset-rightRight edge (landscape)

CSS Solution

Enable Viewport Coverage

html
<!-- index.html --><meta  name="viewport"  content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>

Important: viewport-fit=cover is required to access safe area insets.

Using CSS Environment Variables

css
/* Basic usage */.header {  padding-top: env(safe-area-inset-top);}
.footer {  padding-bottom: env(safe-area-inset-bottom);}
/* With fallback */.header {  padding-top: env(safe-area-inset-top, 20px);}
/* Combined with other padding */.content {  padding-top: calc(env(safe-area-inset-top) + 16px);  padding-bottom: calc(env(safe-area-inset-bottom) + 16px);}

Full Page Layout

css
/* App container */.app {  position: fixed;  top: 0;  left: 0;  right: 0;  bottom: 0;  display: flex;  flex-direction: column;}
/* Header respects notch */.header {  padding-top: env(safe-area-inset-top);  padding-left: env(safe-area-inset-left);  padding-right: env(safe-area-inset-right);  background: #fff;}
/* Scrollable content */.content {  flex: 1;  overflow-y: auto;  -webkit-overflow-scrolling: touch;  padding-left: env(safe-area-inset-left);  padding-right: env(safe-area-inset-right);}
/* Footer respects home indicator */.footer {  padding-bottom: env(safe-area-inset-bottom);  padding-left: env(safe-area-inset-left);  padding-right: env(safe-area-inset-right);  background: #fff;}

Tab Bar with Safe Area

css
.tab-bar {  position: fixed;  bottom: 0;  left: 0;  right: 0;  display: flex;  background: #fff;  border-top: 1px solid #eee;
  /* Add padding for home indicator */  padding-bottom: env(safe-area-inset-bottom);}
.tab-bar-item {  flex: 1;  display: flex;  flex-direction: column;  align-items: center;  padding: 8px 0;  min-height: 49px; /* iOS standard height */}

Full-Bleed Background with Safe Content

css
.hero {  /* Background extends to edges */  background: linear-gradient(to bottom, #4f46e5, #7c3aed);  padding-top: calc(env(safe-area-inset-top) + 20px);  padding-left: env(safe-area-inset-left);  padding-right: env(safe-area-inset-right);}
.hero-content {  /* Content stays in safe area */  max-width: 100%;}

JavaScript Solution

Reading Safe Area Values

typescript
function getSafeAreaInsets() {  const computedStyle = getComputedStyle(document.documentElement);
  return {    top: parseInt(computedStyle.getPropertyValue('--sat') || '0'),    bottom: parseInt(computedStyle.getPropertyValue('--sab') || '0'),    left: parseInt(computedStyle.getPropertyValue('--sal') || '0'),    right: parseInt(computedStyle.getPropertyValue('--sar') || '0'),  };}
// Set CSS custom propertiesfunction setSafeAreaProperties() {  const style = document.documentElement.style;
  // Create temporary element to read values  const temp = document.createElement('div');  temp.style.paddingTop = 'env(safe-area-inset-top)';  temp.style.paddingBottom = 'env(safe-area-inset-bottom)';  temp.style.paddingLeft = 'env(safe-area-inset-left)';  temp.style.paddingRight = 'env(safe-area-inset-right)';  document.body.appendChild(temp);
  const computed = getComputedStyle(temp);  style.setProperty('--sat', computed.paddingTop);  style.setProperty('--sab', computed.paddingBottom);  style.setProperty('--sal', computed.paddingLeft);  style.setProperty('--sar', computed.paddingRight);
  document.body.removeChild(temp);}
// Update on orientation changewindow.addEventListener('orientationchange', () => {  setTimeout(setSafeAreaProperties, 100);});

React Hook

typescript
import { useState, useEffect } from 'react';
interface SafeAreaInsets {  top: number;  bottom: number;  left: number;  right: number;}
function useSafeArea(): SafeAreaInsets {  const [insets, setInsets] = useState<SafeAreaInsets>({    top: 0,    bottom: 0,    left: 0,    right: 0,  });
  useEffect(() => {    function updateInsets() {      const temp = document.createElement('div');      temp.style.cssText = `        position: fixed;        top: 0;        padding-top: env(safe-area-inset-top);        padding-bottom: env(safe-area-inset-bottom);        padding-left: env(safe-area-inset-left);        padding-right: env(safe-area-inset-right);      `;      document.body.appendChild(temp);
      const computed = getComputedStyle(temp);      setInsets({        top: parseFloat(computed.paddingTop) || 0,        bottom: parseFloat(computed.paddingBottom) || 0,        left: parseFloat(computed.paddingLeft) || 0,        right: parseFloat(computed.paddingRight) || 0,      });
      document.body.removeChild(temp);    }
    const handleOrientationChange = () => {      setTimeout(updateInsets, 100);    };
    updateInsets();    window.addEventListener('resize', updateInsets);    window.addEventListener('orientationchange', handleOrientationChange);
    return () => {      window.removeEventListener('resize', updateInsets);      window.removeEventListener('orientationchange', handleOrientationChange);    };  }, []);
  return insets;}
// Usagefunction Header() {  const { top } = useSafeArea();
  return (    <header style={{ paddingTop: top }}>      App Header    </header>  );}

Vue Composable

typescript
import { ref, onMounted, onUnmounted } from 'vue';
export function useSafeArea() {  const insets = ref({    top: 0,    bottom: 0,    left: 0,    right: 0,  });
  function updateInsets() {    const temp = document.createElement('div');    temp.style.cssText = `      position: fixed;      padding-top: env(safe-area-inset-top);      padding-bottom: env(safe-area-inset-bottom);      padding-left: env(safe-area-inset-left);      padding-right: env(safe-area-inset-right);    `;    document.body.appendChild(temp);
    const computed = getComputedStyle(temp);    insets.value = {      top: parseFloat(computed.paddingTop) || 0,      bottom: parseFloat(computed.paddingBottom) || 0,      left: parseFloat(computed.paddingLeft) || 0,      right: parseFloat(computed.paddingRight) || 0,    };
    document.body.removeChild(temp);  }
  onMounted(() => {    updateInsets();    window.addEventListener('resize', updateInsets);  });
  onUnmounted(() => {    window.removeEventListener('resize', updateInsets);  });
  return insets;}

Native iOS Configuration

Status Bar Style

typescript
// capacitor.config.tsimport type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {  ios: {    // Content extends behind status bar    contentInset: 'automatic', // or 'always', 'scrollableAxes', 'never'  },};

Extend Behind Safe Areas

swift
// ios/App/App/AppDelegate.swiftimport UIKitimport Capacitor
@UIApplicationMainclass AppDelegate: UIResponder, UIApplicationDelegate {    func application(        _ application: UIApplication,        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?    ) -> Bool {        // Extend content to edges        if let window = UIApplication.shared.windows.first {            window.backgroundColor = .clear        }        return true    }}

Info.plist Settings

xml
<!-- ios/App/App/Info.plist --><!-- Allow full screen content --><key>UIViewControllerBasedStatusBarAppearance</key><true/>
<!-- For landscape support --><key>UISupportedInterfaceOrientations</key><array>    <string>UIInterfaceOrientationPortrait</string>    <string>UIInterfaceOrientationLandscapeLeft</string>    <string>UIInterfaceOrientationLandscapeRight</string></array>

Native Android Configuration

Display Cutout Mode

xml
<!-- android/app/src/main/res/values-v28/styles.xml --><resources>    <style name="AppTheme" parent="Theme.AppCompat.NoActionBar">        <!-- Extend content into cutout area -->        <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>    </style></resources>

Edge-to-Edge Display

kotlin
// android/app/src/main/java/.../MainActivity.ktimport android.os.Buildimport android.view.Viewimport android.view.WindowInsetsimport android.view.WindowInsetsController
class MainActivity : BridgeActivity() {    override fun onCreate(savedInstanceState: Bundle?) {        super.onCreate(savedInstanceState)
        // Enable edge-to-edge        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {            window.setDecorFitsSystemWindows(false)        } else {            @Suppress("DEPRECATION")            window.decorView.systemUiVisibility = (                View.SYSTEM_UI_FLAG_LAYOUT_STABLE or                View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or                View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN            )        }    }}

AndroidManifest Configuration

xml
<!-- android/app/src/main/AndroidManifest.xml --><activity    android:name=".MainActivity"    android:theme="@style/AppTheme"    android:windowSoftInputMode="adjustResize"    android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"></activity>

Capacitor Status Bar Plugin

Installation

bash
npm install @capacitor/status-barnpx cap sync

Usage

typescript
import { StatusBar, Style } from '@capacitor/status-bar';
// Set status bar styleawait StatusBar.setStyle({ style: Style.Dark });
// Set background color (Android)await StatusBar.setBackgroundColor({ color: '#ffffff' });
// Show/hide status barawait StatusBar.hide();await StatusBar.show();
// Overlay modeawait StatusBar.setOverlaysWebView({ overlay: true });

Common Issues and Solutions

Issue: Content Behind Notch

Solution: Add viewport-fit and safe area padding

html
<meta name="viewport" content="viewport-fit=cover">
css
body {  padding-top: env(safe-area-inset-top);}

Issue: Tab Bar Under Home Indicator

Solution: Add bottom safe area padding

css
.tab-bar {  padding-bottom: env(safe-area-inset-bottom);}

Issue: Landscape Layout Broken

Solution: Handle left/right insets

css
.content {  padding-left: env(safe-area-inset-left);  padding-right: env(safe-area-inset-right);}

Issue: Keyboard Pushes Content

Solution: Use adjustResize and handle insets dynamically

typescript
import { Keyboard } from '@capacitor/keyboard';
Keyboard.addListener('keyboardWillShow', (info) => {  document.body.style.paddingBottom = `${info.keyboardHeight}px`;});
Keyboard.addListener('keyboardWillHide', () => {  document.body.style.paddingBottom = 'env(safe-area-inset-bottom)';});

Issue: Safe Areas Not Working in WebView

Cause: Missing viewport-fit=cover

Solution:

html
<!-- Must be exactly like this --><meta  name="viewport"  content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>

Testing Safe Areas

iOS Simulator

  1. Use iPhone with notch (iPhone 14 Pro, etc.)
  2. Test both portrait and landscape
  3. Test with keyboard visible

Android Emulator

  1. Create emulator with camera cutout
  2. Test navigation gesture mode
  3. Test 3-button navigation mode

Preview Different Devices

css
/* Debug mode - visualize safe areas */.debug-safe-areas::before {  content: '';  position: fixed;  top: 0;  left: 0;  right: 0;  height: env(safe-area-inset-top);  background: rgba(255, 0, 0, 0.3);  z-index: 9999;  pointer-events: none;}
.debug-safe-areas::after {  content: '';  position: fixed;  bottom: 0;  left: 0;  right: 0;  height: env(safe-area-inset-bottom);  background: rgba(0, 0, 255, 0.3);  z-index: 9999;  pointer-events: none;}

Resources

來源與署名

來源:Cap-go/capgo-skills位於plugins/capacitor-ui/skills/safe-area-handling提交2cfb277

授權條款: 無授權條款

內容歸原作者所有。SourceWeft 從公開儲存庫中收錄這些內容。

檢舉或申請下架