feat(address): 카카오 주소 검색을 추가한다
This commit is contained in:
@@ -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 = """
|
||||||
|
<!doctype html>
|
||||||
|
<html><head>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<style>html,body,#postcode{margin:0;width:100%;height:100%;}</style>
|
||||||
|
<script src="https://t1.kakaocdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js"
|
||||||
|
onerror="document.documentElement.dataset.postcodeError='true'"></script>
|
||||||
|
</head><body><div id="postcode"></div>
|
||||||
|
<script>
|
||||||
|
try {
|
||||||
|
new kakao.Postcode({
|
||||||
|
width: '100%', height: '100%',
|
||||||
|
oncomplete: function(data) {
|
||||||
|
window.webkit.messageHandlers.postcodeSelection.postMessage({ zonecode: data.zonecode, address: data.address });
|
||||||
|
}
|
||||||
|
}).embed(document.getElementById('postcode'));
|
||||||
|
} catch (error) {
|
||||||
|
document.documentElement.dataset.postcodeError = 'true';
|
||||||
|
}
|
||||||
|
</script></body></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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user