Jetpack Compose で WebView を使用するには、AndroidView でラップする必要があります。このガイドでは、一般的なユースケースと、Compose でそれらをサポートする方法について説明します。
WebView を AndroidView でラップする
Compose で WebView を使用するには、AndroidView でラップします。
@Composable fun SimpleWebView( initialUrl: String, modifier: Modifier = Modifier ) { AndroidView( modifier = modifier.fillMaxSize(), factory = { context -> WebView(context).apply { webViewClient = WebViewClient() settings.javaScriptEnabled = true loadUrl(initialUrl) } } ) }
これは、アプリ内で単純な URL を表示する場合には有効です。ただし、WebView は、Android View ライフサイクルや Compose ライフサイクルとは別の複雑な状態ライフサイクルを処理します。Compose を統合すると、複雑な WebView シナリオが発生し、難しいバグが発生する可能性があります。以降のセクションでは、これらの機能をサポートするために特別な処理が必要になる可能性があるユースケースについて説明します。
WebView の状態を保持する
Compose での構成変更とナビゲーションの処理は、WebView がホスト Activity にバインドされたレガシー View であり、そのインスタンスが Activity のライフサイクルを超えて存続することは推奨されないため、難しいものとなっています。
したがって、WebView の状態を永続化する標準的な方法は、WebView インスタンスを Activity とともに破棄して再作成できるようにすることです。Bundle を使用して、内部ナビゲーション履歴とスクロール状態を手動で永続化できます。
@Composable fun PersistentWebView(url: String) { val webViewStateBundle = rememberSaveable { Bundle() } AndroidView( factory = { context -> WebView(context).apply { webViewClient = WebViewClient() settings.javaScriptEnabled = true // Restore the state and history if (webViewStateBundle.containsKey("WEBVIEW_STATE")) { restoreState(webViewStateBundle.getBundle("WEBVIEW_STATE")!!) } else { loadUrl(url) } } }, onRelease = { releasedWebView -> // Save navigation history before the instance is destroyed val bundle = Bundle() releasedWebView.saveState(bundle) webViewStateBundle.putBundle("WEBVIEW_STATE", bundle) }, modifier = Modifier.fillMaxSize() ) }
「戻る」ナビゲーションを処理する
WebView にナビゲーション履歴がある場合、システム「戻る」ジェスチャーは画面を終了するのではなく、WebView 内を戻るようにナビゲートします。
Compose の BackHandler API を使用してシステムの [戻る] イベントをインターセプトし、WebView goBack() 関数を呼び出します。
// ... @Composable fun BackNavigationDemoScreen(onBack: () -> Unit) { // Hold a reference to the WebView to check its history state var webViewReference by remember { mutableStateOf<WebView?>(null) } // Intercept the system back press if the WebView has history BackHandler(enabled = true) { val webView = webViewReference if (webView != null && webView.canGoBack()) { webView.goBack() // Go back in history } else { onBack() // Exit screen } } Scaffold( topBar = { TopAppBar( title = { Text("Back Navigation Demo") }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } } ) } ) { padding -> Column(modifier = Modifier.fillMaxSize().padding(padding)) { AndroidView( modifier = Modifier.fillMaxSize(), factory = { context -> WebView(context).apply { settings.javaScriptEnabled = true // Keeps link navigations internal to the WebView instead of opening Chrome webViewClient = WebViewClient() loadUrl("https://developer.android.com") webViewReference = this } }, onRelease = { webViewReference = null } ) } } }
この実装により、ブラウザ スタイルのナビゲーション動作が提供されます。
ネストされたスクロール
Compose で WebView を使用する場合、ネストされたスクロールは簡単にサポートされません。LazyColumn などのスクロール可能な Compose コンテナ内に WebView を配置すると、WebView がすべてのスクロール ジェスチャーを消費する可能性があります。WebView は独自の内部レンダリング エンジンに依存しているため、LazyColumn とのネストは現在正しく機能しません。
WebView のネストされたスクロールの公式サポートの進捗状況を追跡するには、こちらの問題を参照してください。
エッジ ツー エッジ レイアウトとウィンドウ インセット
エッジ ツー エッジ レイアウトを使用する場合、WebView コンテンツはステータスバーなどのシステムバーの下に表示されることがあります。windowInsetsPadding 修飾子を使用すると、WebView 全体をセーフエリアにプッシュできます。
@Composable fun EdgeToEdgeDemo(url: String) { AndroidView( modifier = Modifier .fillMaxSize() .windowInsetsPadding(WindowInsets.systemBars), factory = { context -> WebView(context).apply { loadUrl(url) } } ) }
インセットの詳細については、WebView でのウィンドウ インセットについてをご覧ください。
アプリのテーマを WebView のコンテンツと同期する
アプリケーションがライトモードとダークモードを切り替えるとき、WebView コンテンツは正しく処理されていれば、ページを再読み込みすることなく自動的に更新できます。
ウェブページのコンテンツを所有している場合は、アプリのテーマと色を同期させるために、メディアクエリ prefers-color-scheme を処理して、選択したテーマにウェブページが適応するようにします。
ドロップダウンやポップアップなどのネイティブ要素がアプリのテーマを検出して一致させることができるようにするには、Activity. に DayNight スタイル テーマを適用します。
<resources> <!-- ... <!-- Use a DayNight theme in your manifest to handle both modes automatically --> <style name="Theme.Webviewdemo.DayNight" parent="Theme.AppCompat.DayNight.NoActionBar" /> </resources>
@Composable fun ThemeSyncDemo(onBack: () -> Unit) { val context = LocalContext.current AndroidView( modifier = Modifier.fillMaxSize(), factory = { _ -> WebView(context).apply { settings.javaScriptEnabled = true webViewClient = WebViewClient() val html = """ <html> <head> // ... @media (prefers-color-scheme: dark) { body { background-color: #212121; color: #ffffff; } select { border-color: #BB86FC; background: #212121; color: #ffffff; } } </style> </head> // ... </html> """.trimIndent() loadDataWithBaseURL(null, html, "text/html", "UTF-8", null) } } ) }
ウェブページにダークモードがない場合や、ウェブ コンテンツを所有していない場合は、アルゴリズムによるダークニングでダークモードを強制的に適用できることがあります。すでにダークモードが実装されている最新のウェブサイトでは、このアルゴリズムは無視され、代わりに独自の組み込みスタイルが使用されます。
Compose でウェブ権限を処理する
ウェブページがハードウェアまたはデータアクセス(カメラ、マイク、位置情報など)をリクエストすると、WebView は WebChromeClient 内の特定のコールバックをトリガーします。これらのコールバックを処理し、対応する Android ランタイム権限が付与されていることを確認する必要があります。
カメラとマイクの権限を処理する
ウェブページがカメラまたはマイクへのアクセスをリクエストすると(WebRTC や動画録画など)、WebView が WebChromeClient.onPermissionRequest を呼び出します。
ただし、grant() を呼び出す前に、次の Android ランタイム権限をリクエストする必要があります。
Manifest.permission.CAMERAManifest.permission.RECORD_AUDIO
まず、WebView からリクエストされた PermissionRequest を追跡する WebView の権限ハンドラを定義します。
class WebViewPermissionHandler( private val launcher: ManagedActivityResultLauncher<Array<String>, Map<String, Boolean>> ) { var pendingRequest by mutableStateOf<PermissionRequest?>(null) private set fun handleRequest(request: PermissionRequest) { val isTrustedOrigin = request.origin.host == "www.trusted-domain.com" || request.origin.host == "app.local" // Always verify the origin before granting request if (!isTrustedOrigin) { Log.w("WebViewPermission", "Blocked and denied permission request from untrusted origin: ${request.origin.host}") request.deny() return } val androidPermissions = mutableListOf<String>() request.resources.forEach { resource -> when (resource) { PermissionRequest.RESOURCE_VIDEO_CAPTURE -> androidPermissions.add(Manifest.permission.CAMERA) PermissionRequest.RESOURCE_AUDIO_CAPTURE -> androidPermissions.add(Manifest.permission.RECORD_AUDIO) } } // Save the request and launch the Android system dialog pendingRequest = request launcher.launch(androidPermissions.toTypedArray()) } fun onResult(results: Map<String, Boolean>) { val allGranted = results.values.all { it } Log.d("WebViewPermission", "Kotlin: All permissions granted? $allGranted") if (allGranted) { pendingRequest?.grant(arrayOf("/* list of permissions */")) } else { pendingRequest?.deny() } pendingRequest = null } }
次に、WebViewPermissionHandler を記憶するコンポーザブルを作成します。rememberLauncherForActivityResult を使用して権限をリクエストします。
@Composable fun rememberWebViewPermissionHandler(): WebViewPermissionHandler { val handlerState = remember { mutableStateOf<WebViewPermissionHandler?>(null) } val launcher = rememberLauncherForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { results -> handlerState.value?.onResult(results) } return remember { WebViewPermissionHandler(launcher).also { handlerState.value = it } } }
onPermissionRequest コールバックから権限を処理します。これにより、権限ランチャーが起動します。
@Composable fun WebViewPermissionScreen() { val permissionHandler = rememberWebViewPermissionHandler() AndroidView( factory = { context -> WebView(context).apply { settings.javaScriptEnabled = true webChromeClient = object : WebChromeClient() { override fun onPermissionRequest(request: PermissionRequest) { // Simply delegate to the handler permissionHandler.handleRequest(request) } } // load a web page that needs permissions } }, modifier = Modifier.fillMaxSize() ) }
埋め込み WebView の代替手段
WebView の埋め込みを避けたい場合は、Android には Chrome カスタムタブなど、ウェブ コンテンツを表示するための他のオプションが用意されています。ユースケース(ブラウジングや認証など)に適したアプローチを選択する方法については、Android アプリ内でウェブ コンテンツを使用するをご覧ください。