[웹개발자의 IOS 탐방기] 2. 웹앱에 CustomToolbar 붙이기

서론

[1.Swift UI로 웹앱 만들기] 에서 간단히 만들어본 SwiftUI기반의 웹앱에 본격적으로 SwiftUI 기반의 네이티브 기능들을 하나씩 붙여보려고 한다. 그중 제일 먼저 시작할 Custom Toolbar를 만들어 볼 예정이다. 가장 많이 필요한 "이전","다음","홈","설정" 4개의 버튼을 만들고 그 중 "설정"을 제외한 나머지 기능을 먼저 구현한다.

 

Constants.Swift 파일 생성

나는 전역에서 사용할 상수 혹은 변수를 관리할 때에 Constants 라는 명명을 자주 사용한다. 때문에 여기서도 전역에서 사용할 값을을 다루는 Constants.swift 파일을 생성해줬다. 파일 Source의 경우 Swift File을 선택해준다.

지금 당장은 공통으로 사용할 값이 모바일 도메인 값밖에 없기 때문에 아래와 같이 선언한다. 저 값은 향후 WebView 객체 생성 혹은 홈 버튼 클릭시 유용하게 사용될 것이다.

//
//  Constants.swift
//  webApp
//
//  Created by mingyukim on 10/31/23.
//

import Foundation

struct Constants {
    static let DOMAIN_PATH = "https://m.naver.com/"
}

WebViewModel.swift 파일 생성

웹애서는 보통 MVC (Model, View, Controller) 패턴, 혹은 MVCRS (MVC + Repository + Service) 등을 많이 사용하는데 App에서는 MVVM (Model, View, ViewModel) 패턴을 사용한다는 것이다. 아래 정리되는 것은 완벽한 MVVM패턴이 아니지만, 추후 완벽한 의존성 분리작업을 위한 작은 첫 발걸음이라 생각하고, 해당 파일에 WebView 객체에 대한 참조값을 저장하여 컨트롤 할 수 있게 만들어준다.

//
//  WebViewModel.swift
//  webApp
//
//  Created by mingyukim on 10/31/23.
//

import SwiftUI
import WebKit
import Foundation

class WebViewModel: ObservableObject {
    // 여기에 WebView의 참조를 저장한다. 이를 통해 외부에서 WebView의 메서드를 호출할 수 있다.
    var webView: WKWebView?

    func goBack() -> Void {
        if ((webView?.canGoBack) != nil) {
            webView?.goBack()
        }
    }

    func goForward() -> Void {
        if ((webView?.canGoForward) != nil) {
            webView?.goForward()
        }
    }

    func goHome() -> Void {
        let homeURL = Constants.DOMAIN_PATH
        
        if let url = URL(string: homeURL) {
            let request = URLRequest(url: url)
            webView?.load(request)
        }
    }
    
    func showSetting() -> Void {
        // 추후 기능 구현
    }
}

ToolbarView.swift 파일 생성

이제 서론에서 이야기한 간단한 기능들을 가지고 있는 Custom Toolbar View를 선언 및 구현할 swift 파일을 생성한다.

//
//  ToolbarView.swift
//  webApp
//
//  Created by mingyukim on 10/31/23.
//

import SwiftUI
import WebKit

struct ToolbarView: View {
    
    var goBackAction: () -> Void
    var goForwardAction: () -> Void
    var goHomeAction: () -> Void
    var showSettingAction: () -> Void
    
    var body: some View {
        HStack {
            Button(action: {goBackAction()}) {
                Text("이전")
            }
            
            Spacer()
            
            Button(action: {goForwardAction()}) {
                Text("다음")
            }
            
            Spacer()
            
            Button(action: {goHomeAction()}) {
                Text("홈")
            }
            
            Spacer()
            
            Button(action: {showSettingAction()}) {
                Text("설정")
            }
        }
        .padding()
    }
        
}

각 Acition들을 Void로 선언 한 까닭은 리턴 값 없이 순수하게 webViewModel에서 선언한 func들을 사용하기 위함이다. 만약 당신들이 특정 값을 리턴받아 사용하고 싶다면 WebViewModel의 func 반환값 및 return값을 수정하면 된다.

WebView.Swift 파일 수정

WebViewModel을 생성하여 WebView 객체를 참조시켜 관리하기로 하였으니, 아래와 같이 코드를 수정하여 webViewModel을 @ObservedObject (observable 객체를 구독하는 property wrapper)로 선언하여 참조값이 변경될 때마다 View를 다시 그려주는 역할을 진행하도록 한다. 스위프트의 생명주기에 따라 효율성을 극대화 하려면 @StateObject 프로퍼티 와퍼를 사용해도 된다고 하는 글을 봤는데, 나의 의견은 좀 다르다.

@StateObject와 @ObservedObject는 SwiftUI에서 객체의 상태를 추적하고 업데이트 할 때 뷰를 다시 렌더링하는 데 사용되는 프로퍼티 래퍼이다.

두 프로퍼티 래퍼 모두 ObservableObject 프로토콜을 준수하는 객체를 추적하지만, 사용하는 목적과 생명 주기 관리 방식에서 중요한 차이점이 있다.

공통점:
데이터 변경 추적: 두 프로퍼티 래퍼 모두 ObservableObject 프로토콜을 준수하는 객체의 변경을 감지하고 뷰를 다시 렌더링한다.
뷰와 데이터 연결: 둘 다 SwiftUI에서 데이터 바인딩을 사용하여 뷰와 모델 사이의 연결을 도와준다.

차이점:
소유권 (Ownership):
@StateObject: 뷰가 객체의 소유권을 갖는다. 이것은 SwiftUI에서 처음 생성될 때만 객체가 생성되며, 뷰의 본문이 재생성되어도 해당 객체는 유지되는것을 뜻한다.
@ObservedObject: 뷰가 객체의 소유권을 갖지 않는다. 대신 외부에서 객체를 제공받아 사용하며, 뷰가 소멸되면 참조도 함께 소멸된다.
생명 주기:
@StateObject: 객체의 생명 주기는 해당 뷰의 생명 주기와 연결된다. 뷰가 메모리에서 해제될 때까지 객체는 메모리에 유지된다.
@ObservedObject: 객체의 생명 주기는 뷰의 생명 주기와 독립적이다. 뷰가 재생성되면 새로운 객체 인스턴스가 필요하다.

사용 시나리오:
@StateObject: 뷰 내에서 처음 생성되는 객체를 추적하거나 관리할 때 주로 사용된다.
@ObservedObject: 부모 뷰나 다른 곳에서 생성된 객체를 자식 뷰에 전달할 때 사용된다.

따라서 , @StateObject는 객체를 생성하고 관리하는 데 적합하며, @ObservedObject는 외부에서 제공된 객체를 추적하는 데 적합하기 때문에, 나는 ContextView에서 생성한 webViewModel을 추적하기 위하여 @ObservedObject를 사용하기로 했다. 물론 IOS 13버전에서도 사용이 가능하기 때문에 웹앱에 사용하기엔 더할나위 없다고 판단하였다.

struct WebView: UIViewRepresentable {
    
    @ObservedObject private var webViewModel: WebViewModel
    private var request: URLRequest
    
    init(request: URLRequest,webViewModel: WebViewModel) {
        self.request = request
        self.webViewModel = webViewModel
    }
    
    func makeUIView(context: Context) -> WKWebView {
        // 여러 개의 WKWebView 에서 쿠키값을 공유하기 위해 WKProcessPool 코드 추가.
        let wKProcessPool = WKProcessPool()
        let wKPreferences = WKPreferences()
        let webConfiguration = WKWebViewConfiguration()
        if #available(iOS 14.0, *) {
            webConfiguration.defaultWebpagePreferences.allowsContentJavaScript = true
        } else {
            // Fallback on earlier versions
            wKPreferences.javaScriptEnabled = true
        }
        wKPreferences.javaScriptCanOpenWindowsAutomatically = true
        
        // 웹뷰 구성 설정        
        webConfiguration.processPool = wKProcessPool
        webConfiguration.preferences = wKPreferences
        
        let webView = WKWebView(frame: .zero, configuration: webConfiguration)
        webView.navigationDelegate = context.coordinator
        webViewModel.webView = webView
        
        return webView
    }
    
    func updateUIView(_ uiView: WKWebView, context: Context) {
        if uiView.url?.absoluteString != self.request.url?.absoluteString {
            uiView.load(self.request)
        }
    }
    
    // 이하 생략
}

ContentView.swift 파일 수정

마지막으로 위에서 새로 생성 및 수정한 WebView와 WebViewModel, ToolbarView를 ContentView에 아래와 같이 그려주면 "설정" 버튼을 제외한 "뒤로가기","앞으로가기","홈이동" 버튼들이 정상적으로 작동하는것을 확인할 수 있다.

struct ContentView: View {
    
    @ObservedObject var webViewModel: WebViewModel = WebViewModel()
    var request: URLRequest = URL(string: Constants.DOMAIN_PATH).map { URLRequest(url: $0) }!
    
    
    var body: some View {
        
        VStack(spacing: 0) {
            WebView(request: request, webViewModel: webViewModel)
            
            ToolbarView(goBackAction: webViewModel.goBack, goForwardAction: webViewModel.goForward, goHomeAction: webViewModel.goHome, showSettingAction: webViewModel.showSetting)
                .background(Color.gray.opacity(0.1))
        }
    }
}