עטיפת WebView ב-Compose

כדי להשתמש ב-WebView ב-Jetpack Compose, צריך לעטוף אותו ב-AndroidView. במדריך הזה מוסבר על תרחישים נפוצים לדוגמה ואיך לתמוך בהם ב-Compose.

הוספת WebView ל-AndroidView

כדי להשתמש ב-WebView ב-Compose, צריך לעטוף אותו ב-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 הוא View מדור קודם שקשור למארח שלו Activity, ולא מומלץ שהמופע שלו ימשיך להתקיים אחרי מחזור החיים של 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 במקום לצאת מהמסך.

משתמשים ב-API של Compose BackHandler כדי ליירט את אירוע החזרה של המערכת, וקוראים לפונקציה 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
                }
            )
        }
    }
}

ההטמעה הזו מספקת התנהגות ניווט בסגנון דפדפן.

גלילה בתוך גלילה

גלילה בתצוגות נפרדות לא נתמכת בקלות כשמשתמשים ב-WebView ב-Compose. כשממקמים WebView בתוך מאגר של Compose שאפשר לגלול בו, כמו LazyColumn, יכול להיות שכל תנועות הגלילה יפעילו את ה-WebView. מכיוון ש-WebView מסתמך על מנוע רינדור פנימי משלו, הטמעה שלו בתוך LazyColumn לא פועלת כרגע כמו שצריך.

כדי לעקוב אחרי ההתקדמות של התמיכה הרשמית בגלילה בתצוגה משורשרת ב-WebView, אפשר לעיין בבעיה הזו.

פריסות מקצה לקצה וחלונות פנימיים

כשמשתמשים בפריסות מקצה לקצה, יכול להיות שתוכן של WebView יופיע מתחת לסרגלי מידע כמו שורת הסטטוס. אפשר להשתמש ב-modifier‏ 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 כדי לוודא שדף האינטרנט מותאם לעיצוב שנבחר.

כדי שרכיבים מקומיים כמו תפריטים נפתחים וחלונות קופצים יזהו את העיצוב של האפליקציה ויתאימו לו, צריך להחיל עיצוב בסגנון DayNight על Activity.

<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 מפעיל קריאות חוזרות (callbacks) ספציפיות ב-WebChromeClient שלו. אתם צריכים לטפל בקריאות החוזרות האלה ולוודא שניתנו הרשאות מתאימות בתחילת ההפעלה של Android.

איך מטפלים בהרשאות גישה למצלמה ולמיקרופון

כשדף אינטרנט מבקש גישה למצלמה או למיקרופון (לדוגמה, WebRTC או הקלטת וידאו), WebView קורא ל-WebChromeClient.onPermissionRequest.

עם זאת, לפני שקוראים ל-grant(), צריך לבקש את ההרשאות הבאות בתחילת ההפעלה של Android:

  • Manifest.permission.CAMERA
  • Manifest.permission.RECORD_AUDIO

קודם כול, מגדירים handler של הרשאות ל-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 }
    }
}

מטפלים בהרשאה מהקריאה החוזרת (callback) של 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 מוסבר איך לבחור את הגישה הנכונה לתרחישים לדוגמה (כמו גלישה או אימות).

מקורות מידע נוספים