본문 바로가기
📱Mobile/🔥Swift

[Swift] SwiftUI WebView, WebView + javascript message handler 예제

by 후누스 토르발즈 2021. 9. 28.
반응형

 

 

 

앱의 종류에 따라서 필요시 웹 뷰를 구현하는 앱들이 있다. 단순히 보여주는 용도도 가능하나

하이브리드 앱은 웹과 앱이 서로 상호작용하도록 구현을 많이하는데 일반 사용자는 웹화면을 보고있는지 모르는 것처럼 느낄 수 있다.

단순히 보여주기만 할 수도 있지만 서로 상호작용 할 수도 할 수 있다.

 

 

 

 

1. WebView

WebView.swift

import SwiftUI
import WebKit

struct WebView: UIViewRepresentable {
    
    var url: String
    func makeUIView(context: Context) -> WKWebView {
        
        guard let url = URL(string: self.url) else {
            return WKWebView()
        }
        
        let webView = WKWebView()
        webView.load(URLRequest(url: url))
        return webView
    }
    
    func updateUIView(_ uiView: UIViewType, context: Context) {
        
    }

}


struct WebView_Previews: PreviewProvider {
    static var previews: some View {
        WebView(url: "https://pgnt.tistory.com/112")
    }
}

UIViewRepresentable: 해당 뷰를 스위프트UI 뷰 계층 구조에 통합하는 데 사용하는 UIKit 뷰의 래퍼 기능을 제공한다.

 

ContentView.swift

import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationView{
            VStack{
                NavigationLink(
                    destination: WebView(url: "https://pgnt.tistory.com/112")){
                    Text("TISTORY")
                }.padding()
                NavigationLink(
                    destination: WebView(url: "https://www.google.com")){
                    Text("GOOGLE")
                }.padding()
                NavigationLink(
                    destination: WebView(url: "https://www.naver.com")){
                    Text("NAVER")
                }.padding()
                NavigationLink(
                    destination: WebView(url: "https://www.facebook.com")){
                    Text("FACEBOOK")
                }.padding()
                Spacer()
            }
            .padding()
            .padding(.top, 40)
            .navigationTitle("WebView")
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

 

 

 

여기까지 작성하면 단순하게 웹뷰를 스위프트UI의 계층구조에 통합하여 보여줄 수 있다.

하지만 어떠한 상호작용도 하지않고 단순히 표시만 해주기 때문에 좀 더 코드를 추가하여야 한다.

 

 

 

2. WebView + javascript message handler

ContentView.swift

import SwiftUI

//blog host
//https://pgnt.tistory.com/

import SwiftUI

struct ContentView: View {
    // 관찰 가능한 개체에 구독하고 관찰 가능한 개체가 변경될 때마다 뷰를 무효화하는 속성 래퍼 유형입니다.
    @ObservedObject var viewModel = WebViewModel()
    @State var bar: Int = 0
    
    var body: some View {
        VStack {
            //            WebView(url: "https://pgnt.tistory.com/", viewModel: viewModel)
            WebView(url: "http://localhost:18080/", viewModel: viewModel)            
            HStack {
                Text("\(bar)")
                Button(action: {
                    self.viewModel.foo.send(bar)
                }) {
                    Text("보내기")
                }
            }
        }
        // RunLoop: 입력 소스를 관리하는 개체에 대한 프로그래밍 방식 인터페이스 .main: 메인 스레드의 런 루프를 반환.
        .onReceive(self.viewModel.bar.receive(on: RunLoop.main)){ value in
            self.bar = value + 1
        }
    }
}

 

WebView.swift

//
//  WebView.swift
//  example
//
//  Created by 이정훈 on 2022/12/18.
//
import UIKit
import SwiftUI
import Combine
import WebKit

// 브릿지 통신안하는 일반적인 웹 뷰 생성
//struct WebView: UIViewRepresentable {
//
//    var url: String
//    func makeUIView(context: Context) -> WKWebView {
//
//        guard let url = URL(string: self.url) else {
//            return WKWebView()
//        }
//
//        let webView = WKWebView()
//        webView.load(URLRequest(url: url))
//        return webView
//    }
//
//    func updateUIView(_ uiView: UIViewType, context: Context) {
//
//    }
//}


// 'webview.coordinator'가 예상 유형인 'WKScriptMessageHandler'와 일치하지 않기때문에 extention
// WKScriptMessageHandler: 웹 페이지에서 실행되는 JavaScript 코드에서 메시지를 수신하기 위한 인터페이스입니다.
extension WebView.Coordinator: WKScriptMessageHandler{
    func userContentController(_ userContentController: WKUserContentController,
                               didReceive message: WKScriptMessage){
        // 수신한 메시지의 네임이 일치하는경우
        if message.name == "EXAMPLE" {
            // 대리자의 필수 정의인 received
            delegate?.receivedJsonValueFromWebView(value: message.body as! [String : Any?])
        }
    }
}

// javascript와 Native간의 데이터 통신을 위한 프로토콜/함수 정의
protocol WebViewHandlerDelegate {
    func receivedJsonValueFromWebView(value: [String: Any?])
}

//protocol UIViewRepresentable: 해당 뷰를 SwiftUI 뷰 계층 구조에 통합하는 데 사용하는 UIKit 보기에 대한 래퍼입니다.
struct WebView: UIViewRepresentable, WebViewHandlerDelegate {
    var url: String
    @ObservedObject var viewModel: WebViewModel = WebViewModel()
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    func receivedJsonValueFromWebView(value: [String : Any?]) {
        print("from javascript JSON : \n", value)
        if let action = value["action"] as? String {
            print("action : \(action)")

            switch action {
            case "pong":
                if let param = value["param"] as? [String: Any?] {
                    pong(param: param)
                }
            default:
                return
            }
        }
    }
    
    func pong(param: [String: Any?]){
        if let count = param["count"] as? String {
            print("count : \(count)")
            self.viewModel.bar.send(Int(count)!)
        }
    }
    
    // 필수 정의
    func makeUIView(context: Context) -> WKWebView {
        print("makeUIView")
        // 웹 사이트에 적용할 표준 동작을 캡슐화하는 개체.
        let preferences = WKPreferences()
        
        // JavaScript가 사용자 상호 작용없이 창을 열 수 있는지 여부
        preferences.javaScriptCanOpenWindowsAutomatically = false
        
        // 웹 보기를 초기화하는 데 사용하는 속성 모음.
        let configuration = WKWebViewConfiguration()
        
        // 웹 보기에 대한 기본 설정 관련 설정을 관리하는 개체 정의
        configuration.preferences = preferences
        
        // 앱의 기본 코드와 웹 페이지의 스크립트 및 기타 콘텐츠 간의 상호 작용을 조정하는 개체.
        configuration.userContentController.add(self.makeCoordinator(), name: "EXAMPLE")
        
        // 웹 뷰를 생성하고 지정된 프레임 및 구성 데이터로 초기화. CGRect: 직사각형의 위치와 치수를 포함하는 구조., .zero: 원점과 크기가 모두 0인 사각형.
        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) {
            // 지정된 URL 요청 개체에서 참조하는 웹 콘텐츠를로드하고 탐색
            webView.load(URLRequest(url: url))
        }
        return webView
    }
    
    // 필요하지 않는 이상 코드 추가하지 않아도 됨.
    func updateUIView(_ uiView: WKWebView, context: Context) {
        
    }
    
    //class NSOpject: 대부분의 Objective-C 클래스 계층의 루트 클래스로, 하위 클래스가 런타임 시스템에 대한 기본 인터페이스와 Objective-C 개체로 작동하는 기능을 상속
    //protocol WKNavigationDelegate: 탐색 변경을 수락 또는 거부하고 탐색 요청의 진행 상황을 추적하는 메소드
    class Coordinator : NSObject, WKNavigationDelegate {
        var parent: WebView
        var foo: AnyCancellable? = nil
        
        var delegate: WebViewHandlerDelegate?
        
        // 생성자
        init(_ uiWebView: WebView) {
            self.parent = uiWebView
            self.delegate = parent
        }
        
        // 소멸자
        deinit {
            foo?.cancel()
        }
        //탐색 요청 허용 또는 거부
        // 지정된 기본 설정 및 작업 정보를 기반으로 새 콘텐츠를 탐색할 수 있는 권한을 대리자에게 요청
        //        func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, preferences: WKWebpagePreferences, decisionHandler: @escaping (WKNavigationActionPolicy, WKWebpagePreferences) -> Void) {
        //            print("지정된 기본 설정 및 작업 정보를 기반으로 새 콘텐츠를 탐색할 수 있는 권한을 대리자에게 요청")
        //            decisionHandler(.allow, preferences)
        //        }
        
        // 지정된 작업 정보를 기반으로 새 콘텐츠를 탐색할 수 있는 권한을 대리인에게 요청합니다.
        func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
            print("지정된 작업 정보를 기반으로 새 콘텐츠를 탐색할 수 있는 권한을 대리인에게 요청합니다.")
            //            if let host = navigationAction.request.url?.host {
            //                // 특정 도메인을 제외한 도메인을 연결하지 못하게 할 수 있다.
            //                if host != "pgnt.tistory.com" {
            //                    decisionHandler(.cancel)
            //                    return
            //               }
            //            }
            
            self.foo = self.parent.viewModel.foo.receive(on: RunLoop.main).sink(receiveValue: { value in
                webView.evaluateJavaScript("fromNative('\(value)')", completionHandler: { result, error in
                    if let anError = error {
                        print("Error \(anError.localizedDescription)")
                    }
                    print("Result: \(result ?? "")")
                })
            })
            decisionHandler(.allow)
        }
        
        // 탐색 요청에 대한 응답이 알려진 후 대리인에게 새 콘텐츠 탐색 권한을 요청
        func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
            print("탐색 요청에 대한 응답이 알려진 후 대리인에게 새 콘텐츠 탐색 권한을 요청")
            decisionHandler(.allow)
        }
        
        //요청의 로드 진행률 추적
        // 주 프레임에서 탐색이 시작되었음을 대리자에게 알림
        func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
            print("주 프레임에서 탐색이 시작되었음을 대리자에게 알림")
        }
        
        // 웹 보기가 요청에 대한 서버 리디렉션을 수신했음을 대리자에게 알림
        func webView(_ webView: WKWebView, didReceiveServerRedirectForProvisionalNavigation navigation: WKNavigation!) {
            print("웹 보기가 요청에 대한 서버 리디렉션을 수신했음을 대리자에게 알림")
        }
        
        // 웹 보기가 메인 프레임에 대한 콘텐츠를 수신하기 시작했음을 대리자에게 알림
        func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
            print("웹 보기가 메인 프레임에 대한 콘텐츠를 수신하기 시작했음을 대리자에게 알림")
        }
        
        // 탐색이 완료되었음을 대리자에게 알림
        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
            print("탐색이 완료되었음을 대리자에게 알림")
        }
        
        
        //인증 문제에 응답
        // 대리자에게 인증 질문에 응답하도록 요청
        //        func webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
        //            print("대리자에게 인증 질문에 응답하도록 요청")
        //        }
        
        // 사용되지 않는 버전의 TLS를 사용하는 연결을 계속할지 여부를 대리인에게 묻음
        func webView(_ webView: WKWebView, authenticationChallenge challenge: URLAuthenticationChallenge, shouldAllowDeprecatedTLS decisionHandler: @escaping (Bool) -> Void) {
            print("사용되지 않는 버전의 TLS를 사용하는 연결을 계속할지 여부를 대리인에게 물음")
        }
        
        //탐색 오류에 응답하기
        // 탐색 중 오류가 발생했음을 대리자에게 알림
        func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
            print("탐색 중 오류가 발생했음을 대리자에게 알림")
        }
        
        //초기 탐색 프로세스 중에 오류가 발생했음을 대리자에게 알림
        func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
            print("초기 탐색 프로세스 중에 오류가 발생했음을 대리자에게 알림")
        }
        
        // 웹 보기의 콘텐츠 프로세스가 종료되었음을 대리자에게 알림
        func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
            print("웹 보기의 콘텐츠 프로세스가 종료되었음을 대리자에게 알림")
        }
        
        //        //인스턴스 메소드
        //        func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) {
        //
        //        }
        //        func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) {
        //
        //        }
    }
}

struct WebView_Previews: PreviewProvider {
    static var previews: some View {
        WebView(url: "https://pgnt.tistory.com/112")
    }
}

 

WebViewModel.swift

import Foundation
import Combine

// ObservableObject: 개체가 변경되기 전에 내보내는 게시자가 있는 개체 유형입니다.
class WebViewModel: ObservableObject {
    // PassthroughSubject: 다운스트림 구독자에게 요소를 브로드캐스트 하는 주제
    var foo = PassthroughSubject<Int, Never>()
    var bar = PassthroughSubject<Int, Never>()
}

 

 

간단한 서버 생성

 

index.html (간단하게 만들것이여서 static에 넣고 localhost:{port} )

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Insert title here</title>
<style>
	html, body {
	    margin: 0;
	}
	
	button {
		width: 100%;
	}

</style>
<script>
	// process and send message
	function toNative(){
		count = document.getElementById("count").value;
		
		const sendData = {
			action: "pong",
			param: {
				count: count
			}
		 };
		
		window.webkit.messageHandlers.EXAMPLE.postMessage(sendData);
	}

	// receive and procoess and response
	function fromNative(data){
		document.getElementById("count").value = Number(data) + 1;
		return "from native data processing completed";
	}
</script>
</head>
<body>
	<button onclick="toNative();">퐁</button>
	<input id="count" value="0"/>
</body>
</html>

 

http 호출시 초기 탐색 프로세스 중에 오류가 발생하였다면

 

ATS(App Transport Security)하위의 Key가 Allow Arbitrary Loads인 것의 Value를 YES로 변경해줍니다.

(키를 사용하여 앱의 다른 곳에서 ATS 보호를 유지하면서 웹 보기에 대한 안전하지 않은 로드를 허용 합니다.)

 

실행결과

 

 

 

 

참조: https://velog.io/@altmshfkgudtjr/SwiftUI%EC%97%90%EC%84%9C-WebView%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%B4%EB%B3%B4%EC%9E%90

반응형