简体   繁体   English

SwiftUI 使用MapKit实现地址自动补全

[英]SwiftUI Using MapKit for Address Auto Complete

I have a form where the user enters their address.我有一个表格,用户可以在其中输入他们的地址。 While they can always enter it manually, I also wanted to provide them with an easy solution with auto complete so that they could just start typing their address and then tap on the correct one from the list and have it auto populate the various fields.虽然他们总是可以手动输入地址,但我也想为他们提供一个自动完成的简单解决方案,这样他们就可以开始输入他们的地址,然后从列表中点击正确的地址并让它自动填充各个字段。

I started by working off of jnpdx's Swift5 solution - https://stackoverflow.com/a/67131376/11053343我开始使用 jnpdx 的 Swift5 解决方案 - https://stackoverflow.com/a/67131376/11053343

However, there are two issues that I cannot seem to solve:但是,有两个问题我似乎无法解决:

  1. I need the results to be limited to the United States only (not just the continental US, but the entire United States including Alaska, Hawaii, and Puerto Rico).我需要将结果仅限于美国(不仅仅是美国大陆,而是整个美国,包括阿拉斯加、夏威夷和波多黎各)。 I am aware of how MKCoordinateRegion works with the center point and then the zoom spread, but it doesn't seem to work on the results of the address search.我知道 MKCoordinateRegion 如何与中心点一起工作,然后是缩放扩展,但它似乎不适用于地址搜索的结果。

  2. The return of the results provides only a title and subtitle, where I need to actually extract all the individual address information and populate my variables (ie address, city, state, zip, and zip ext).结果的返回仅提供标题和副标题,我需要在其中实际提取所有个人地址信息并填充我的变量(即地址、城市、state、zip 和 zip ext)。 If the user has an apt or suite number, they would then fill that in themselves.如果用户有 apt 或 suite 号码,他们会自己填写。 My thought was to create a function that would run when the button is tapped, so that the variables are assigned based off of the user's selection, but I have no idea how to extract the various information required.我的想法是创建一个 function,它会在点击按钮时运行,以便根据用户的选择分配变量,但我不知道如何提取所需的各种信息。 Apple's docs are terrible as usual and I haven't found any tutorials explaining how to do this. Apple 的文档和往常一样糟糕,我还没有找到任何解释如何执行此操作的教程。

This is for the latest SwiftUI and XCode (ios15+).这是针对最新的 SwiftUI 和 XCode (ios15+)。

I created a dummy form for testing.我创建了一个用于测试的虚拟表单。 Here's what I have:这是我所拥有的:

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

If anyone is wondering how to generate global results, change the code from this:如果有人想知道如何生成全局结果,请更改以下代码:

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

to this in Address Structure file:在地址结构文件中对此:

self.locationResults = results

Since no one has responded, I, and my friend Tolstoy, spent a lot of time figuring out the solution and I thought I would post it for anyone else who might be interested.由于没有人回应,我和我的朋友托尔斯泰花了很多时间找出解决方案,我想我会把它发布给任何可能感兴趣的人。 Tolstoy wrote a version for the Mac, while I wrote the iOS version shown here. Tolstoy 为 Mac 编写了一个版本,而我编写了此处显示的 iOS 版本。

Seeing as how Google is charging for usage of their API and Apple is not, this solution gives you address auto-complete for forms.看看谷歌是如何为使用他们的 API 而苹果收费的,这个解决方案为您提供了 forms 的地址自动完成。 Bear in mind it won't always be perfect because we are beholden to Apple and their maps.请记住,它并不总是完美的,因为我们感谢 Apple 和他们的地图。 Likewise, you have to turn the address into coordinates, which you then turn into a placemark, which means there will be some addresses that may change when tapped from the completion list.同样,您必须将地址转换为坐标,然后将其转换为地标,这意味着当从完成列表中点击时,某些地址可能会发生变化。 Odds are this won't be an issue for 99.9% of users, but thought I would mention it.对于 99.9% 的用户来说,这可能不是问题,但我想我会提到它。

At the time of this writing, I am using XCode 13.2.1 and SwiftUI for iOS 15.在撰写本文时,我将 XCode 13.2.1 和 SwiftUI 用于 iOS 15。

I organized it with two Swift files.我用两个 Swift 文件组织了它。 One to hold the class/struct (AddrStruct.swift) and the other which is the actual view in the app.一个保存类/结构(AddrStruct.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 ?? ""
    }
}

For testing purposes, I called my main view file Test.swift.出于测试目的,我将我的主视图文件称为 Test.swift。 Here's a stripped down version for reference.这是一个精简版供参考。

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()
    }
}

Awesome.惊人的。 Saved me a bunch of time.节省了我很多时间。

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM