import Foundation import SwiftUI import WebKit struct KakaoPostcodeResult { let zonecode: String let address: String init?(payload: Any) { guard let values = payload as? [String: Any], let zonecode = values["zonecode"] as? String, let address = values["address"] as? String, !zonecode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } self.zonecode = zonecode self.address = address } } struct KakaoPostcodeSearchView: View { let onSelect: (KakaoPostcodeResult) -> Void @Environment(\.dismiss) private var dismiss @State private var isLoading = true @State private var hasFailed = false var body: some View { VStack(spacing: 0) { HStack { Text("우편번호 검색").appFont(.body2) Spacer() Button("닫기") { dismiss() } .appFont(.body3) .frame(minHeight: 44) .accessibilityLabel("우편번호 검색 닫기") } .foregroundColor(.white) .padding(.horizontal, SodaSpacing.s14) ZStack { KakaoPostcodeWebView(onSelect: onSelect) { failed in hasFailed = failed isLoading = false } .opacity(hasFailed ? 0 : 1) .allowsHitTesting(!hasFailed) .accessibilityHidden(hasFailed) if hasFailed { Text("주소 검색을 불러오지 못했습니다. 닫은 후 다시 시도해 주세요.") .appFont(.body3) .foregroundColor(.white) .padding(SodaSpacing.s14) } else if isLoading { ProgressView("주소 검색을 불러오는 중") .tint(.white) .foregroundColor(.white) .padding(SodaSpacing.s14) .background(Color.gray900) .cornerRadius(SodaRadius.r14) } } } .background(Color.black) } } private struct KakaoPostcodeWebView: UIViewRepresentable { let onSelect: (KakaoPostcodeResult) -> Void let onLoad: (Bool) -> Void private static let handlerName = "postcodeSelection" func makeCoordinator() -> Coordinator { Coordinator(onSelect: onSelect, onLoad: onLoad) } func makeUIView(context: Context) -> WKWebView { let configuration = WKWebViewConfiguration() configuration.userContentController.add(context.coordinator, name: Self.handlerName) let webView = WKWebView(frame: .zero, configuration: configuration) webView.navigationDelegate = context.coordinator webView.loadHTMLString(Self.html, baseURL: URL(string: "https://postcode.map.kakao.com/")) return webView } func updateUIView(_ uiView: WKWebView, context: Context) {} static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) { coordinator.isActive = false uiView.stopLoading() uiView.configuration.userContentController.removeScriptMessageHandler(forName: handlerName) uiView.navigationDelegate = nil uiView.uiDelegate = nil } private static let html = """
""" final class Coordinator: NSObject, WKScriptMessageHandler, WKNavigationDelegate { private let onSelect: (KakaoPostcodeResult) -> Void private let onLoad: (Bool) -> Void fileprivate var isActive = true private var hasFailed = false private var hasSelected = false init(onSelect: @escaping (KakaoPostcodeResult) -> Void, onLoad: @escaping (Bool) -> Void) { self.onSelect = onSelect self.onLoad = onLoad } func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { guard isActive, !hasFailed, !hasSelected, message.name == KakaoPostcodeWebView.handlerName, message.frameInfo.isMainFrame, let result = KakaoPostcodeResult(payload: message.body) else { return } hasSelected = true onSelect(result) } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { webView.evaluateJavaScript("document.documentElement.dataset.postcodeError === 'true'") { [weak self] value, error in guard let self, self.isActive, !self.hasFailed else { return } if error != nil || value as? Bool != false { self.fail() } else { self.onLoad(false) } } } func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { fail() } func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { fail() } func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { fail() } private func fail() { guard isActive else { return } hasFailed = true onLoad(true) } } }