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

10
我有一个表单,用户可以输入他们的地址。尽管他们总是可以手动输入,但我也想提供自动完成的简便解决方案,这样他们只需开始键入他们的地址,然后从列表中点击正确的地址,并使其自动填充各个字段。
我首先从jnpdx的Swift5解决方案开始工作 - https://dev59.com/gVwX5IYBdhLWcg3wyCDT#67131376 然而,有两个问题我无法解决:
  1. 我需要结果仅限于美国(不仅仅是大陆美国,而是包括阿拉斯加、夏威夷和波多黎各的整个美国)。我知道MKCoordinateRegion如何使用中心点和缩放范围,但它似乎不能在地址搜索的结果上工作。

  2. 结果只提供标题和副标题,我需要实际提取所有个人地址信息并填充我的变量(例如地址、城市、州、邮政编码和邮政编码扩展)。如果用户有公寓或套房号码,他们将自己填写。我的想法是创建一个函数,在按钮被点击时运行,这样基于用户的选择来分配变量,但我不知道如何提取所需的各种信息。苹果的文档像往常一样糟糕,我没有找到任何解释如何做这件事的教程。

这是最新的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
2个回答

19

由于没有人回复,我和我的朋友托尔斯泰花了很多时间来找出解决方案,并且我想发布它,以供其他有兴趣的人使用。托尔斯泰写了一个Mac版本,而我写了在这里展示的iOS版本。

考虑到谷歌收费使用他们的API,而苹果不收费,这个解决方案为表单提供了地址自动完成功能。请记住,由于我们受到苹果和他们的地图的约束,它不总是完美的。同样,您必须将地址转换为坐标,然后将其转换为地标,这意味着当从完成列表中点击时,可能会有一些地址会发生变化。可能对99.9%的用户来说这不是问题,但我想提一下。

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

我使用了两个Swift文件进行组织。一个用于保存类/结构(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 { $0.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()
    }
}

看起来是个不错的解决方案,但在private var currentPromise这一行上我得到了泛型类型'Result'特化了太多类型参数(得到了2个,但期望1个)的错误提示。 - soleil
MKPlacemarkCLPlacemark的子类。所以不需要进行额外的反向地理搜索。因此,您可以直接获取地标(placemark)而不是获取CLCoordinate,然后立即初始化ReversedGeoLocation。文档在这里 - undefined

1
如果有人想知道如何生成全局结果,请按以下方式更改代码:

self.locationResults = results.filter{$0.subtitle.contains("United States")}

将其转换为地址结构文件中的内容:

self.locationResults = results

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接