Compose Multiplatform 에서는 플랫폼별 공통으로 사용가능한 WebView를 제공하고 있지 않으므로, expect/actual 키워드를 활용하여 플랫폼별로 웹뷰를 구현해야 한다.

처음에는 https://github.com/KevinnZou/compose-webview 라이브러리를 활용하여 구현하고자 했으나, 개발환경 버전과 일치하지 않아 문제가 생겼다.

이 포스팅에서는 직접 JavaFX를 활용하여 desktop(jvm)을 위한 웹뷰 구현방법을 소개하고자 한다.

JavaFX란?

“JavaFX is an open source, next generation client application platform for desktop, mobile and embedded systems built on Java. It is a collaborative effort by many individuals and companies with the goal of producing a modern, efficient, and fully featured toolkit for developing rich client applications.”

공식문서 내용 발췌

JavaFX는 Java를 기반으로 하는 데스크톱, 모바일 및 임베디드 시스템을 위한 오픈 소스 차세대 클라이언트 애플리케이션 플랫폼이다. 많은 개인과 회사의 노력으로 다채로운 클라이언트 애플리케이션을 개발하기 위한 현대적이고 효율적이며 모든 기능을 갖춘 툴킷을 제작한다는 목표를 가지고 있다.

JavaFX 의존성 추가하기

implementation("org.openjfx:javafx-base:$jdk:$platform")
implementation("org.openjfx:javafx-graphics:$jdk:$platform")
implementation("org.openjfx:javafx-web:$jdk:$platform")
implementation("org.openjfx:javafx-swing:$jdk:$platform")
implementation("org.openjfx:javafx-controls:$jdk:$platform")
implementation("org.openjfx:javafx-media:$jdk:$platform")

의존성 추가하는 방법이 다소 까다롭다. 개발환경의 jdk 버전과 목표로 하고 있는 플랫폼별로 의존성을 달리 하기 때문에 빌드 시에 이를 유의하여 제공해야한다. 플러그인을 통해 관리하는 방법도 있는 듯 하지만 시도해보진 않았다.

만약 JDK 19를 사용하는 애플리실리콘 macOS 환경이라면 다음과 같이 추가 하면 된다.

implementation("org.openjfx:javafx-base:19:mac-aarch64")
implementation("org.openjfx:javafx-graphics:19:mac-aarch64")
implementation("org.openjfx:javafx-web:19:mac-aarch64")
implementation("org.openjfx:javafx-swing:19:mac-aarch64")
implementation("org.openjfx:javafx-controls:19:mac-aarch64")
implementation("org.openjfx:javafx-media:19:mac-aarch64")

Compose로 WebView 구현하기

이제 javafx.scene.web.WebView 참조가 가능하므로, https://github.com/KevinnZou/compose-webview 의 코드를 발췌하여 약간의 수정을 거쳐 Compose로 WebView를 다음과 같이 구현했다.

@Composable
fun JavaFxWebView(
    state: WebViewState,
    modifier: Modifier = Modifier,
    navigator: WebViewNavigator = rememberWebViewNavigator(),
    onCreated: () -> Unit = {},
    onDispose: () -> Unit = {},
    engineChangeListener: ChangeListener<Worker.State>? = null
) {
    val jfxPanel = remember { JFXPanel() }

    LaunchedEffect(Unit) {
        Platform.runLater {
            val wv = WebView().apply {
                applySettings(state.settings)
                engineChangeListener?.run { engine.loadWorker.stateProperty().addListener(this) }
            }
            jfxPanel.scene = Scene(wv)
            state.webView = wv
            onCreated()
        }
    }

    DisposableEffect(Unit) {
        onDispose {
            onDispose()
        }
    }

    val webView = state.webView

    webView?.let { wv ->
        LaunchedEffect(navigator, wv) {
            navigator.handleNavigationEvents(wv)
        }

        LaunchedEffect(state, wv) {
            snapshotFlow { state.content }.collect { content ->
                when (content) {
                    is WebContent.Url -> {
                        Platform.runLater {
                            wv.engine.load(content.url)
                        }
                    }

                    is WebContent.Data -> {
                        Platform.runLater {
                            if (content.mimeType.isNullOrBlank())
                                wv.engine.loadContent(content.data)
                            else
                                wv.engine.loadContent(content.data, content.mimeType)
                        }
                    }

                    is WebContent.NavigatorOnly -> {
                        // NO-OP
                    }

                    else -> {}
                }
            }
        }

        SwingPanel(
            factory = {
                jfxPanel
            },
            update = {

            },
            modifier = modifier
        )
    }
}


public sealed class WebContent {
    public data class Url(
        val url: String,
        val additionalHttpHeaders: Map<String, String> = emptyMap(),
    ) : WebContent()

    public data class Data(
        val data: String,
        val baseUrl: String? = null,
        val encoding: String = "utf-8",
        val mimeType: String? = null,
        val historyUrl: String? = null
    ) : WebContent()

    public data object NavigatorOnly : WebContent()
}

internal fun WebContent.withUrl(url: String) = when (this) {
    is WebContent.Url -> copy(url = url)
    else -> WebContent.Url(url)
}

public sealed class LoadingState {

    public object Initializing : LoadingState()

    public data class Loading(val progress: Float) : LoadingState()

    public object Finished : LoadingState()
}

@Stable
class WebViewState constructor(webContent: WebContent) {
    var lastLoadedUrl: String? by mutableStateOf(null)
        internal set

    var content: WebContent by mutableStateOf(webContent)

    var loadingState: LoadingState by mutableStateOf(LoadingState.Initializing)
        internal set

    val isLoading: Boolean
        get() = loadingState !is LoadingState.Finished

    var pageTitle: String? by mutableStateOf(null)
        internal set

    val settings: JavaFxWebSettings = JavaFxWebSettings(
        onSettingsChanged = {
            applySetting()
        }
    )

    private fun applySetting() {
        webView?.applySettings(settings)
    }

    fun evaluateJavascript(script: String, callback: ((String?) -> Unit)?) {
        Platform.runLater {
            webView?.engine?.executeScript(script).let {
                callback?.invoke(it?.toString())
            }
        }
    }

    var webView by mutableStateOf<WebView?>(null)
        internal set
}

@Stable
public class WebViewNavigator(private val coroutineScope: CoroutineScope) {
    internal sealed interface NavigationEvent {
        data object Back : NavigationEvent
        data object Forward : NavigationEvent
        data object Reload : NavigationEvent
        data object StopLoading : NavigationEvent

        data class LoadUrl(
            val url: String,
            val additionalHttpHeaders: Map<String, String> = emptyMap()
        ) : NavigationEvent

        data class LoadHtml(
            val html: String,
            val baseUrl: String? = null,
            val mimeType: String? = null,
            val encoding: String? = "utf-8",
            val historyUrl: String? = null
        ) : NavigationEvent
    }

    internal val navigationEvents: MutableSharedFlow<NavigationEvent> = MutableSharedFlow(replay = 1)

    public var canGoBack: Boolean by mutableStateOf(false)
        internal set

    public var canGoForward: Boolean by mutableStateOf(false)
        internal set

    public fun loadUrl(url: String, additionalHttpHeaders: Map<String, String> = emptyMap()) {
        coroutineScope.launch {
            navigationEvents.emit(
                NavigationEvent.LoadUrl(
                    url,
                    additionalHttpHeaders
                )
            )
        }
    }

    public fun loadHtml(
        html: String,
        baseUrl: String? = null,
        mimeType: String? = null,
        encoding: String? = "utf-8",
        historyUrl: String? = null
    ) {
        coroutineScope.launch {
            navigationEvents.emit(
                NavigationEvent.LoadHtml(
                    html,
                    baseUrl,
                    mimeType,
                    encoding,
                    historyUrl
                )
            )
        }
    }

    public fun navigateBack() {
        coroutineScope.launch { navigationEvents.emit(NavigationEvent.Back) }
    }

    public fun navigateForward() {
        coroutineScope.launch { navigationEvents.emit(NavigationEvent.Forward) }
    }

    public fun reload() {
        coroutineScope.launch { navigationEvents.emit(NavigationEvent.Reload) }
    }

    public fun stopLoading() {
        coroutineScope.launch { navigationEvents.emit(NavigationEvent.StopLoading) }
    }
}

@Composable
public fun rememberWebViewNavigator(
    coroutineScope: CoroutineScope = rememberCoroutineScope()
): WebViewNavigator = remember(coroutineScope) { WebViewNavigator(coroutineScope) }

@Composable
public fun rememberWebViewState(
    url: String,
    additionalHttpHeaders: Map<String, String> = emptyMap()
): WebViewState =
    remember {
        WebViewState(
            WebContent.Url(
                url = url,
                additionalHttpHeaders = additionalHttpHeaders
            )
        )
    }.apply {
        this.content = WebContent.Url(
            url = url,
            additionalHttpHeaders = additionalHttpHeaders
        )
    }

@Composable
public fun rememberWebViewStateWithHTMLData(
    data: String,
    baseUrl: String? = null,
    encoding: String = "utf-8",
    mimeType: String? = null,
    historyUrl: String? = null
): WebViewState =
    remember {
        WebViewState(WebContent.Data(data, baseUrl, encoding, mimeType, historyUrl))
    }.apply {
        this.content = WebContent.Data(
            data, baseUrl, encoding, mimeType, historyUrl
        )
    }

@Composable
public fun rememberSaveableWebViewState(): WebViewState =
    rememberSaveable(saver = WebStateSaver) {
        WebViewState(WebContent.NavigatorOnly)
    }

val WebStateSaver: Saver<WebViewState, Any> = run {
    val pageTitleKey = "pagetitle"
    val lastLoadedUrlKey = "lastloaded"

    mapSaver(
        save = {
            mapOf(
                pageTitleKey to it.pageTitle,
                lastLoadedUrlKey to it.lastLoadedUrl,
            )
        },
        restore = {
            WebViewState(WebContent.NavigatorOnly).apply {
                this.pageTitle = it[pageTitleKey] as String?
                this.lastLoadedUrl = it[lastLoadedUrlKey] as String?
            }
        }
    )
}

private fun WebView.applySettings(javaFxWebSettings: JavaFxWebSettings) {
    engine.isJavaScriptEnabled = javaFxWebSettings.javaScriptEnabled
}

internal suspend fun WebViewNavigator.handleNavigationEvents(
    webView: WebView
): Nothing = withContext(Dispatchers.Main) {
    navigationEvents.collect { event ->
        with(webView.engine) {
            when (event) {
                is WebViewNavigator.NavigationEvent.Back ->
                    if (history.currentIndex > 0) history.go(-1)

                is WebViewNavigator.NavigationEvent.Forward ->
                    if (history.currentIndex < history.maxSize - 1) history.go(1)

                is WebViewNavigator.NavigationEvent.Reload ->
                    reload()

                is WebViewNavigator.NavigationEvent.StopLoading ->
                    stopLoading()

                is WebViewNavigator.NavigationEvent.LoadHtml ->
                    loadContent(event.html, event.mimeType)

                is WebViewNavigator.NavigationEvent.LoadUrl ->
                    loadUrl(event.url, event.additionalHttpHeaders)
            }
        }
    }
}

자바스크립트 콜백 수신하기

JavaFxWebView(
        engineChangeListener = { observableState, oldState, newState ->
            if (newState == Worker.State.SUCCEEDED) {
                val window = state.webView?.engine?.executeScript("window") as JSObject
                window.setMember("${자바스크립트인터페이스명}", object : JavascriptInterface {
                    override fun completeLogin(token: String) {
                        println("콜백 수신, token = $token")
                    }
                })
            }
        }
    )

interface JavascriptInterface {
    // 콜백받을 메서드 명
    fun completeLogin(token:String)
}

0개의 댓글

답글 남기기

Avatar placeholder

이메일은 공개되지 않습니다. 필수 입력창은 * 로 표시되어 있습니다.