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

问题描述 投票:0回答:3

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

我首先使用 jnpdx 的 Swift5 解决方案 - https://stackoverflow.com/a/67131376/11053343

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

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

  2. 结果的返回仅提供标题和副标题,我需要实际提取所有单独的地址信息并填充我的变量(即地址、城市、州、邮政编码和邮政编码分机号)。如果用户有公寓号或套房号,他们会自己填写。我的想法是创建一个在点击按钮时运行的函数,以便根据用户的选择分配变量,但我不知道如何提取所需的各种信息。 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
swiftui mapkit mkcoordinateregion mklocalsearch
3个回答
19
投票

由于没有人回复,我和我的朋友托尔斯泰花了很多时间找出解决方案,我想我会将其发布给其他可能感兴趣的人。托尔斯泰编写了 Mac 版本,而我编写了此处显示的 iOS 版本。

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

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

我用两个 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。这是一个精简版供参考。

测试.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()
    }
}

1
投票

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

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

在地址结构文件中:

self.locationResults = results

0
投票

这是已接受答案的一个更简单的版本,无需使用组合

    class SearchManager: NSObject, ObservableObject {
        @Published var completions: [MKLocalSearchCompletion] = []
        
        private let completer = MKLocalSearchCompleter()
        
        override init() {
            super.init()
            completer.resultTypes = .address
            completer.delegate = self
        }
        
        func search(for term: String) {
            completer.queryFragment = term
        }
    }

    extension SearchManager: MKLocalSearchCompleterDelegate {
        func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
            completions = completer.results
        }
        
        func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) {
            print("Error: \(error.localizedDescription)")
        }
    }

这是 SwiftUI 视图

    struct Test: View {
        @StateObject var searchManager = SearchManager()
        @State private var searchTerm: String = ""
        @State private var searchTimer: Timer?
        @FocusState var isFocused: Bool
        @State private var addresses: [CNPostalAddress] = []
        
        var body: some View {
           List {
                Section {
                    Button("Start typing your street address and you will see a list of possible matches."){ isFocused = false}
                }
                Section {
                    TextField("Address", text: $searchTerm)
                        .focused($isFocused, equals: true)
                    if isFocused && !searchTerm.isEmpty {
                        ForEach(searchManager.completions, id: \.self) { completion in
                            Button {
                                isFocused = false
                                getMapItem(from: completion)
                            } label: {
                                VStack(alignment: .leading) {
                                    Text(completion.title)
                                    Text(completion.subtitle)
                                        .font(.system(.caption))
                                    
                                }
                            }
                        }
                    }
                }
                .onChange(of: searchTerm) { newTerm in
                   startSearchTimer()
                }
                Section{
                    ForEach(addresses, id: \.self){ add in
                            Text(add.street)
                            Text(add.city)
                            Text(add.state)
                            Text(add.postalCode)
                            Text(add.country)  
                    }
                }
            }
        }
        private func getMapItem(from localSearchCompletion: MKLocalSearchCompletion) {
            let searchRequest = MKLocalSearch.Request(completion: localSearchCompletion)
            let search = MKLocalSearch(request: searchRequest)
            search.start { (response, error) in
                guard error == nil, let mapItems = response?.mapItems else { return }
                var postalAddresses: [CNPostalAddress] = []
                for item in mapItems {
                    let clPlacemark = CLPlacemark(placemark: item.placemark)
                    guard let address = clPlacemark.postalAddress else { return }
                    postalAddresses.append(address)
                }
                self.addresses = postalAddresses
            }
        }
        private func startSearchTimer() {
            searchTimer?.invalidate() // Invalidate previous timer if exists
            searchTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: false) { _ in
                searchManager.search(for: searchTerm)
            }
        }
    }
© www.soinside.com 2019 - 2024. All rights reserved.