내 벨로그를 앱으로 만들어 보자!(Compose WebView)

김흰돌·2023년 8월 29일
1
post-thumbnail

벨로그를 운영하면서 폰으로 급하게 내 벨로그를 확인해야할 일이 종종 있었다.

예를 들면 면접에 들어가기 전 내가 벨로그에 기록해놓은 면접 질문 목록을 본다던가, 이동하는 중에 유튜브나 인스타에서도 볼 게 없을 때 다른 사람이 올린 글을 구경하기도 했다.

이왕된거 Compose에서 지원하는 WebView를 이용해서 내 벨로그를 쉽게 들어올 수 있게 만들어보았다.

먼저 화면부터 설계해보자

화면


내 벨로그 앱을 실행하면 내 벨로그 메인 화면이 나오고
BottomBar에 뒤로 가기와 앞으로 가기 버튼을 넣어두었다.
이렇게 구성하기 위해서 아래와 같이 코드를 작성했다.

@Composable
fun MainScreen(viewModel: MainViewModel) {
    val snackbarHostState = remember { SnackbarHostState() }
    Scaffold(
        snackbarHost = { SnackbarHost(snackbarHostState) },
        bottomBar = {
            BottomAppBar(
                modifier = Modifier.height(50.dp),
                actions = {
                    Spacer(Modifier.weight(1f))
                    IconButton(onClick = { viewModel.undo() }) {
                        Icon(
                            imageVector = Icons.Default.ArrowBack,
                            contentDescription = "back",
                            tint = Color.DarkGray
                        )
                    }
                    IconButton(onClick = { viewModel.redo() }) {
                        Icon(
                            imageVector = Icons.Default.ArrowForward,
                            contentDescription = "forward",
                            tint = Color.DarkGray
                        )
                    }
                }
            )
        }
    ) {
        MyWebView(viewModel, snackbarHostState)
    }
}

BootomAppBar를 선언하고 IconButton을 용도에 맞게 넣어주었다.

한 가지 설명해야할 부분은 Spacer 부분이다.
Spacer는 Jetpack Compose에서 공간을 만드는 데 사용되는 컴포저블인데 weight(1f)를 이용하여 Spacer에 가중치를 부여하면, Spacer가 차지하는 공간은 이 가중치에 따라 부모 컴포넌트의 남은 공간을 채우게 된다.

따라서 Spacer가 IconButton을 제외한 남은 공간을 차지하게 되면서 IconButton이 자연스레 오른쪽에 모여있게 만들었다.(작성자는 오른손 잡이이기 때문 ㅎ)

그리고 MyWebView에 필요한 인자값을 넘겨준다.



로직

MyWebView 코드는 ViewModel과 함께 살펴보겠다.

fun MyWebView(
    viewModel: MainViewModel,
    snackbarHostState: SnackbarHostState
) {

    val webView = rememberWebView()

    LaunchedEffect(Unit) {
        viewModel.undoSharedFlow.collectLatest {
            if (webView.canGoBack()) {
                webView.goBack()
            } else {
                snackbarHostState.showSnackbar("뒤로 갈 수 없습니다.")
            }
        }
    }

    LaunchedEffect(Unit) {
        viewModel.redoSharedFlow.collectLatest {
            if (webView.canGoForward()) {
                webView.goForward()
            } else {
                snackbarHostState.showSnackbar("앞으로 갈 수 없습니다.")
            }
        }
    }
}
class MainViewModel : ViewModel() {

    private val _undoSharedFlow = MutableSharedFlow<Unit>()
    val undoSharedFlow = _undoSharedFlow.asSharedFlow()

    private val _redoSharedFlow = MutableSharedFlow<Unit>()
    val redoSharedFlow = _redoSharedFlow.asSharedFlow()

    fun undo() {
        viewModelScope.launch {
            _undoSharedFlow.emit(Unit)
        }
    }

    fun redo() {
        viewModelScope.launch {
            _redoSharedFlow.emit(Unit)
        }
    }
}

뒤로 가기와 앞으로 가기 버튼의 기능을 구현하기 위해 ViewModel을 사용하였다.

ViewModel의 undo와 redo가 실행되면 sharedFlow에 값을 방출하고

MyWebView에서 LaunchedEffect가 SharedFlow의 값을 수집한다.

값이 변경될 때마다 LaunchedEffect 내의 블록이 실행된다.

만약 앞으로 가거나 뒤로 갈 수 있다면 웹뷰를 이동시키고, 더 이상 이동할 수 없으면 SnackBar를 띄운다.



웹뷰(SSL 오류 수정)

이번엔 웹뷰 관련 코드를 확인해보자.

@SuppressLint("SetJavaScriptEnabled")
@Composable
fun rememberWebView(): WebView {
    val context = LocalContext.current
    val webView = remember {
        WebView(context).apply {
            settings.apply {
                javaScriptEnabled = true
                domStorageEnabled = true  // localStorage 등을 위해 필요
                mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW  // HTTPS/HTTP 혼합 컨텐츠 허용
            }
            webViewClient = object : WebViewClient() {
                override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) {
                    handler?.proceed()  // SSL 에러 무시하고 진행
                }
            }
            loadUrl("https://velog.io/@YourVelog")
        }
    }
    return webView
}

LocalContext.current는 현재 컴포저블이 실행되는 Context를 가져오고 이 Context는 웹뷰의 생성자에 전달한다.

remember 함수는 주어진 람다가 생성하는 객체를 메모리에 저장하며, Composable이 다시 구성될 때마다 동일한 객체 인스턴스를 반환한다. 즉, 여기서 WebView 인스턴스가 한 번만 생성되고 계속 재사용된다는 것을 의미한다.

settings.javaScriptEnabled = true는 JavaScript가 활성화되도록 설정하는 코드이다.

webViewClient = WebViewClient() 기본 WebViewClient 인스턴스를 설정하여 웹 페이지 로딩 등의 작업을 처리할 수 있게 한다.
webChromeClient = WebChromeClient()로 크롬 클라이언트를 사용할 수도 있다.

loadUrl("https://velog.io/@YourVelog") 초기 URL로 내 벨로그 주소를 여기에 넣어준다.

이렇게 해서 코드를 실행해보면 아래와 같은 화면이 나온다.

뒤로 가기를 누르면 전 페이지로 이동하고 더 이상 이동할 수 없으면 SnackBar를 띄운다.


마찬가지로 앞으로 가기를 누르면 앞으로 이동하다가 더 이상 이동할 수 없으면 SnackBar를 띄운다.


하지만 위 GIF 처럼 문제가 하나 생기는 데 작성자가 만든 뒤로가기 아이콘이 아닌 안드로이드 Back 버튼을 눌렀을 경우 앱이 나가지는 현상이 발생했다.

그렇기 때문에 Back 버튼을 눌렀을 경우 다른 동작을 하게끔 만들어줘야 한다.



Back 버튼 기능 구현

Activity에 Back버튼을 눌렀을 경우 호출되는 함수를 Override한다.

    override fun onBackPressed() {
        viewModel.backPressed()
    }

여기서 ViewModel의 backPressed 함수를 호출한다.

class MainViewModel : ViewModel() {

    private val _backPressedFlow = MutableSharedFlow<Unit>()
    val backPressedFlow = _backPressedFlow.asSharedFlow()

    fun backPressed() {
        viewModelScope.launch {
            _backPressedFlow.emit(Unit)
        }
    }
}

위에서 만든 redo, undo와 마찬가지로 SharedFlow에 값을 방출하고

LaunchedEffect(Unit) {
    viewModel.backPressedFlow.collectLatest {
        if (webView.canGoBack()) {
            webView.goBack()
        } else {
            snackbarHostState.showSnackbar("뒤로 갈 수 없습니다.")
        }
    }
}

LaunchedEffect를 통해 값의 변화를 감지하여 Back 버튼을 눌렀을 경우 redo와 같은 기능을 동작하게 만들었다.


그 결과로 Back 버튼을 눌렀을 경우에도 이전 페이지로 이동하는 모습을 볼 수 있다.



전체 코드 -> Github

1개의 댓글

comment-user-thumbnail
2023년 11월 3일

좋은 글 감사합니다~

답글 달기
Powered by GraphCDN, the GraphQL CDN