SwiftUI에서 WebView를 사용해보자

NB·2021년 1월 23일
8

Swift

목록 보기
3/4
post-thumbnail

서론

현재 많은 앱 서비스들은 웹 뷰를 사용하고 있다. 그 이유에 대해서는 상당히 다양할 것이다. 업데이트가 너무 빈번해서 앱 스토어를 통해서 업데이트하는 것보다 웹을 업데이트하면 간편하게 업데이트할 수 있다던지, 반응형 웹을 만들어놓고, 프레임을 앱으로 감싸서 생산성을 높인다던지..

이번 챕터에서는 SwiftUI에서 웹 뷰를 적용하는 방법과 어떤 구조를 적용할 수 있는지에 대해서 알아보겠다.

먼저 전체적으로 필요한 파일에 대해서 크게 다음과 같이 나눠보았다.

  • WebViewModel.swift

  • WebView.swift

  • 웹뷰를 보여줄 상위 View.swift


이제부터 각 파일의 역할이 무엇인지 알아보자.


WebViewModel 선언하기

import Foundation
import Combine

class WebViewModel: ObservableObject {
    var foo = PassthroughSubject<Bool, Never>()
    var bar = PassthroughSubject<Bool, Never>()
}

우리는 해당 ObservableObjectPassthroughSubject 를 통해서 WebView 와 View 사이 간의 구독시스템을 만들 것이다. 위 코드에서 예시로, foobar 2개의 Bool, Never 타입의 PassthroughSubject 객체를 생성한다.

(보통 실제로 구독할 변수와 똑같이 변수명을 짓는 것이 마음 편하다)

우리는 이제 AppView에서 WebView로 보내는 값을 foo로, 그 반대는 bar로 구성하여 데이터가 주고받는 것을 알아볼 것이다. 또한, 해당 기능을 숙지한다면 다음과 같이 응용 할 수 있다!

  • View에서 버튼을 눌러서 웹 뷰의 이전 페이지로 이동하고 싶을 때

  • 특정 웹 뷰를 방문한 페이지의 title을 View로 전달하여 전달하고 싶을 때

  • 웹을 탐색할 때에는 자체 로딩 애니메이션을 보여주고 싶을 때


PassthroughSubject에 대한 추가 정보 😲

[Combine] Subject - naljin


WebView 선언하기

이 부분에 대해서는 사용하는 메소드에 따라서 코드가 길어질 수도 있으니, 분할해서 설명을 진행하겠다. 먼저 큰 틀은 다음과 같다.

import UIKit
import SwiftUI
import Combine
import WebKit

struct WebView: UIViewRepresentable {
    var url: String
    @ObservedObject var viewModel: WebViewModel
    
    // 변경 사항을 전달하는 데 사용하는 사용자 지정 인스턴스를 만듭니다.
    func makeCoordinator() -> Coordinator {
        ...
    }
    
    // 뷰 객체를 생성하고 초기 상태를 구성합니다. 딱 한 번만 호출됩니다.
    func makeUIView(context: Context) -> WKWebView {
        ...
    }
    
    // 지정된 뷰의 상태를 다음의 새 정보로 업데이트합니다.
    func updateUIView(_ webView: WKWebView, context: Context) {
        ...
    }
    
    // 탐색 변경을 수락 또는 거부하고 탐색 요청의 진행 상황을 추적
    class Coordinator : NSObject, WKNavigationDelegate {
        ...
    }
}

우리는 SwiftUI에서 WebView를 사용하기 위해서 UIViewRepresentable 프로토콜을 채택하여 사용한다. 그렇기 때문에 필수적으로 구현되어야 할 메소드가 존재한다. 상단에 보이는 makeUIViewupdateUIView 는 구현해야 한다. 각 메소드에 대한 의미는 코드에 주석으로 작성되어 있다. 또한, Web의 변경사항을 App으로 알리기 위해서 CoordinatormakeCoordinator 을 추가로 구현하였다.

# makeCoordinator 메소드 구현하기

func makeCoordinator() -> Coordinator {
    Coordinator(self)
}

위 메소드는 간단하다. 단순히 아래 Coordinator 클래스에 대한 인스턴스를 생성시켜주면 그만이다. 단지, 주의해야 할 부분은 인자로 self를 넘겨준다. 그 이유에 대해서는 해당 클래스를 선언하는 부분에서 추가적으로 알 수 있다.


# makeUIView 메소드 구현하기

🔖 사용된 클래스 알아보기

  • WKPreferences : 웹 사이트에 적용 할 표준 동작을 캡슐화하는 개체

  • WKWebViewConfiguration : 웹보기를 초기화하는 데 사용하는 속성 모음

  • WKWebView : 인앱 브라우저와 같은 대화 형 웹 콘텐츠를 표시하는 개체

func makeUIView(context: Context) -> WKWebView {
    let preferences = WKPreferences()
    preferences.javaScriptCanOpenWindowsAutomatically = false  // JavaScript가 사용자 상호 작용없이 창을 열 수 있는지 여부
    
    let configuration = WKWebViewConfiguration()
    configuration.preferences = preferences
    
    let webView = WKWebView(frame: CGRect.zero, configuration: configuration)
    webView.navigationDelegate = context.coordinator    // 웹보기의 탐색 동작을 관리하는 데 사용하는 개체
    webView.allowsBackForwardNavigationGestures = true    // 가로로 스와이프 동작이 페이지 탐색을 앞뒤로 트리거하는지 여부
    webView.scrollView.isScrollEnabled = true    // 웹보기와 관련된 스크롤보기에서 스크롤 가능 여부
    
    if let url = URL(string: url) {
        webView.load(URLRequest(url: url))    // 지정된 URL 요청 개체에서 참조하는 웹 콘텐츠를로드하고 탐색
    }
    
    return webView
} 

본인은 기본적으로 이렇게 구성 했지만, Apple Developers 공식 문서에서는 더 다양한 기능들을 제공하니 자유롭게 사용하면 좋을듯 하다.



# updateUIView 메소드 구현하기

func updateUIView(_ webView: WKWebView, context: Context) {
    // Something 
}

사실 해당 메소드에 대해서 공식 문서의 설명은 다음과 같다.

updateUIView(_:context:)

선언: func updateUIView(_ uiView: Self.UIViewType, context: Self.Context)

앱의 상태가 변경되면 SwiftUI는 이러한 변경의 영향을 받는 인터페이스 부분을 업데이트합니다. SwiftUI는 해당 UIKit 뷰에 영향을 미치는 변경 사항에 대해 이 메서드를 호출합니다. 이 메소드를 사용하여 context 매개 변수에 제공된 새 상태 정보와 일치하도록 보기 구성을 업데이트하십시오.

그렇지만, 이번 포스팅에서는 단순히 WebView를 표시시킬 뿐, 그 이상은 사용하지 않으니 내부 코드는 작성하지 않았다. 만약 필요하면, 해당 부분에 대해서 작성을 하면 될 것 같다.



# Coordinator 클래스 구현하기

🔖 사용된 클래스 알아보기

  • WKNavigationDelegate : 탐색 변경을 수락 또는 거부하고 탐색 요청의 진행 상황을 추적
class Coordinator : NSObject, WKNavigationDelegate {
    var parent: WebView
    var foo: AnyCancellable? = nil
    
    // 생성자
    init(_ uiWebView: WebView) {
        self.parent = uiWebView
    }

    // 소멸자
    deinit {
        foo?.cancel()
    }
    
    // 지정된 기본 설정 및 작업 정보를 기반으로 새 콘텐츠를 탐색 할 수있는 권한을 대리인에게 요청
    func webView(_ webView: WKWebView,
                   decidePolicyFor navigationAction: WKNavigationAction,
                   decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
        if let host = navigationAction.request.url?.host {
            // 특정 도메인을 제외한 도메인을 연결하지 못하게 할 수 있다.
            if host != "velog.io" {
               return decisionHandler(.cancel)
           }
        }
        
        // bar에 값을 send 해보자!
        parent.viewModel.bar.send(false)
        
        // foo로 값이 receive 되면 출력해보자!
        self.foo = self.parent.viewModel.foo.receive(on: RunLoop.main)
                                            .sink(receiveValue: { value in
            print(value)
        })
        
        return decisionHandler(.allow)
    }
     
    // 기본 프레임에서 탐색이 시작되었음
    func webView(_ webView: WKWebView,
                   didStartProvisionalNavigation navigation: WKNavigation!) {
        print("기본 프레임에서 탐색이 시작되었음")
    }
    
    // 웹보기가 기본 프레임에 대한 내용을 수신하기 시작했음
    func webView(_ webView: WKWebView,
                   didCommit navigation: WKNavigation!) {
        print("내용을 수신하기 시작");
    }
    
    // 탐색이 완료 되었음
    func webView(_ webview: WKWebView,
                   didFinish: WKNavigation!) {
        print("탐색이 완료")
    }
    
    // 초기 탐색 프로세스 중에 오류가 발생했음 - Error Handler
    func webView(_ webView: WKWebView,
                   didFailProvisionalNavigation: WKNavigation!,
                   withError: Error) {
        print("초기 탐색 프로세스 중에 오류가 발생했음")
    }
    
    // 탐색 중에 오류가 발생했음 - Error Handler
    func webView(_ webView: WKWebView,
                   didFail navigation: WKNavigation!,
                   withError error: Error) {
        print("탐색 중에 오류가 발생했음")
    }
}

위 코드만 보게 된다면 처음에는 상당히 헷갈린다. webView라는 메소드가 왜 이렇게 많은지 모르겠다. 하지만, 링크를 걸어둔 WKNavigationDelegate 에 대해서 찾아보면, 받는 인자에 따라서 역할이 다른 것을 확인할 수 있다. 각 메소드에서 역할은 주석에 적어두었으니, 사용해보고 필요 없다고 생각이 들면 과감하게 지워버리자.

또한, 위 코드에서 제일 상단의 webView 메소드를 보게 되면, bar에 값을 send하고, foo에 값을 receive 하는 코드가 있다. WebView 안에서는 저런 식으로 값을 보내고 받을 수 있다. 아래 Gif는 Button을 누르면, footrue를 보냈을 때, 정상적으로 출력을 하는 것을 볼 수 있다. 또한 bar에도 정상적으로 false가 수신되어서 Before에서 After로 출력된 모습을 확인할 수 있다.



View 선언하기

이제 우리가 공들여서 제작한 웹 뷰를 사용할 차례이다. 원하는 View 파일로 들어가서 웹 뷰를 호출해주면 되는데, 아래 코드에서는 ContentView.swift 에서 호출을 진행해보겠다.

import SwiftUI

struct ContentView: View {
    @ObservedObject var viewModel = WebViewModel()
    @State var bar = true
    
    var body: some View {
        VStack {
            WebView(url: "https://velog.io/", viewModel: viewModel)
            
            HStack {
                Text(bar ? "Before" : "After")
            
                Button(action: {
                    self.viewModel.foo.send(true)
                }) {
                    Text("보내기")
                }
            }
        }
        .onReceive(self.viewModel.bar.receive(on: RunLoop.main)) { value in
            self.bar = value
        }
    }
}

해당 코드를 보게 된다면, webView에서 보낸 bar 의 값을 @State var bar 에 할당하기 위해서 onReceive 함수가 작성되어있다. 이 함수를 통해서 viewModel 의 bar을 통해서 값이 넘어오면 self.bar 에 값을 할당해주는 원리이다.

또한 보내기 버튼을 누르면 viewModel의 foo에게 true 값을 보내게 된다.



Javascript와 Native 간의 데이터 전송

위 코드에서는 더 어지러울까봐 한 번에 소개하지는 않았지만, 실제 웹뷰내의 javascriptApp 사이간의 데이터도 주고받을 수 있다. 주고 받는 방법에 대해서는 WKScriptMessageHandler 프로토콜을 통해서 주고 받을 수 있는데 아래와 같이 추가적으로 작성하면 된다. '...' 이라고 적혀있는 것은 사이에 존재하는 코드를 생략한 것을 뜻한다.

// 주고받을 형식에 대한 프로토콜 생성
protocol WebViewHandlerDelegate {
    func receivedJsonValueFromWebView(value: [String: Any?])
    func receivedStringValueFromWebView(value: String)
}

// 생성한 프로토콜 추가적으로 적용
struct WebView: UIViewRepresentable, WebViewHandlerDelegate {
    func receivedJsonValueFromWebView(value: [String : Any?]) {
        print("JSON 데이터가 웹으로부터 옴: \(value)")
    }
    
    func receivedStringValueFromWebView(value: String) {
        print("String 데이터가 웹으로부터 옴: \(value)")
    }
   
    ...
    
    func makeUIViewe(context: Context) -> WKWebView {
        
        ...
        
        configuration.userContentController.add(self.makeCoordinator(), 
                                                name: "BRIDGE_이름")
        
        ...
        
    }
    
    ...
    
    class Coordinator : NSObject, WKNavigationDelegate {
        
        ...
        
        var delegate: WebViewHandlerDelegate?
        
        ...
        
        // 생성자
        init(_ uiWebView: WebView) {
            self.parent = uiWebView
            self.delegate = parent
        }
        
        ...
        
    }
}

// Coordinator 클래스에 WKScriptMessageHandler 프로토콜 추가 적용
extension WebView.Coordinator: WKScriptMessageHandler {
    func userContentController(_ userContentController: WKUserContentController,
                                 didReceive message: WKScriptMessage) {
        if message.name == "BRIDGE_이름" {
            delegate?.receivedJsonValueFromWebView(value: body)
        } else if let body = message.body as? String {
            delegate?.receivedStringValueFromWebView(value: body)
        }
    }
}

그리고나서 웹에서 Javascript에서는 다음과 같이 작성하면 된다.

const sendData = { message: "Hello! I'm javascript." };
window.webkit.messageHandlers.BRIDGE_이름.postMessage(sendData);

반대로 Javascript에 존재하는 함수를 Native에서 호출시키려면 다음과 같이 작성해보자. 먼저 아래는 Javascript에 선언된 함수이다.

function 함수명(data) {
    alert("Received :", data);
}

그리고, 아래는 Native의 코드이다. 아래는 "Hi, Javascript" 라는 인자를 넘겨주었지만, 인자가 없다면 지워주자.

// 여기서 webView란 WKWebview 객체이다.
webView.evaluateJavaScript("함수명('\("Hi, Javascript")')", completionHandler: { result, error in
    if let anError = error {
        print("Error \(anError.localizedDescription)")
    }
                    
    print("Result \(result ?? "")")
})

아래 Gif는 보내기를 누르면 Native에서 html에 선언된 함수를 호출하여 DOM을 수정하는 모습이다. 그리고 콘솔을 보게되면 잘렸지만, 웹으로부터 JSON 데이터가 전송된 것을 확인할 수 있다.


참고사이트


페이지가 로드가 안 된다면?

만일 웹뷰를 열심히 만들었는데, 에러가 뜨면서 흰색 화면(Background)만 보이는 경우가 발생할 수도 있다. 그럴 경우 여러가지 이유가 있겠지만, 본인인 경우 https가 아닐 경우, 보여지지 않았다. 그럴 경우에는 해당 도메인에 대해서 예외처리를 해주면 사용 가능하다.

먼저 Info.plist 파일을 열고 다음과 같이 추가해주자.

App Transport Security Settings 라는 Dictionary 타입을 추가한 뒤, Exception Domains 라는 Dictionary 타입을 추가시켜 준다. 그리고나서, 도메인을 추가시키면 된다. 이 때, 도메인도 Dictionary 타입으로 생성시킨 뒤, 아래 아이템으로 NSExceptionAllowsInsecureHTTPLoads 라는 Boolean 타입으로 생성시킨 뒤 true 로 바꿔주자.


참고사이트


마무리

글을 작성하고나서 보니, 웹뷰에 대해서 코드가 많이 복잡하지는 않다. 다만, 처음봤을 때에는 내가 아직 모르는 개념과 용어들이 있어서 혼란스러움이 가득했다. 하지만 역시 목표한 것을 구현하고나서 이렇게 한 번 글로 작성하면 그 것에 대한 개념이 좀 더 쉽게 잡히는 것을 느꼈다! 그리고 슬슬, Apple Developers 도큐먼에서 찾는 것이 익숙해져가고 있다..



💡 중요

  • CombineSubject 에 대한 개념을 꼭 잡고가자!

  • 만약 페이지가 로딩이 안 된다면 해당 http 인지 https 인지 확인해보자.


참고자료

  1. SwiftUI: WebView - Md Yamin
  2. 쉽게 실수하는 WKWebView 메모리 누수 수정 - Jingyu Jung
  3. Combine 시작하기 - naljin
profile
𝙄 𝙖𝙢 𝙖 𝙛𝙧𝙤𝙣𝙩𝙚𝙣𝙙 𝙙𝙚𝙫𝙚𝙡𝙤𝙥𝙚𝙧 𝙬𝙝𝙤 𝙚𝙣𝙟𝙤𝙮𝙨 𝙙𝙚𝙫𝙚𝙡𝙤𝙥𝙢𝙚𝙣𝙩. 👋 💻

5개의 댓글

comment-user-thumbnail
2022년 6월 13일

제가 webView.evaluateJavaScript를 userContentController 이 안에서 쓰고싶은데
webView 객체를 어떻게 얻어와야하는지 몰라서 헤메고 있는데 어떻게 하셨나요..? ㅠㅠ
며칠째 고생중인데 답변 주시면 정말 감사하겠습니다 ㅠ

2개의 답글
comment-user-thumbnail
2022년 11월 13일

여기에 처음 로딩때 로딩인디케이터를 보여주고 싶은데 어떻게 해야하나요?

답글 달기