diff --git a/SodaLive/Sources/V2/Component/Address/KakaoPostcodeSearchView.swift b/SodaLive/Sources/V2/Component/Address/KakaoPostcodeSearchView.swift new file mode 100644 index 00000000..cb043f2a --- /dev/null +++ b/SodaLive/Sources/V2/Component/Address/KakaoPostcodeSearchView.swift @@ -0,0 +1,165 @@ +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) + } + } +} diff --git a/SodaLive/Sources/V2/Component/Address/SodaAddressInputView.swift b/SodaLive/Sources/V2/Component/Address/SodaAddressInputView.swift new file mode 100644 index 00000000..6d7ca8ef --- /dev/null +++ b/SodaLive/Sources/V2/Component/Address/SodaAddressInputView.swift @@ -0,0 +1,77 @@ +import SwiftUI + +struct SodaAddressInputView: View { + @Binding var zipCode: String + @Binding var address: String + @Binding var addressDetail: String + @State private var isSearching = false + + var body: some View { + VStack(alignment: .leading, spacing: SodaSpacing.s4) { + Text("우편번호") + .appFont(.body5) + .foregroundColor(.grayee) + VStack(spacing: SodaSpacing.s8) { + HStack(spacing: SodaSpacing.s8) { + readonlyField(zipCode, placeholder: "우편번호를 입력해 주세요", label: "우편번호") + Button { + isSearching = true + } label: { + Text("주소 검색") + .appFont(.body5) + .foregroundColor(.white) + .padding(.horizontal, SodaSpacing.s14) + .frame(minHeight: 44) + .overlay(Capsule().stroke(Color.gray500, lineWidth: 1)) + .fixedSize(horizontal: true, vertical: false) + } + .buttonStyle(.plain) + .accessibilityLabel("주소 검색") + } + VStack(alignment: .leading, spacing: SodaSpacing.s4) { + Text("주소") + .appFont(.body5) + .foregroundColor(.grayee) + readonlyField(address, placeholder: "주소를 입력해 주세요", label: "기본 주소") + } + VStack(alignment: .leading, spacing: SodaSpacing.s4) { + Text("상세주소") + .appFont(.body5) + .foregroundColor(.grayee) + TextField("", text: $addressDetail, prompt: Text("상세 주소를 입력해 주세요").foregroundColor(.gray600)) + .appFont(.body3) + .foregroundColor(.white) + .textContentType(.streetAddressLine2) + .autocorrectionDisabled() + .padding(SodaSpacing.s14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.gray900) + .cornerRadius(SodaRadius.r14) + .accessibilityLabel("상세 주소") + } + } + } + .sheet(isPresented: $isSearching) { + KakaoPostcodeSearchView { result in + if zipCode != result.zonecode || address != result.address { + addressDetail = "" + } + zipCode = result.zonecode + address = result.address + isSearching = false + } + } + } + + private func readonlyField(_ value: String, placeholder: String, label: String) -> some View { + Text(value.isEmpty ? placeholder : value) + .appFont(.body3) + .foregroundColor(value.isEmpty ? .gray600 : .white) + .padding(SodaSpacing.s14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.gray900) + .cornerRadius(SodaRadius.r14) + .accessibilityLabel(label) + .accessibilityValue(value) + } +}