Post

SwiftUI: SwiftUI의 View에서 UIRepresentableView(ViewController)에 접근하여 명령을 실행하는 방법

SwiftUI: SwiftUI의 View에서 UIRepresentableView(ViewController)에 접근하여 명령을 실행하는 방법

개요: SwiftUI와 UIKit을 연결하고 컨트롤하기

이미 이 주제와 관련하여 포스트를 작성한 적이 있습니다.

그런데 위의 방법은 CombinePassthroughSubject를 이용하기 때문에 Combine을 사용하지 않는 방법에 대해 소개합니다.

 

배경

SwiftUI에서 WebKitView를 이용해 웹 페이지를 띄우는 앱을 만드려고 합니다. 추가하고 싶은 기능은 다음과 같습니다.

  • 구글로 이동
  • 네이버로 이동
  • 사용자가 임의로 만든 자바스크립트 주입 및 실행 (evaulateJS 이용)

 

그래서 아래와 같이 아주 간단한 웹 뷰를 감싼 UIRepresentableView를 만들었습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import SwiftUI
import WebKit

struct WebViewRepresentable: UIViewRepresentable {
    typealias UIViewType = WKWebView
    
    func makeUIView(context: Context) -> WKWebView {
        let webView = WKWebView()
        let request = URLRequest(url: .init(string: "https://google.com")!)
        webView.load(request)
        
        return webView
    }
    
    func updateUIView(_ uiView: WKWebView, context: Context) {}
}

 

그리고 이것을 앱의 메인 부분에 추가했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import SwiftUI

struct WebContentView: View {
    var body: some View {
        VStack {
            HStack {
                Button("구글") {
                    // ??
                }
                .buttonStyle(.borderedProminent)
                Button("네이버") {
                    // ??
                }
                .buttonStyle(.borderedProminent)
                Button("스크립트(evaulateJS) 실행") {
                    // ??
                }
                .buttonStyle(.borderedProminent)
                .tint(.pink)
            }
            
            WebViewRepresentable()
        }
    }
}

문제는 여기서 WebViewRepresentable()를 제어할 수 있는 방법이 없습니다.

앞서 언급한 세 가지 기능을 UIKit에서는 매우 쉽게 추가할 수 있으며, 다음과 같습니다.

  • 구글로 이동, 네이버로 이동
    • webView.load(request)
  • 사용자가 임의로 만든 자바스크립트 주입 및 실행 (evaulateJS 이용)
    • webView.evaluateJavaScript(스크립트)

하지만 SwiftUI에서는 webView 객체에 접근할 수 없으므로 이 방법을 사용할 수가 없습니다. WebViewRepresentable()을 변수에 추가해봤자 일반적인 SwiftUI의 View로 인식합니다.

이를 해결할 다양한 방법이 있는데 뷰모델 형태, ObservableObject를 통하여 UIKit의 뷰(또는 뷰컨트롤러)에 접근한 뒤 명령을 실행할 수 있습니다. 이 포스트에선 뷰모델을 사용한 방법에 대해 알아보겠습니다.

 

방법

  1. 실행할 명령들을 정의한 Provider 프로토콜을 작성합니다.
  2. 위의 Provider를 가지고 있는 Controller 프로토콜을 작성합니다.
  3. ObservableObjectController를 내려받은 뷰모델 클래스를 생성합니다.
    • 뷰모델 안에는 provider와 실행할 명령 메서드들을 추가합니다.
  4. WKWebView를 상속받은 새 UIKit의 뷰 클래스를 생성합니다.
    1. 위 새로운 뷰 클래스에 Provider의 명령을 추가하고,
    2. 생성자와 멤버 변수로 any Controller 타입을 추가하고,
    3. 새로운 뷰 클래스의 selfController 안의 provider와 연결합니다.
  5. SwiftUI의 뷰에서 뷰모델 클래스를 초기화(initializaiton)하고, 해당 뷰모델 변수를 통해 원하는 작업을 실행합니다.

 

Step 1: 실행할 명령들을 정의한 Provider 프로토콜을 작성합니다.

1
2
3
4
protocol WebViewProvider {
    func move(urlString: String)
    func evaluateJS(_ script: String)
}

 

Step 2: 위의 Provider를 가지고 있는 Controller 프로토콜을 작성합니다.

1
2
3
protocol WebViewConnector {
    var provider: (any WebViewProvider)? { get set }
}
  • Controller라는 용어가 혼동의 여지가 있어서 앞으로 이것들은 Connector라고 칭하도록 하겠습니다.

 

Step 3: ObservableObject와 Controller를 내려받은 뷰모델 클래스를 생성합니다.

1
2
3
4
5
6
7
8
9
10
11
final class WebViewNexus: ObservableObject, WebViewConnector {
    @Published var provider: (WebViewProvider)?
    
    func move(urlString: String) {
        provider?.move(urlString: urlString)
    }
    
    func evaluateJS(_ script: String) {
        provider?.evaluateJS(script)
    }
}
  • 뷰모델의 역할과 구분을 짓기 위해 앞으로 Nexus(결합, 연결, 집합체)라는 용어를 사용하도록 하겠습니다.

 

Step 4: WKWebView를 상속받은 새 UIKit의 뷰 클래스를 생성합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class CustomWKWebView: WKWebView, WebViewProvider {
    var connector: any WebViewConnector
    
    init(connector: any WebViewConnector) {
        self.connector = connector
        super.init(frame: .zero, configuration: .init())
        
        // 필수: Critical connection between SwiftUI and UIKit
        DispatchQueue.main.async{
            self.connector.provider = self
        }
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    // WebViewProvider에서 요구하는 메서드

    func move(urlString: String) {
        let request = URLRequest(url: .init(string: urlString)!)
        self.load(request)
    }
    
    func evaluateJS(_ script: String) {
        self.evaluateJavaScript(script)
    }
}

 

Step 4-2: 기존에 있던 WebViewRepresentable 뷰를 새로운 커스텀 타입에 맞춰 변경시킵니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
struct WebViewRepresentable: UIViewRepresentable {
    typealias UIViewType = CustomWKWebView
    
    var connector: any WebViewConnector
    
    init(connector: any WebViewConnector) {
        self.connector = connector
    }
    
    func makeUIView(context: Context) -> CustomWKWebView {
        // let webView를 멤버변수로 선언하면 메모리 위치 오류가 나므로 이 안에 선언
        let webView: CustomWKWebView = .init(connector: connector)
        webView.move(urlString: "https://naver.com")

        return webView
    }
    
    func updateUIView(_ uiView: CustomWKWebView, context: Context) {
        // webView를 코디네이터로 넘기기 위해
        context.coordinator.webView = uiView
        context.coordinator.webView?.navigationDelegate = context.coordinator
    }
    
    func makeCoordinator() -> Coordinator {
        .init(parent: self)
    }
    
    class Coordinator: NSObject, WKNavigationDelegate {
        var webView: CustomWKWebView? = nil
        
        init(parent: WebViewRepresentable) {
            super.init()
        }
        
        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
            print(#function)
        }
        
        func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
            print(#function, error)
        }
    }
}
  • 현재 예제에서는 Coordinator가 필요하지 않지만 추후 확장성을 고려하여 미리 추가해뒀습니다.

 

Step 5: SwiftUI의 뷰에서 뷰모델 클래스를 초기화(initializaiton)하고, 해당 뷰모델 변수를 통해 원하는 작업을 실행합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import SwiftUI

struct WebContentView: View {
    @StateObject var nexus = WebViewNexus()
    
    var body: some View {
        VStack {
            HStack {
                Button("구글") {
                    // 이제 여기에 nexus(뷰모델)를 사용하면 됩니다.
                    nexus.move(urlString: "https://google.com")
                }
                .buttonStyle(.borderedProminent)
                Button("네이버") {
                    // 이제 여기에 nexus(뷰모델)를 사용하면 됩니다.
                    nexus.move(urlString: "https://naver.com")
                }
                .buttonStyle(.borderedProminent)
                Button("스크립트(evaulateJS) 실행") {
                    // 이제 여기에 nexus(뷰모델)를 사용하면 됩니다.
                    nexus.evaluateJS(
                    """
                    document.body.innerHTML = "<b>메롱  🤪</b>"
                    """
                    )
                }
                .buttonStyle(.borderedProminent)
                .tint(.pink)
            }
            
            WebViewRepresentable(connector: nexus)
        }
    }
}

#Preview {
    WebContentView()
}

 

This post is licensed under CC BY 4.0 by the author.