SwiftUI 使用 MapKit 实现地址自动完成

SwiftUI Using MapKit for Address Auto Complete

我有一个表单,用户可以在其中输入他们的地址。虽然他们总是可以手动输入地址,但我也想为他们提供一个自动完成的简单解决方案,这样他们就可以开始输入他们的地址,然后从列表中点击正确的地址并让它自动填充各个字段。

我开始使用 jnpdx 的 Swift5 解决方案 -

但是,有两个问题我似乎无法解决:

  1. 我需要将结果仅限于美国(不仅仅是美国大陆,而是整个美国,包括阿拉斯加、夏威夷和波多黎各)。我知道 MKCoordinateRegion 如何与中心点一起工作,然后是缩放扩展,但它似乎对地址搜索的结果不起作用。

  2. 结果的 return 仅提供标题和副标题,我需要在其中实际提取所有个人地址信息并填充我的变量(即地址、城市、州、邮政编码, 和 zip 分机)。如果用户有 apt 或 suite 号码,他们会自己填写。我的想法是创建一个在点击按钮时会 运行 的函数,以便根据用户的选择分配变量,但我不知道如何提取所需的各种信息。 Apple 的文档和往常一样糟糕,我还没有找到任何解释如何执行此操作的教程。

这是针对最新的 SwiftUI 和 XCode (ios15+)。

我创建了一个用于测试的虚拟表单。这是我拥有的:

import SwiftUI
import Combine
import MapKit

class MapSearch : NSObject, ObservableObject {
    @Published var locationResults : [MKLocalSearchCompletion] = []
    @Published var searchTerm = ""
    
    private var cancellables : Set<AnyCancellable> = []
    
    private var searchCompleter = MKLocalSearchCompleter()
    private var currentPromise : ((Result<[MKLocalSearchCompletion], Error>) -> Void)?
    
    override init() {
        super.init()
        searchCompleter.delegate = self
        searchCompleter.region = MKCoordinateRegion()
        searchCompleter.resultTypes = MKLocalSearchCompleter.ResultType([.address])
        
        $searchTerm
            .debounce(for: .seconds(0.5), scheduler: RunLoop.main)
            .removeDuplicates()
            .flatMap({ (currentSearchTerm) in
                self.searchTermToResults(searchTerm: currentSearchTerm)
            })
            .sink(receiveCompletion: { (completion) in
                //handle error
            }, receiveValue: { (results) in
                self.locationResults = results
            })
            .store(in: &cancellables)
    }
    
    func searchTermToResults(searchTerm: String) -> Future<[MKLocalSearchCompletion], Error> {
        Future { promise in
            self.searchCompleter.queryFragment = searchTerm
            self.currentPromise = promise
        }
    }
}

extension MapSearch : MKLocalSearchCompleterDelegate {
    func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
            currentPromise?(.success(completer.results))
        }
    
    func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) {
        //currentPromise?(.failure(error))
    }
}

struct MapKit_Interface: View {

        @StateObject private var mapSearch = MapSearch()
        @State private var address = ""
        @State private var addrNum = ""
        @State private var city = ""
        @State private var state = ""
        @State private var zip = ""
        @State private var zipExt = ""
        
        var body: some View {

                List {
                    Section {
                        TextField("Search", text: $mapSearch.searchTerm)

                        ForEach(mapSearch.locationResults, id: \.self) { location in
                            Button {
                                // Function code goes here
                            } label: {
                                VStack(alignment: .leading) {
                                    Text(location.title)
                                        .foregroundColor(Color.white)
                                    Text(location.subtitle)
                                        .font(.system(.caption))
                                        .foregroundColor(Color.white)
                                }
                        } // End Label
                        } // End ForEach
                        } // End Section

                        Section {
                        
                        TextField("Address", text: $address)
                        TextField("Apt/Suite", text: $addrNum)
                        TextField("City", text: $city)
                        TextField("State", text: $state)
                        TextField("Zip", text: $zip)
                        TextField("Zip-Ext", text: $zipExt)
                        
                    } // End Section
                } // End List

        } // End var Body
    } // End Struct

由于没有人回应,我和我的朋友托尔斯泰花了很多时间来找出解决方案,我想我会 post 将它提供给可能感兴趣的其他人。托尔斯泰为 Mac 写了一个版本,而我写了这里显示的 iOS 版本。

鉴于 Google 如何对其 API 的使用收费而 Apple 没有,此解决方案为您提供了自动完成表单的地址。请记住,它并不总是完美的,因为我们对 Apple 及其地图感激不尽。同样,您必须将地址转换为坐标,然后将其转换为地标,这意味着有些地址在从完成列表中点击时可能会发生变化。很可能这对 99.9% 的用户来说都不是问题,但我想我会提到它。

在撰写本文时,我正在使用 XCode 13.2.1 和 SwiftUI 用于 iOS 15。

我用两个 Swift 文件组织了它。一个用于保存 class/struct (AddrStruct.swift),另一个是应用程序中的实际视图。

AddrStruct.swift

import SwiftUI
import Combine
import MapKit
import CoreLocation

class MapSearch : NSObject, ObservableObject {
    @Published var locationResults : [MKLocalSearchCompletion] = []
    @Published var searchTerm = ""
    
    private var cancellables : Set<AnyCancellable> = []
    
    private var searchCompleter = MKLocalSearchCompleter()
    private var currentPromise : ((Result<[MKLocalSearchCompletion], Error>) -> Void)?

    override init() {
        super.init()
        searchCompleter.delegate = self
        searchCompleter.resultTypes = MKLocalSearchCompleter.ResultType([.address])
        
        $searchTerm
            .debounce(for: .seconds(0.2), scheduler: RunLoop.main)
            .removeDuplicates()
            .flatMap({ (currentSearchTerm) in
                self.searchTermToResults(searchTerm: currentSearchTerm)
            })
            .sink(receiveCompletion: { (completion) in
                //handle error
            }, receiveValue: { (results) in
                self.locationResults = results.filter { [=10=].subtitle.contains("United States") } // This parses the subtitle to show only results that have United States as the country. You could change this text to be Germany or Brazil and only show results from those countries.
            })
            .store(in: &cancellables)
    }
    
    func searchTermToResults(searchTerm: String) -> Future<[MKLocalSearchCompletion], Error> {
        Future { promise in
            self.searchCompleter.queryFragment = searchTerm
            self.currentPromise = promise
        }
    }
}

extension MapSearch : MKLocalSearchCompleterDelegate {
    func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
            currentPromise?(.success(completer.results))
        }
    
    func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) {
        //could deal with the error here, but beware that it will finish the Combine publisher stream
        //currentPromise?(.failure(error))
    }
}

struct ReversedGeoLocation {
    let streetNumber: String    // eg. 1
    let streetName: String      // eg. Infinite Loop
    let city: String            // eg. Cupertino
    let state: String           // eg. CA
    let zipCode: String         // eg. 95014
    let country: String         // eg. United States
    let isoCountryCode: String  // eg. US

    var formattedAddress: String {
        return """
        \(streetNumber) \(streetName),
        \(city), \(state) \(zipCode)
        \(country)
        """
    }

    // Handle optionals as needed
    init(with placemark: CLPlacemark) {
        self.streetName     = placemark.thoroughfare ?? ""
        self.streetNumber   = placemark.subThoroughfare ?? ""
        self.city           = placemark.locality ?? ""
        self.state          = placemark.administrativeArea ?? ""
        self.zipCode        = placemark.postalCode ?? ""
        self.country        = placemark.country ?? ""
        self.isoCountryCode = placemark.isoCountryCode ?? ""
    }
}

出于测试目的,我调用了我的主视图文件 Test.swift。这是一个精简版供参考。

Test.swift

import SwiftUI
import Combine
import CoreLocation
import MapKit

struct Test: View {
    @StateObject private var mapSearch = MapSearch()

    func reverseGeo(location: MKLocalSearchCompletion) {
        let searchRequest = MKLocalSearch.Request(completion: location)
        let search = MKLocalSearch(request: searchRequest)
        var coordinateK : CLLocationCoordinate2D?
        search.start { (response, error) in
        if error == nil, let coordinate = response?.mapItems.first?.placemark.coordinate {
            coordinateK = coordinate
        }

        if let c = coordinateK {
            let location = CLLocation(latitude: c.latitude, longitude: c.longitude)
            CLGeocoder().reverseGeocodeLocation(location) { placemarks, error in

            guard let placemark = placemarks?.first else {
                let errorString = error?.localizedDescription ?? "Unexpected Error"
                print("Unable to reverse geocode the given location. Error: \(errorString)")
                return
            }

            let reversedGeoLocation = ReversedGeoLocation(with: placemark)

            address = "\(reversedGeoLocation.streetNumber) \(reversedGeoLocation.streetName)"
            city = "\(reversedGeoLocation.city)"
            state = "\(reversedGeoLocation.state)"
            zip = "\(reversedGeoLocation.zipCode)"
            mapSearch.searchTerm = address
            isFocused = false

                }
            }
        }
    }

    // Form Variables

    @FocusState private var isFocused: Bool

    @State private var btnHover = false
    @State private var isBtnActive = false

    @State private var address = ""
    @State private var city = ""
    @State private var state = ""
    @State private var zip = ""

// Main UI

    var body: some View {

            VStack {
                List {
                    Section {
                        Text("Start typing your street address and you will see a list of possible matches.")
                    } // End Section
                    
                    Section {
                        TextField("Address", text: $mapSearch.searchTerm)

// Show auto-complete results
                        if address != mapSearch.searchTerm && isFocused == false {
                        ForEach(mapSearch.locationResults, id: \.self) { location in
                            Button {
                                reverseGeo(location: location)
                            } label: {
                                VStack(alignment: .leading) {
                                    Text(location.title)
                                        .foregroundColor(Color.white)
                                    Text(location.subtitle)
                                        .font(.system(.caption))
                                        .foregroundColor(Color.white)
                                }
                        } // End Label
                        } // End ForEach
                        } // End if
// End show auto-complete results

                        TextField("City", text: $city)
                        TextField("State", text: $state)
                        TextField("Zip", text: $zip)

                    } // End Section
                    .listRowSeparator(.visible)

            } // End List

            } // End Main VStack

    } // End Var Body

} // End Struct

struct Test_Previews: PreviewProvider {
    static var previews: some View {
        Test()
    }
}

如果有人想知道如何生成全局结果,请将代码更改为:

self.locationResults = results.filter{[=10=].subtitle.contains("United States")}

地址结构文件中的这个:

self.locationResults = results