Compose에서 WebView 래핑

Jetpack Compose에서 WebView를 사용하려면 AndroidView로 래핑해야 합니다. 이 가이드에서는 일반적인 사용 사례와 Compose에서 이를 지원하는 방법을 설명합니다.

AndroidView로 WebView 래핑

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 뷰 수명 주기 및 Compose 수명 주기와 별개인 복잡한 상태 수명 주기를 처리합니다. Compose를 통합하면 어려운 버그를 유발하는 복잡한 WebView 시나리오가 발생할 수 있습니다. 다음 섹션에서는 이러한 기능을 지원하기 위해 특별한 처리가 필요할 수 있는 사용 사례를 설명합니다.

WebView 상태 유지

WebView는 호스트 Activity에 바인딩된 레거시 View이므로 Compose에서 구성 변경 및 탐색을 처리하는 것은 어렵습니다. 또한 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에서 웹 권한 처리

웹페이지에서 하드웨어 또는 데이터 액세스 (예: 카메라, 마이크 또는 위치)를 요청하면 WebViewWebChromeClient에서 특정 콜백을 트리거합니다. 이러한 콜백을 처리하고 상응하는 Android 런타임 권한이 부여되었는지 확인해야 합니다.

카메라 및 마이크 권한 처리

웹페이지에서 카메라 또는 마이크 액세스 (예: WebRTC 또는 동영상 녹화)를 요청하면 WebViewWebChromeClient.onPermissionRequest를 호출합니다.

하지만 grant()를 호출하기 전에 다음 Android 런타임 권한을 요청해야 합니다.

  • Manifest.permission.CAMERA
  • Manifest.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 앱 내에서 웹 콘텐츠 사용을 참고하세요.