KevinnZou / compose-webview-multiplatform

WebView for JetBrains Compose Multiplatform
https://kevinnzou.github.io/compose-webview-multiplatform/
Apache License 2.0
383 stars 47 forks source link

Flickering effect when showing new Window on top of WebView #72

Closed tomastiminskas closed 6 months ago

tomastiminskas commented 7 months ago

I was able to load a url in the webview (inside a Window) and implement js bridge using the SNAPSHOT version. Now I'm facing a different issue. When I got a message from the web app I need to show a popup view with a button for the user to authorize some actions.

I first tried showing the AUTHORIZE view on top of the webview in the same window, and then I tried showing it in a new window. On both cases the webview starts to flicker as shown in the video.

Any ideas what might be causing this?

https://github.com/KevinnZou/compose-webview-multiplatform/assets/2242281/8bc5d2ad-3915-449d-83e0-f11ed30f119b

KevinnZou commented 7 months ago

@tomastiminskas Hi, thanks for your feedback! Can you please provide a minimal code example that can reproduce the issue?

tomastiminskas commented 7 months ago

@KevinnZou thanks for the quick response.

Here you can find the code I was trying: https://github.com/stakwork/sphinx-kotlin-ui/blob/tt/feature/js-bridge/common/src/desktopMain/kotlin/chat/sphinx/common/components/WebAppUI.kt

In the fun WebAppUI() I'm initializing and showing the webview which loads without issues. If after a few seconds I run the code on fun AuthorizeViewUI() in the same file, then the new window will show over the webview window and the webview will start to flicker as mentioned in the issue.

Let me know if you need any additional information, thanks in advance

KevinnZou commented 7 months ago

@tomastiminskas Thanks for your information!

Are you using the Window API from compose.desktop.components.splitPane? Could you please just provide a simple code block that doesn't depend on other libraries and can directly reproduce the problem you are facing?

tomastiminskas commented 6 months ago

@KevinnZou Thanks again for the response. I will work on an example and provide a code block for you as soon as possible

tomastiminskas commented 6 months ago

@KevinnZou here is a sample of code to reproduce the issue

Main.kt

import androidx.compose.runtime.*
import androidx.compose.ui.window.application
import chat.sphinx.common.components.AuthorizeViewUI
import chat.sphinx.common.components.WebAppUI
import chat.sphinx.common.state.AuthorizeViewState
import chat.sphinx.common.viewmodel.WebAppViewModel
import dev.datlag.kcef.KCEF
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File

fun main() = application {

    var downloadProgress by remember { mutableStateOf(-1F) }
    var initialized by remember { mutableStateOf(false) } // if true, KCEF can be used to create clients, browsers etc
    val bundleLocation = System.getProperty("compose.application.resources.dir")?.let { File(it) } ?: File(".")

    LaunchedEffect(Unit) {
        withContext(Dispatchers.IO) { // IO scope recommended but not required
            KCEF.init(
                builder = {
                    installDir(File(bundleLocation, "kcef-bundle")) // recommended, but not necessary

                    progress {
                        onDownloading {
                            downloadProgress = it
                            // use this if you want to display a download progress for example
                        }
                        onInitialized {
                            initialized = true
                        }
                    }
                },
                onError = {
                    println("Error ${it?.localizedMessage ?: ""}")
                    // error during initialization
                },
                onRestartRequired = {
                    // all required CEF packages downloaded but the application needs a restart to load them (unlikely to happen)
                }
            )
        }
    }

    val webAppViewModel = remember { WebAppViewModel() }
    WebAppUI(webAppViewModel)

    val authorizeView by webAppViewModel.authorizeViewStateFlow.collectAsState()
    (authorizeView as? AuthorizeViewState.Opened)?.let {
        AuthorizeViewUI(webAppViewModel, it.budgetField)
    }
}

WebAppViewModel.kt

package chat.sphinx.common.viewmodel

import chat.sphinx.common.state.AuthorizeViewState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch

class WebAppViewModel {

    private val _webViewStateFlow: MutableStateFlow<String?> by lazy {
        MutableStateFlow(null)
    }

    private val _authorizeViewStateFlow: MutableStateFlow<AuthorizeViewState> by lazy {
        MutableStateFlow(AuthorizeViewState.Closed())
    }

    val webViewStateFlow: StateFlow<String?>
        get() = _webViewStateFlow.asStateFlow()

    val authorizeViewStateFlow: StateFlow<AuthorizeViewState>
        get() = _authorizeViewStateFlow.asStateFlow()

    fun toggleWebAppWindow(
        url: String?
    ) {
        CoroutineScope(SupervisorJob()).launch {
            delay(1000L)

            toggleWebViewWindow(url)
        }
    }

    private fun toggleAuthorizeView() {
        _webViewStateFlow?.value?.let { url ->
            _authorizeViewStateFlow.value = AuthorizeViewState.Opened(url, false)
        }
    }

    fun closeAuthorizeView() {
        _authorizeViewStateFlow.value = AuthorizeViewState.Closed()
    }

    private fun toggleWebViewWindow(url: String?) {
        url?.let { nnUrl ->
            _webViewStateFlow.value = nnUrl
        }

        CoroutineScope(SupervisorJob()).launch {
            openAuthorize()
        }
    }

    private suspend fun openAuthorize() {
        delay(60000L)

        toggleAuthorizeView()
    }
}

WebAppUI.kt

package chat.sphinx.common.components

import Roboto
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.WindowState
import chat.sphinx.common.viewmodel.WebAppViewModel
import chat.sphinx.utils.getPreferredWindowSize
import com.multiplatform.webview.web.WebView
import com.multiplatform.webview.web.WebViewState
import com.multiplatform.webview.web.rememberWebViewNavigator
import com.multiplatform.webview.web.rememberWebViewState

@Composable
fun WebAppUI(
    webAppViewModel: WebAppViewModel
) {
    var isOpen by remember { mutableStateOf(true) }

    webAppViewModel.toggleWebAppWindow("https://google.com.ar")

    if (isOpen) {
        Window(
            onCloseRequest = {
                webAppViewModel.toggleWebAppWindow(null)
            },
            title = "Web App",
            state = WindowState(
                position = WindowPosition.Aligned(Alignment.Center),
                size = getPreferredWindowSize(1200, 800)
            )
        ) {
            Box(
                modifier = Modifier.fillMaxSize()
            ) {
                Text(
                    text = "Loading. Please wait...",
                    maxLines = 1,
                    fontSize = 14.sp,
                    fontFamily = Roboto,
                    modifier = Modifier.align(Alignment.Center)
                )

                Column(
                    modifier = Modifier.fillMaxSize(),
                    verticalArrangement = Arrangement.Center,
                    horizontalAlignment = Alignment.CenterHorizontally
                ) {
                    val webViewState by webAppViewModel.webViewStateFlow.collectAsState()
                    webViewState?.let { url ->
                        MaterialTheme {
                            val webViewState = rememberWebViewState(url)
                            val webViewNavigator = rememberWebViewNavigator()

                            initWebView(webViewState)

                            Column(Modifier.fillMaxSize()) {
                                WebView(
                                    state = webViewState,
                                    modifier = Modifier.fillMaxSize(),
                                    navigator = webViewNavigator
                                )
                            }
                        }
                    }
                }
            }
        }
    }
}

@Composable
fun AuthorizeViewUI(
    webAppViewModel: WebAppViewModel,
    budgetField: Boolean
) {
    var isOpen by remember { mutableStateOf(true) }

    if (isOpen) {
        Window(
            onCloseRequest = { webAppViewModel.closeAuthorizeView() },
            title = if (budgetField) "Set Budget" else "Authorize",
            state = WindowState(
                position = WindowPosition.Aligned(Alignment.Center),
                size = getPreferredWindowSize(200, 200)
            ),
            alwaysOnTop = false,
            resizable = true,
            focusable = true,
        ) {
//            Box(
//                modifier = Modifier.fillMaxSize()
//                    .background(androidx.compose.material3.MaterialTheme.colorScheme.background),
//            ) {
//
//            }
        }
    }
}

fun initWebView(webViewState: WebViewState) {
    webViewState.webSettings.apply {
        zoomLevel = 1.0
        isJavaScriptEnabled = true
        customUserAgentString = "Sphinx"
        androidWebSettings.apply {
            isAlgorithmicDarkeningAllowed = true
            safeBrowsingEnabled = true
            allowFileAccess = true
        }
    }
}

In this example I'm loading google webpage in a webview inside the main window of the app. The issue only happens when the second window opens after the web page finished loading, so I added a 60 second delay before showing the second Window (since the library usually takes around 50 seconds to load after launch). You can modify that value if needed. You will see that google page loads successfully and when the second window is presented over the main window it starts to flicker and google page content becomes invisible.

Let me know if this example works or you need something else from me

Thanks in advance

KevinnZou commented 6 months ago

@tomastiminskas Thank you for the information. The code you shared is too complex for us to reproduce. However, I have written a simplified version and tested it locally. I have discovered an issue with the implementation of the desktop side, specifically a problem with backward writing which results in infinite recomposition. I will fix this issue in the next version. It is important to note that this issue only occurs if a recompose is triggered after the webview has loaded. This is why it works well in normal cases.

However, the backward write issue should not be the cause of your problem. Displaying an authentication window should not trigger the WebView window to recompose. Even if the backward write issue is resolved, the page will still refresh. The test code below opens a new window without recomposing the original window. Therefore, there must be an issue with your structure that is causing the unnecessary recomposition of the WebView window. If you can identify and fix this issue, the flickering effect will disappear without having to wait for the new version.

fun main() = application {
    Window(onCloseRequest = ::exitApplication) {
        var restartRequired by remember { mutableStateOf(false) }
        var downloading by remember { mutableStateOf(0F) }
        var initialized by remember { mutableStateOf(false) }

        LaunchedEffect(Unit) {
            withContext(Dispatchers.IO) {
                KCEF.init(builder = {
                    installDir(File("kcef-bundle"))
                    progress {
                        onDownloading {
                            downloading = it
                        }
                        onInitialized {
                            initialized = true
                        }
                    }
                    settings {
                        cachePath = File("cache").absolutePath
                    }
                }, onError = {
                    it?.printStackTrace()
                }, onRestartRequired = {
                    restartRequired = true
                })
            }
        }

        if (restartRequired) {
            Text(text = "Restart required.")
        } else {
            if (initialized) {
                WindowTestView()
            } else {
                Text(text = "Downloading $downloading%")
            }
        }

        DisposableEffect(Unit) {
            onDispose {
                KCEF.disposeBlocking()
            }
        }
    }
}

@Composable
fun WindowTestView() {
    var showAuth by remember { mutableStateOf(false) }
    LaunchedEffect(Unit) {
        withContext(Dispatchers.IO){
            delay(5000)
            showAuth = true
        }
    }

    WebViewWindow()

    if (showAuth) {
        AuthWindow()
    }
}

@Composable
fun WebViewWindow() {
    println("WebViewWindow recompose")
    Window(
        onCloseRequest = {  },
        title = "WebView",
        state = WindowState(
            position = WindowPosition.Aligned(Alignment.Center),
            size = DpSize(1200.dp, 800.dp)
        ),
        alwaysOnTop = false,
        resizable = true,
        focusable = true,
    ) {
        Box(
            modifier = Modifier
                .fillMaxSize()
                .background(Color.White),
        ) {
            SimpleWebView()
        }
    }
}

@Composable
fun SimpleWebView() {
    val state = rememberWebViewState("https://kotlinlang.org/")
    state.webSettings.logSeverity = KLogSeverity.Debug
    WebView(state = state, modifier = Modifier.fillMaxSize())
}

@Composable
fun AuthWindow() {
    var isOpen by remember { mutableStateOf(true) }
    if (isOpen) {
        Window(
            onCloseRequest = { isOpen = false },
            title = "Authorize",
            state = WindowState(
                position = WindowPosition.Aligned(Alignment.Center)
            ),
            alwaysOnTop = false,
            resizable = true,
            focusable = true,
        ) {
            Box(
                modifier = Modifier
                    .fillMaxSize()
                    .background(Color.Blue),
            ) {

            }
        }
    }

}
tomastiminskas commented 6 months ago

@KevinnZou thank you so much. I was able to fix it avoiding the recomposition of the webview.