解析SwiftUI佈局細節(三)地圖的基本操作
前言
前面的幾篇文章總結了怎樣用 SwiftUI 搭建基本框架時候的一些注意點(和這篇文章在相同的分類裡面,有需要了可以點進去看看),這篇文章要總結的東西是用地圖資料處理結合來說的,通過這篇文章我們能總結到的點有下面幾點:
1、SwiftUI怎樣使用UIKit的控制元件
2、網路請求到的資料我們怎樣重新整理頁面(模擬)
3、順便總結下系統地圖的一些基本使用(定位、地圖顯示、自定義大頭針等等)
(點選地圖位置會獲取經緯度,反地理編譯得到具體的位置資訊,顯示在列表中)
SwiftUI怎樣使用UIKit的控制元件
我們來總結一下,SwiftUI怎麼使用UIKit的控制元件,中間的連線就是 UIViewRepresentable,UIViewRepresentable 是一個協議。我們結合他的原始碼來具體看看它的內容:
@available(iOS 13.0, tvOS 13.0, *) @available(macOS, unavailable) @available(watchOS, unavailable) public protocol UIViewRepresentable : View where Self.Body == Never { /// The type of view to present. associatedtype UIViewType : UIView /// Creates the view object and configures its initial state. /// /// You must implement this method and use it to create your view object. /// Configure the view using your app's current data and contents of the /// `context` parameter. The system calls this method only once, when it /// creates your view for the first time. For all subsequent updates, the /// system calls the ``UIViewRepresentable/updateUIView(_:context:)`` /// method. /// /// - Parameter context: A context structure containing information about /// the current state of the system. /// /// - Returns: Your UIKit view configured with the provided information. func makeUIView(context: Self.Context) -> Self.UIViewType /// Updates the state of the specified view with new information from /// SwiftUI. /// /// When the state of your app changes, SwiftUI updates the portions of your /// interface affected by those changes. SwiftUI calls this method for any /// changes affecting the corresponding UIKit view. Use this method to /// update the configuration of your view to match the new state information /// provided in the `context` parameter. /// /// - Parameters: /// - uiView: Your custom view object. /// - context: A context structure containing information about the current /// state of the system. func updateUIView(_ uiView: Self.UIViewType, context: Self.Context) static func dismantleUIView(_ uiView: Self.UIViewType, coordinator: Self.Coordinator) /// A type to coordinate with the view. associatedtype Coordinator = Void func makeCoordinator() -> Self.Coordinator typealias Context = UIViewRepresentableContext<Self> }
上面的程式碼可以分析出 UIViewRepresentable 是一個協議,它也是遵守了 View 這個協議的,條件就是內容不能為空,它有一個關聯型別 (associatedtype UIViewType : UIView) , 看看原始碼你知道這個 UIViewType 是個關聯型別之後也明白後面中使用的一些問題( 還是得理解不能去記它的用法 ),裡面的下面兩個方法是我們使用的:
func makeUIView(context: Self.Context) -> Self.UIViewType func updateUIView(_ uiView: Self.UIViewType, context: Self.Context)
按照我的理解,第一個方法就像一個初始化方法,返回的就是你SwiftUI想用的UIKit的控制元件物件。
第二個方法是我們用來更新UIKit控制元件的方法
理解前面加我們提的關聯型別,那我們在第一個方法返回的物件型別就是你要使用的UIKit的型別,第二個方法更新的View也就是我們UIKit的控制元件。在我們的Demo中就是 MKMapView 。
接下來還有一點,我們既然點選地圖之後需要給我們點選的位置新增一個大頭針並且去獲取這個點的經緯度,那我們首先第一步就是必須得給地圖新增一個單擊手勢,具體的我們怎麼做呢?首先有一點,在SwiftUI中我們建立的View都是Struct型別,但手勢的事件是#selector(),本質上還是OC的東西,所以在事件前面都是帶有@Obic的修飾符的,但你要是Struct型別肯定是行不通的,那怎麼辦呢?其實 UIViewRepresentable 已經幫我們把這步預留好了,就是下面的這個關聯型別:
/// A type to coordinate with the view. associatedtype Coordinator = Void
具體的返回就是在下面方法,大傢俱體的看看這個方法給的簡介說明,就明白了:
/// Creates the custom instance that you use to communicate changes from /// your view to other parts of your SwiftUI interface. /// /// Implement this method if changes to your view might affect other parts /// of your app. In your implementation, create a custom Swift instance that /// can communicate with other parts of your interface. For example, you /// might provide an instance that binds its variables to SwiftUI /// properties, causing the two to remain synchronized. If your view doesn't /// interact with other parts of your app, providing a coordinator is /// unnecessary. /// /// SwiftUI calls this method before calling the /// ``UIViewRepresentable/makeUIView(context:)`` method. The system provides /// your coordinator either directly or as part of a context structure when /// calling the other methods of your representable instance. func makeCoordinator() -> Self.Coordinator
再具體點的使用我們這裡不詳細說明了,大家直接看Demo中的程式碼,我們新增完點選事件之後要做的就是一個點選座標的轉換了,你獲取到你點選的地圖的Point,你就需要通過MKMapView的點選職位轉換經緯度的方法去獲取點選位置的經緯度資訊,下面這個方法:
open func convert(_ point: CGPoint, toCoordinateFrom view: UIView?) -> CLLocationCoordinate2D
獲取到點選位置的經緯度,就可以繼續往下看了,下面會說明把點選的這個位置新增到資料來源之後怎樣去更新地圖上面的資訊。
網路請求到的資料我們怎樣重新整理頁面(模擬)
關於重新整理資料這個是比較簡單的,用到的就是我們前面提的繫結資料的模式,這點真和Rx挺像的,你建立了一個列表,然後給列表綁定了一個數組資料來源,等你網路請求到資料之後,你需要處理的就是去改變這個資料來源的資料,它就能去重新整理它繫結的UI。
在前面第一小節我們提到了地圖獲取到點選的經緯度之後怎樣更新地圖上面的資訊,其實用的也是這點,繫結資料重新整理!我們在初始化AroundMapView的時候給它綁定了 userLocationArray 這個資料,具體的就沒必要細說了,看程式碼能理解這部分的東西!
其實在我們使用UIKit的時候如許多的複用問題我們基本上都是通過寫資料再Model裡面去解決的,SwiftUI 也不例外。我們來看看我們 List 繫結部分的程式碼:
/// 地址List List(aroundViewModel.userLocationArray, id: \.self){ model in /// List 裡面的具體View內容 }.listStyle(PlainListStyle())
我們給List繫結的是 AroundViewModel 的 userLocationArray 陣列,那這個陣列我們又是怎樣定義的呢?
/// @Published var userLocationArray:Array<UserLocation> = Array()
我們使用的是 @Published 關鍵字,如果你用 @ObservedObject 來修飾一個物件 (Demo中用的是 @EnvironmentObject ),那麼那個物件必須要實現 ObservableObject 協議( AroundViewModel 實現了 ObservableObject 協議 ),然後用 @Published 修飾物件裡屬性,表示這個屬性是需要被 SwiftUI 監聽,這句話就能幫我們理解它的用法。
那接下來我們只需要關心這個資料來源的增刪就可以了。就像我們在定位成功之後新增資料一樣,程式碼如下:
init() { /// 開始定位 userLocation { (plackMark) in /// mkmapView監聽了這個屬性的,這裡改變之後是會重新整理地圖內容的 /// 在AroundMapView裡面我們以這個點為地圖中心點 self.userLocationCoordinate = plackMark.location!.coordinate print("aroundLocationIndex-1:",aroundLocationIndex) let locationModel = UserLocation(id: aroundLocationIndex, latitude: plackMark.location?.coordinate.latitude ?? 0.000000, longitude: plackMark.location?.coordinate.longitude ?? 0.000000, location: plackMark.thoroughfare ?? "獲取位置出錯啦~") self.userLocationArray.append(locationModel) print("aroundLocationIndex-1:",self.userLocationArray) /// 加1 aroundLocationIndex += 1 } }
通過上面的解析應該瞭解了請求到資料之後我們怎樣去重新整理UI的問題。
地圖使用
我們結合SwiftUI總結一下地圖的使用,這部分的程式碼去Demo看比較有效果,地圖我們使用 CoreLocation 框架,在這個 Demo 中我們使用到的關於 CoreLocation 的東西主要有下面幾點:
1、CLLocationManager & CLLocationManagerDelegate(定位)
2、CLGeocoder (地理編碼和反地理編碼)
3、CLPlacemark、CLLocation、CLLocationCoordinate2D (幾個位置類)和 MKAnnotationView (大頭針)
我們先來看看 CLLocationManager & CLLocationManagerDelegate
/// manager lazy var locationManager: CLLocationManager = { let locationManager = CLLocationManager() locationManager.delegate = self /// 導航級別 /* kCLLocationAccuracyBestForNavigation /// 適合導航 kCLLocationAccuracyBest /// 這個是比較推薦的綜合來講,我記得百度也是推薦 kCLLocationAccuracyNearestTenMeters /// 10m kCLLocationAccuracyHundredMeters /// 100m kCLLocationAccuracyKilometer /// 1000m kCLLocationAccuracyThreeKilometers /// 3000m */ locationManager.desiredAccuracy = kCLLocationAccuracyBest /// 隔多少米定位一次 locationManager.distanceFilter = 10 return locationManager
}()
上面我們定義了一個 CLLocationManager,加下來就是開始定位了,在開始定位之前我們要做的一件事就肯定是判斷使用者位置資訊有沒有開啟,具體的是否開啟許可權判斷和判斷後的回撥方法程式碼如下所示,程式碼註釋寫的很詳細,我們這裡也不做累贅。
判斷有沒有開始獲取位置許可權:
/// 先判斷使用者定位是否可用 預設是不啟動定位的 if CLLocationManager.locationServicesEnabled() { /// userLocationManager.startUpdatingLocation() /// 單次獲取使用者位置 locationManager.requestLocation() }else{ /// plist新增 NSLocationWhenInUseUsageDescription NSLocationAlwaysUsageDescription /// 提個小的知識點,以前我們寫這個內容的時候都比較隨意,但現在按照蘋果的稽核要求 /// 你必須得明確說明他們的使用意圖,不然會影響稽核的,不能隨便寫個需要訪問您的位置 /// 請求使用位置 前後臺都獲取 locationManager.requestAlwaysAuthorization() /// 請求使用位置 前臺都獲取 /// userLocationManager.requestWhenInUseAuthorization() }
獲取許可權之後的回撥方法以及各種狀態的判斷程式碼如下:
/// 使用者授權回撥 /// - Parameter manager: manager description /// open > public > interal > fileprivate > private func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { /// CLAuthorizationStatus switch manager.authorizationStatus { case .notDetermined: print("使用者沒有決定") case .authorizedWhenInUse: print("使用App時候允許") case .authorizedAlways: print("使用者始終允許") case.denied: print("定位關閉或者對此APP授權為never") /// 這種情況下你可以判斷是定位關閉還是拒絕 /// 根據locationServicesEnabled方法 case .restricted: print("訪問受限") @unknown default: print("不確定的型別") }
}
當定位許可權開啟之後我們就開始了獲取位置,單次獲取具體位置的方法呼叫上面程式碼有,就是 requestLocation() 方法,接下來就是成功和失敗的方法處理了,下面兩個方法:
/// 獲取更新到的使用者位置 /// - Parameters: /// - manager: manager description /// - locations: locations description func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { print("緯度:" + String(locations.first?.coordinate.latitude ?? 0)) print("經度:" + String(locations.first?.coordinate.longitude ?? 0)) print("海拔:" + String(locations.first?.altitude ?? 0)) print("航向:" + String(locations.first?.course ?? 0)) print("速度:" + String(locations.first?.speed ?? 0)) /* 緯度34.227653802098665 經度108.88102549186357 海拔410.17602920532227 航向-1.0 速度-1.0 */ /// 反編碼得到具體的位置資訊 guard let coordinate = locations.first else { return } reverseGeocodeLocation(location: coordinate ) } /// 獲取失敗回撥 /// - Parameters: /// - manager: manager description /// - error: error description func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { print("定位Error:" + error.localizedDescription) guard let locationFail = self.locationFail else{return} locationFail(error.localizedDescription) }
這樣我們就拿到你的具體位置資訊,回到給你的就是一個元素是 CLLocation 型別的陣列,我們在Demo中只取了First,你拿到的是經緯度,你要想獲取這個經緯度的具體位置資訊就得經過反地理編碼,拿到某某市區某某街道某某位置的資訊,在CoreLocation中做地理編碼和反地理編碼的就是 CLGeocoder 這個類,它的 reverseGeocodeLocation 就是反地理編碼方法, 地理拜納姆的方法就是 geocodeAddressString 。具體的我們看看Demo中的方法:
地理編碼方法:(具體位置資訊 -> 經緯度)
/// 地理編碼 /// - Parameter addressString: addressString description private func geocodeUserAddress(addressString:String) { locationGeocoder.geocodeAddressString(addressString){(placeMark, error) in print("地理編碼緯度:",placeMark?.first?.location?.coordinate.latitude ?? "") print("地理編碼經度:",placeMark?.first?.location?.coordinate.longitude ?? "") } }
反地理編碼方法:( 經緯度 -> 具體位置資訊 )
/// 反地理編碼定位得到的位置資訊 /// - Parameter location: location description private func reverseGeocodeLocation(location:CLLocation){ locationGeocoder.reverseGeocodeLocation(location){(placemark, error) in /// city, eg. Cupertino print("反地理編碼-locality:" + (placemark?.first?.locality ?? "")) /// eg. Lake Tahoe print("反地理編碼-inlandWater:" + (placemark?.first?.inlandWater ?? "")) /// neighborhood, common name, eg. Mission District print("反地理編碼-subLocality:" + (placemark?.first?.subLocality ?? "")) /// eg. CA print("反地理編碼-administrativeArea:" + (placemark?.first?.administrativeArea ?? "")) /// eg. Santa Clara print("反地理編碼-subAdministrativeArea:" + (placemark?.first?.subAdministrativeArea ?? "")) /// eg. Pacific Ocean print("反地理編碼-ocean:" + (placemark?.first?.ocean ?? "")) /// eg. Golden Gate Park print("反地理編碼-areasOfInterest:",(placemark?.first?.areasOfInterest ?? [""])) /// 具體街道資訊 print("反地理編碼-thoroughfare:",(placemark?.first?.thoroughfare ?? "")) /// 回撥得到的位置資訊 guard let locationPlacemark = placemark?.first else{return} guard let locationSuccess = self.locationSuccess else{return} locationSuccess(locationPlacemark) /// 地理編碼位置,看能不能得到正確經緯度 self.geocodeUserAddress(addressString: (placemark?.first?.thoroughfare ?? "")) } }
最後我們梳理一下關於大頭針的幾個類,我們在專案中使用的是 MKPointAnnotation
MKPointAnnotation 繼承與 MKShape 遵守了 MKAnnotation 協議 , MKAnnotation 就是底層的協議了,像它裡面的title,image這些屬性我們就不提了,大家可以點進去看看原始碼。
MKMapView 有個 MKMapViewDelegate 代理方法,它具體的方法可以點進這個協議去看,裡面有個方法是
- (nullable MKAnnotationView *)mapView:(MKMapView *)mapView viewForAnnotation:(id <MKAnnotation>)annotation;
它返回的是一個 MKAnnotationView ,這個方法也為每個 大頭針 MKAnnotation 提供了一個自定義的View,也就是我們自定義大頭針的位置。這樣地圖基本的東西我們也就說的差不多了,最後要提的一點是獲取到位置的經緯度型別,我們經常使用的百度、高德等的地圖它們定位得到的經緯度座標型別是不一樣的,它們之間的聯絡我們再梳理一下。
什麼是國測局座標、百度座標、WGS84座標 ?三種座標系說明如下:
* WGS84:表示GPS獲取的座標;
** GCJ02:是由中國國家測繪局制訂的地理資訊系統的座標系統。由WGS84座標系經加密後的座標系。
*** BD09:為百度座標系,在GCJ02座標系基礎上再次加密。其中bd09ll表示百度經緯度座標,bd09mc表示百度墨卡托米制座標;百度地圖SDK在國內(包括港澳臺)使用的是BD09座標;在海外地區,統一使用WGS84座標。
參考文章:
專案地址
百度地圖座標型別說明文件
&n