原文:A Swift Tutorial for Google Maps SDK
本文由CocoaChina和百度智客聯盟共同翻譯,譯者:slowwind
在iOS中使用地圖為開發者提供了無線的可能性,程序書籍中通常用整個章節來說明在iOS中如何使用地圖。從僅僅在地圖顯示某個位,到繪制多個中間位置組成一條旅行路線,或者甚至可以用完全不同的方式來探索地圖的可能性,處理這些事情毫無疑問是一次極好的經歷,並能得到讓人驚歎的成果。
到iOS 5.1(並包含此版本)為止,iOS使用Google Mobile Maps服務來提供對地圖的訪問以及所有的相關服務。從那之後,事情發生了變化,Apple推出了Map Kit,這是一款全新的完全內置於iOS中的框架,並被使用至今。從Apple停止使用Google的地圖服務時,Google便決定創造自己的、適用於包括iOS在內所有平台的地圖SDK,從而與Map kit或其他平台的其他地圖SDK競爭。目前,許多開發者都使用此SDK,Google在此領域則成為了強者。因此為iOS上的Google Maps SDK寫點東西絕對是值得的。
在寫作此入門教程之時,iOS的Google Maps SDK版本是1.9.2。它包括許多特性,其中絕大多數包含在地圖的web版本中,而另一方面,則也有許多特性在移動平台上缺失而無法使用。值得一提的是,此版本SDK所占硬盤空間較大,而如果你要拷貝框架源代碼到你的工程,這就成了必須考慮的事情。然而,它提供的特性是十分有趣和重要的,我想都沒想就拷貝了。
與我其他的教程相反,這次我的介紹不會很長。這主要因為我們在接下來的部分有許多事情要做,而在這裡開始討論不同特性是完全漫無目標的,因此我們會在之後詳細地審視它們。我想說的是,准備好迎接那些真正有趣的東西吧,如果你過去從未使用過 Google Maps SDK,那麼你肯定會喜歡上它的。在接下來的章節裡我會全面闡述開發者在使用地圖過程中所要處理的最常見任務。簡而言之,下面是我們將要學習的:
如何在地圖上顯示用戶當前位置
如何定位自定義地址
如何繪制路徑
如何在路徑中添加中間點(路徑點)
更多…
因此,為不浪費更多時間,讓我們將話題轉移到所有今天將會遇到的好東西吧。
Demo概覽
介紹下教程中使用的Demo,首先無須從頭開始創建新工程,相反你可以用一個初始工程作為基礎,然後我們向其中添加新功能。所以要先下載它,並在Xcode中打開,做好准備。
此示例程序實際是一個單視圖程序,並包含三個子視圖:
屏幕頂部邊沿的工具欄,包含一些工具欄按鈕。在初始工程中,這些工具欄按鈕項已經與IBAction方法連接。
占據了大部分可用屏幕區域的視圖(UIView),這裡將顯示地圖。
底部的標簽,在這裡我們將顯示路線的長度和時耗。
接下來,頂部工具欄的工具欄按鈕項從左到右如下:
指定自定義地址
設置路徑的起始和結束位置
改變行進模式
改變地圖類型
在接下來的教程部分中,我們會看到以上所有這些。而目前,你所要做的是快速浏覽起始工程,從而做到在其中能輕松翻閱。下面你見到的是一組屏幕截圖,作為本教程我們要達到的目標的一個樣例。
重要提示:如果你下載和測試最終的應用,確認設置了你的API key到AppDelegate.swift文件。閱讀下一部分來獲得更多有關信息。
獲取API Key
使用Google Maps SDK前,首先需要獲取一個API Key。這意味著我們需要從Google獲取一段特殊的字符串,以便稍後從app中調用Google API。此API Key需要從Google Developers Console取得,正如此名稱所暗示的,這是跟開發者有關的一個"地方"。顯然,你必須要有Google賬號,如果你還沒有賬號,先去創建一個。另一種情況,如果你有Google賬號,那麼可以順利地往下繼續。
為獲取在應用中使用的API Key,你可以忽略這裡給出的指示而遵循Google給出的入門指南。但是,在此教程寫作之時,Google Developers Console的接口並不與以上指南相符(顯然此接口更新了,而指南還沒有更新),因此我建議你按照如下步驟來獲取你的API Key。
那麼,我們開始吧。使用你的Google賬號,登錄Developers Console並點擊如下屏幕顯示的API Project選項:
然後,點擊展開API & auth目錄,並選擇API選項,這樣就找到Google提供的所有可用API,但我們感興趣的只是iOS的Google Maps SDK。根據浏覽器的不同,API要麼顯示成列表,必須滾動它直到找到地圖API;要麼顯示成組,這樣你只需要點擊合適的組即可。以下兩個屏幕截圖說明了這兩個選項:
選擇iOS的Google Maps SDK API,進入新頁面,在這裡你要做的是使能此API:
完此步,點擊Credentials選項,又一次來到API & auth目錄之下。在新的頁面裡,點擊Create new Key按鈕,此按鈕位於左下角:
點擊此按鈕,彈出一個對話框窗口,詢問你要創建的Key的類型。顯然,你必須點擊iOS Key按鈕,從而為我們的程序產生一個合法的Key。
在下一個對話框窗口中你必須輸入或粘貼應用的bundle identifier(bundle ID)。起始工程的bundle ID值為com.appcoda.GMapsDemo,只需拷貝並粘貼它,如下所示:
上一步是必需的,從而使我們的應用被授權使用Google Maps API。注意,如果你計劃在更多的應用程序中使用相同的API Key,那麼你首先必須在此對話框(當然在此之後你能找到它)添加它們的bundle IDs。
點擊Create按鈕,則所需的API Key便生成了。在你返回的窗口的右下角,你會看到一塊與下圖相似的區域:
如上面所示,點擊Edit allowed iOS applications可以增加或移除應用的bundle IDs。你也能重新生成此鍵值,但目前這不是必需的。你所需要做的僅是選擇此API Key並拷貝它,這樣你就可以在之後粘貼它到工程中。
工程配置
當使用非iOS提供的SDK時有一個缺點,這是因為工程總依賴於將要使用的SDK,需要作某種程度的配置。因此,在此部分我們要做一些必需的初始化步驟來使用Google Maps SDK,否則就不可能使用它。注意以下描述的一些步驟能在Google的官方文檔中找到。而其他一些步驟並沒有在官方文檔中描述,你必須搜索一下才能查到它們。當然,在本文檔中你無需自行搜索了,步驟都在這裡了。
首先,你需要下載Google Maps SDK(此框架必須添加到工程中)。你可從此處獲取它;點擊Download the SDK按鈕,並在你跳轉到的頁面中選擇建議的版本(目前為1.9.2)。一旦你獲取到它 ,解壓並往下繼續。
如果你還沒有打開起始工程,那麼在Xcode中打開它。然後從Finder拖動GoogleMaps.framework到Project Navigator。當Xcode詢問你時,請保證選擇Copy items if needed選項,並且當然不要忘記勾選GMapsDemo:
接下來,返回到Finder,並再次點擊GoogleMaps.framework。從Resources文件夾中選擇GoogleMaps.bundle並把它拖入到Xcode的Project Navigator中。將此bundle添加到工程的時候,與以上描述作相同的選擇。
進行到此處,下面兩項應該出現在你的工程裡(Google Maps SDK 組是我為方便更好地組織文件而創建的):
為使Google Maps SDK正常工作,需要包含幾個其他的框架到工程中。在我給你必須添加的框架和靜態庫列表之前,請保證在Project Navigator中選擇工程,點擊Build Phases並展開Link Binary With Libraries選項。使用加號(+)按鈕來按下面的列表來逐項增加:
AVFoundation.framework
CoreData.framework
CoreLocation.framework
CoreText.framework
GLKit.framework
ImageIO.framework
libc++.dylib
libicucore.dylib
libz.dylib
OpenGLES.framework
QuartzCore.framework
SystemConfiguration.framework
這兒是你最終應該看到的:
接下來,點擊Build Setting標簽頁,並尋找Other Linker Flags 設置。找到之後,將-ObjC賦值給它。這是因為我們要將Objective-C編寫的地圖SDK鏈接到目前的Swift工程。
說到鏈接,不幸的是還沒有真正直接的方式在Swift中嵌入Objective-C的代碼。而在我們的情況中,我們需要這麼做,因為我們必須導入Google地圖靜態庫的Objective-C頭文件。實際上,我們需要的是Xcode為我們創建一個Objective-C頭文件(.h),但沒有直接的方法可以做到,我們將間接地按如下步驟解決此問題:
1.在鍵盤上敲擊Command + N鍵,在工程中增加一個新文件。
2.在文件模板中,選擇Objective-C File選項。按照向導進行,命名文件為temp並創建它。
3.完成向導後,Xcode會顯示如下信息,務必點擊Yes按鈕。
4.除了temp.m,也將創建一個命名為 GMapsDemo-Bridging-Header.h的新文件。選擇它在編輯器中打開,並按添加如下代碼行:
import
5.刪除temp.m文件,我們並不需要它。
最後,是來使用我們之前小節中生成的API Key的時候了。打開AppDelegate.swift文件,並在application(_:didFinishLaunchingWithOptions:) 函數中增加如下代碼行:
func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool { // Override point for customization after application launch. GMSServices.provideAPIKey("YOUR_API_KEY") return true }
當然,你必須將"YOUR_API_KEY"字符串替換成你在Google Developers Console那裡生成的真正API Key。值得注意的是,如果沒有按前述步驟操作,比如沒有橋接和導入頭文件,那麼就不可能訪問GoogleMaps框架和它的類(就像上述代碼片段所示)。
工程的初始化准備現在完成了,我們從現在開始可以投入到編碼中了。如同我在此部分開始所說,此類配置在使用外部SDK時是無法避免的,但值得慶幸的是,在這種情況下,所有的前期工作即便繁多,卻都是很容易的。
顯示地圖
現在工程的所有必需初始化准備都完成了,讓我們來開始做一些真正的編程工作,將Google地圖第一次添加到應用中。就如你接下來將要看到的,這十分簡單,按合理順序來完成。
如果你打開Main.storyboard文件,你會注意到在View Controller場景中有一個視圖(UIView)位於畫布正中央。
此視圖將作為我們的地圖視圖,但為了按此目的使用它,我們需要對它做一些修改。因此,首先選中它,然後打開Utilities面板。找到Identity Inspector,在類的選項區域中將類名設置為GMSMapsView。此GMSMapsView是Google Maps框架的一部分,實際上是UIView的子類。
在此視圖中還有一處修改需要完成,但這次我們必須到ViewController.swift文件中。在類的頂部你可以找到如下的IBOutlet屬性聲明:
@IBOutlet weak var viewMap: UIView!
在這裡我們所需要做的是改變viewMap屬性的類,如下所示,從UIView變為GMSMapView:
@IBOutlet weak var viewMap: GMSMapView!
現在,我們可將viewMap作為我們的地圖視圖了。
將Google地圖添加到應用中真的十分簡單。只需到viewDidLoad方法中增加如下兩行:
override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view, typically from a nib. let camera: GMSCameraPosition = GMSCameraPosition.cameraWithLatitude(48.857165, longitude: 2.354613, zoom: 8.0) viewMap.camera = camera }
設置地圖時的第一項任務是初始化一個GMSCameraPosition對象。利用此對象,我們指定將在地圖上顯示的初始位置。上圖的坐標以巴黎地圖為中心,但你可以找到其他的坐標來替代(這裡只是一個演示)。除了坐標之外,我們還需要指定初始的地圖縮放級別。
以上對象被賦值給地圖視圖的camera屬性。這樣,實際地圖可以成功的在viewMap視圖中渲染。
現在你可以第一次測試此應用了。因此,為測試它而使用真實的設備或模擬器,並等待地圖出現在視圖上吧。
在我們進入下一部分前,稍微擴展一下我們所做的東西。讓我們創建一個動作表單(action sheet),使我們可以選擇另一種地圖類型。Maps SDK在iOS中提供三種類型:
普通
地形
混合
普通模式是默認模式,它如上圖所示。其他兩個選項根據各自的類型改變了地圖的外觀,如果我們能將它們設置到我們的地圖中,那可就太好了。
地圖視圖包含了一個命名為mapType的屬性。在Google Maps SDK中有三個常量來表示每種類型,此屬性必須設置成其中一個。這些常量是:
kGMSTypeNormal
kGMSTypeTerrain
kGMSTypeHybrid
現在,我們到hangeMapType(_:) IBAction方法中,在這裡我們將增加必要代碼來顯示action sheet並為地圖視圖設置合適的地圖類型。下面代碼片段包含所有你需要的:
@IBAction func changeMapType(sender: AnyObject) { let actionSheet = UIAlertController(title: "Map Types", message: "Select map type:", preferredStyle: UIAlertControllerStyle.ActionSheet) let normalMapTypeAction = UIAlertAction(title: "Normal", style: UIAlertActionStyle.Default) { (alertAction) -> Void in self.viewMap.mapType = kGMSTypeNormal } let terrainMapTypeAction = UIAlertAction(title: "Terrain", style: UIAlertActionStyle.Default) { (alertAction) -> Void in self.viewMap.mapType = kGMSTypeTerrain } let hybridMapTypeAction = UIAlertAction(title: "Hybrid", style: UIAlertActionStyle.Default) { (alertAction) -> Void in self.viewMap.mapType = kGMSTypeHybrid } let cancelAction = UIAlertAction(title: "Close", style: UIAlertActionStyle.Cancel) { (alertAction) -> Void in } actionSheet.addAction(normalMapTypeAction) actionSheet.addAction(terrainMapTypeAction) actionSheet.addAction(hybridMapTypeAction) actionSheet.addAction(cancelAction) presentViewController(actionSheet, animated: true, completion: nil) }
以上的實現中沒有什麼特別困難的。如你看到的,在每個action中(除了cancel action)我們指定了地圖視圖的合適地圖類型。
現在再次運行此應用。這次你僅需使用頂部工具欄的最右邊的工具欄按鈕項,就可以簡單地改變地圖類型。
我的位置
在屏幕上顯示地圖絕對是有趣的,但是如果它沒有指向對象,那就沒什麼用處。因此,從此部分開始,我們將逐步添加新功能使得我們的地圖真正實用。
當使用地圖時,最通常的任務是指出用戶的當前位置。這做起來很簡單,但需要用戶的同意才能獲取到當前位置。從iOS 8開始,向用戶申請權限分為兩步:首先,必須在工程的.plist文件中添加一個新條目。此條目以所需權限的類型作為關鍵字,以描述信息作為值,此描述信息會在應用首次運行時在一個警告視圖中呈現給用戶。第二步是以編程的方式請求權限,如果沒有顯示警告給用戶,這將會觸發警告控制器的出現。我們待會兒就將看到。
當使用Core Location服務時(我們在這會使用得很多),應用會監控位置更新,要麼一直監控(即使應用在後台運行),要麼僅當應用被使用時才監控。上述提到的那種權限就是這些事情;取決於我們需要應用如何去監控位置變化,我們在.plist文件中提供相應的關鍵字,然後我們在代碼裡請求相應的權限。在我們的情況中沒有讓應用始終監控位置更新的需求。在應用沒有運行時,如果地圖沒有任何的改變發生,監控位置更新是沒有任何意義的,而且對設備的電池也是極大的浪費。
在Project Navigator中定位並打開info.plist文件。然後,到Editor > Add Item目錄增加一個新的條目。以字符串NSLocationWhenInUseUsageDescription作為關鍵字,並填寫任意你喜歡的描述作為值。可選擇使用如下為:"允許訪問你的位置,你可以在地圖上定位你的位置。"(不包括引號部分)。
現在回到ViewController.swift文件,讓我們聲明兩個接下來我們馬上要用到的兩個屬性。在類的頂部,增加如下兩行,緊跟在IBOutlet屬性聲明之後:
3var locationManager = CLLocationManager() var didFindMyLocation = false
locationManager屬性會被用於向用戶請求權限來追蹤他的位置,並基於授權狀態來確定顯不顯示他的當前位置。didFindMyLocation 標識在之後會被用到,從而我們可知道用戶的當前位置是否在地圖上被定位了,並最終避免不必要的位置更新。
但是,先做重要的事情。如上圖的代碼片段所示,locationManager對象在聲明時就被初始化了。然而,這是不夠的。在viewDidLoad方法中,我們必須設置ViewController類作為它的代理,並向用戶請求權限。如下:
override func viewDidLoad() { ... locationManager.delegate = self locationManager.requestWhenInUseAuthorization() }
要注意的是,在此處的請求類型必須匹配我們在.plist文件中增加的請求類型。通過調用位置管理器對象的requestWhenInUseAuthorization()方法,要麼應用第一次運行,會顯示一個警告視圖給用戶,向他請求權限來跟蹤他的當前位置,要麼系統會返回他之前指定的偏好。在任何情況下,我們都必須通過CLLocationManagerDelegate協議的特定的代理方法來檢查應用的授權狀態,但在此之前,讓我們繼承此協議。找到類的頭文件中類所在行,增加協議聲明:
class ViewController: UIViewController, CLLocationManagerDelegate { ... }
現在讓我們看看代理方法:
func locationManager(manager: CLLocationManager!, didChangeAuthorizationStatus status: CLAuthorizationStatus) { if status == CLAuthorizationStatus.AuthorizedWhenInUse { viewMap.myLocationEnabled = true } }
在此時我們意識到,我們感興趣的唯一情形是授權狀態與上圖所示的值相匹配的時刻。在此種情況下,我們只需設置地圖視圖的myLocationEnabled標識為true,Maps SDK就會去完成尋找用戶當前位置的艱苦工作。
用戶的當前位置被描述為名為myLocation的地圖視圖對象屬性。關於此屬性的好消息是,它是KVO兼容的(鍵-值 觀察兼容的),意味著我們可以簡單地通過它的值來觀察變化,這樣當用戶位置更新時我們就能夠知道了。如果你想要知道更多關於KVO機制的,你可以看一下此入門教程。
因此,基於以上所述,我們下一步是讓我們的類觀察到viewMap對象中myLocation屬性的變化。再次回到viewDidLoad方法中,增加下述代碼行:
override func viewDidLoad() { ... viewMap.addObserver(self, forKeyPath: "myLocation", options: NSKeyValueObservingOptions.New, context: nil) }
當Google Maps SDK定位到用戶的當前位置時,我們真正想得到的是,此位置位於地圖中央,且顯示為眾所周知的藍點。事實上此藍點會自動顯示,因此我們無須為此作額外編碼。因此,只要用戶的位置被定位,我們就把此當前位置顯示在地圖上,如果我們還沒這麼做,那麼添加如下代碼:
override func observeValueForKeyPath(keyPath: String, ofObject object: AnyObject, change: [NSObject : AnyObject], context: UnsafeMutablePointer) { if !didFindMyLocation { let myLocation: CLLocation = change[NSKeyValueChangeNewKey] as CLLocation viewMap.camera = GMSCameraPosition.cameraWithTarget(myLocation.coordinate, zoom: 10.0) viewMap.settings.myLocationButton = true didFindMyLocation = true } }
如同我所說過的,didFindMyLocation標識能幫助應用來避免不必要的位置更新。更進一步的,我們從變更字典中可得到地圖中的新位置。此字典由系統以參數形式傳遞給上述方法,我們使用NSKeyValueChangeNewKey關鍵字能獲取到我們觀察的屬性改變後的新值。想要使用此位置信息,我們新建了一個GMSCameraPosition對象,並將它直接賦值給地圖視圖的camera屬性。這會使攝像機(Camera)以新位置為中心。在這裡,另一個有趣的任務是通過設置地圖視圖的屬性設置中的myLocationButton屬性為true,我們設法在屏幕的右下角顯示了一個可返回當前位置的默認按鈕,以免用戶在地圖上左右移動時無法返回當前位置。此按鈕由Google Maps SDK提供。也有一個指南針按鈕可以同樣的方式使能,但在這兒我們並不需要它。
現在你可以再試一試此應用。如果你在模擬器中測試它,你必須為它提供一個假位置,因為你的真實位置不會在模擬器中監測到。要做到這一點,只需簡單編輯工程的scheme,在Option標簽頁中選擇一個默認位置:
定位自定義位置
比找到當前用戶位置更進一步的,是另一個使用地圖時非常通用的場景--定位自定義位置。通常通過提供給地圖API某種地址(比如街道名,城市,國家,區域),而不是坐標(經緯度)值,來完成此項功能的,因為沒人能記住這些坐標值。但是你有沒有真正好奇過,地圖服務不知道坐標值,怎麼可能定位到你提供的確切地址呢?
答案是--有一個名為geocoding的進程,在地圖定位之前,通過此進程將地址翻譯成經緯度值。甚至於,地圖服務(例如Google Maps)根本不關心我們提供的地址,因為它將地址"翻譯"為地理坐標。因此,真正有趣之處在於我們如何設法向Google Maps SDK給出地址,而此地址又如何轉換成地理坐標,並且又怎樣顯示到我們的地圖上。
值得慶幸的是,Google提供了Google Geocoding API,一項web服務,簡而言之,接受包含真實地址的請求,並返回經度和緯度值(當然是不同於地址的其他值)作為響應。如果你真的對使用Google地圖感興趣,那麼你絕對需要好好讀讀geocoding API文檔。實際上,我鼓勵你現在就訪問此文檔並快速浏覽服務細節。它會幫你更好的理解我們下一步將要做什麼。
geocoding API的響應數據要麼使用JSON格式,要麼使用XML格式。在此示例中,我們將使用JSON格式,因為它僅使用一行代碼即可轉換成Swift(或Objective-C)的數據表示(字典或數組)。如下的示例從Google文檔中直接截取過來,並且是JSON響應的一個極好例子:
{ "results" : [ { "address_components" : [ { "long_name" : "1600", "short_name" : "1600", "types" : [ "street_number" ] }, { "long_name" : "Amphitheatre Pkwy", "short_name" : "Amphitheatre Pkwy", "types" : [ "route" ] }, { "long_name" : "Mountain View", "short_name" : "Mountain View", "types" : [ "locality", "political" ] }, { "long_name" : "Santa Clara", "short_name" : "Santa Clara", "types" : [ "administrative_area_level_2", "political" ] }, { "long_name" : "California", "short_name" : "CA", "types" : [ "administrative_area_level_1", "political" ] }, { "long_name" : "United States", "short_name" : "US", "types" : [ "country", "political" ] }, { "long_name" : "94043", "short_name" : "94043", "types" : [ "postal_code" ] } ], "formatted_address" : "1600 Amphitheatre Pkwy, Mountain View, CA 94043, USA", "geometry" : { "location" : { "lat" : 37.42291810, "lng" : -122.08542120 }, "location_type" : "ROOFTOP", "viewport" : { "northeast" : { "lat" : 37.42426708029149, "lng" : -122.0840722197085 }, "southwest" : { "lat" : 37.42156911970850, "lng" : -122.0867701802915 } } }, "types" : [ "street_address" ] } ], "status" : "OK" }
如果你完整看下來,此響應以兩種方式表示了找到的地址:作為地址組件(一個數組,以字典作為數組項),和作為格式化地址,此處全部地址作為單個字符串給出。除此之外,在以上例子中有兩點更有趣的地方:包含了經度和緯度值的地理字典,和顯式的描述了向geocoding的web服務作出請求的結果狀態。
從以上所述之中,我們將會在應用中用到三種東西:格式化地址,地理詳情和狀態。我建議你看一看Google文檔中的狀態碼;在這裡討論它已經超出了此教程的范圍。在你往下進行之前,請確保你能明白地"讀"懂JSON的數據表示。如果你對JSON感到不適應,那麼你應該要到這兩個鏈接看看:這裡和這裡。我想提到的是以"某關鍵字"開始的是字典,而以中括號([)開始的是數組。
現在回到我們的應用,我們將要創建一個新類來向geocoding API提出請求和處理從其返回的響應。我這裡談論新類的有兩個理由:
此代碼能夠更多的被復用。
之後我們會使用到另一個Google web服務(API),把此類任務集中在一處是十分良好的習慣。
因此,創建一個新類,並確保它是NSObject的子類。將它命名為MapTasks並加入到工程中,此時你可繼續往下進行了。
讓我們在MapTasks.swift文件中開始工作吧,先聲明一些我們接下來要用到的屬性:
let baseURLGeocode = "https://maps.googleapis.com/maps/api/geocode/json?" var lookupAddressResults: Dictionary! var fetchedFormattedAddress: String! var fetchedAddressLongitude: Double! var fetchedAddressLatitude: Double!
baseURLGeocode是我們向geocoding發請求時所使用的URL。當然,我們將會把地址作為參數添加到它之上,但由於地址是一個動態值,需要以編程的方式來實現。在lookupAddressResults字典中我們存儲從結果中返回的第一個地址。值得注意的是,發給geocoding一個地址,它有可能返回多個結果,但為了簡單起見,我們僅保留第一個結果。最後,接下來的三個屬性中我們保存的值如其名字所示。
在此我們必須為類增加一個初始化函數,從而我們可以在之後的ViewController類中創建此類的對象。由於沒有自定義初始化方法的必要,讓我們使用默認的吧:
override init() { super.init() }
現在,讓我們創建如下所示的新方法:
3func geocodeAddress(address: String!, withCompletionHandler completionHandler: ((status: String, success: Bool) -> Void)) { }
第一個參數是我們想要在地圖上定位的地址。第二個參數是一個完成句柄,一旦我們接收並處理完響應數據,此句柄即被調用。此應用僅在此完成句柄被此方法調用後才會將結果顯示到地圖上。正如你所看到的,此完成句柄有兩個參數:第一個是我們從響應中提取的狀態字符串,而第二個由我們來指示geocoding是否成功了(意味著我們是否已收到地圖所需數據)。
現在讓我們一步步實現此方法。首先,我們需要正確的組成URL字符串,將它轉換為合適的格式,然後用它來創建一個NSURL對象。注意我們先確保了給出的地址是合法的:
if let lookupAddress = address { var geocodeURLString = baseURLGeocode + "address=" + lookupAddress geocodeURLString = geocodeURLString.stringByAddingPercentEscapesUsingEncoding(NSUTF8StringEncoding)! let geocodeURL = NSURL(string: geocodeURLString) }
接下來,我們必須向geocoding API發出請求,並將返回的結果儲存到NSData對象中。這兒有個重要的細節,那就是我們以異步方式完成上述過程(包括對所有接收數據的處理),從而使應用在數據獲取期間仍是可響應的。接下來你也可以看到,從API獲取到數據之後,我們將此數據從JSON格式轉換成字典對象:
dispatch_async(dispatch_get_main_queue(), { () -> Void in let geocodingResultsData = NSData(contentsOfURL: geocodeURL!) var error: NSError? let dictionary: Dictionary = NSJSONSerialization.JSONObjectWithData(geocodingResultsData!, options: NSJSONReadingOptions.MutableContainers, error: &error) as Dictionary })
上圖代碼片段中最後一行即是將數據從JSON轉換為Swift表示方式。
如你所注意到的,我們使用NSError對象來"捕獲"在轉換過程中可能發生的任何錯誤。因此,我們現在最關心的是,轉換之後此錯誤值是否是nil。如果不是,那我們會調用完成句柄,並設置標識參數為false:
if (error != nil) { println(error) completionHandler(status: "", success: false) }
如果一切進行得順利,那麼我們就會先得到響應的狀態。如果它包含"OK"的值,我們就"提取"第一個結果並將其保存到lookupAddressResults字典中,然後我們獲取所有其他我們感興趣的值(格式化地址,經度和緯度)。如果狀態是其他值而不是"OK",那麼我們將調用完成句柄,並傳遞狀態值給它,但我們又將設置標識為false:
else { // Get the response status. let status = dictionary["status"] as String if status == "OK" { let allResults = dictionary["results"] as Array self.lookupAddressResults = allResults[0] // Keep the most important values. self.fetchedFormattedAddress = self.lookupAddressResults["formatted_address"] as String let geometry = self.lookupAddressResults["geometry"] as Dictionary self.fetchedAddressLongitude = ((geometry["location"] as Dictionary)["lng"] as NSNumber).doubleValue self.fetchedAddressLatitude = ((geometry["location"] as Dictionary)["lat"] as NSNumber).doubleValue completionHandler(status: status, success: true) } else { completionHandler(status: status, success: false) } }
以上實際是方法的"核心"。值得注意的是,以上使用到的關鍵字名字是從之前我向你給出的JSON數據例子中而來的,你也可以從Google Geocoding API文檔中找到它們。
最後,還有一種情況我們需要處理;作為參數傳遞給方法的地址是否是nil:
else { completionHandler(status: "No valid address.", success: false) }
這次我們在狀態參數中提供了一個自定義的消息。
這是完整的方法,放在了同一片段中:
func geocodeAddress(address: String!, withCompletionHandler completionHandler: ((status: String, success: Bool) -> Void)) { if let lookupAddress = address { var geocodeURLString = baseURLGeocode + "address=" + lookupAddress geocodeURLString = geocodeURLString.stringByAddingPercentEscapesUsingEncoding(NSUTF8StringEncoding)! let geocodeURL = NSURL(string: geocodeURLString) dispatch_async(dispatch_get_main_queue(), { () -> Void in let geocodingResultsData = NSData(contentsOfURL: geocodeURL!) var error: NSError? let dictionary: Dictionary = NSJSONSerialization.JSONObjectWithData(geocodingResultsData!, options: NSJSONReadingOptions.MutableContainers, error: &error) as Dictionary if (error != nil) { println(error) completionHandler(status: "", success: false) } else { // Get the response status. let status = dictionary["status"] as String if status == "OK" { let allResults = dictionary["results"] as Array self.lookupAddressResults = allResults[0] // Keep the most important values. self.fetchedFormattedAddress = self.lookupAddressResults["formatted_address"] as String let geometry = self.lookupAddressResults["geometry"] as Dictionary self.fetchedAddressLongitude = ((geometry["location"] as Dictionary)["lng"] as NSNumber).doubleValue self.fetchedAddressLatitude = ((geometry["location"] as Dictionary)["lat"] as NSNumber).doubleValue completionHandler(status: status, success: true) } else { completionHandler(status: status, success: false) } } }) } else { completionHandler(status: "No valid address.", success: false) } }
是時候使用以上方法了,因此讓我們返回到ViewController.swift文件。首先,讓我們聲明和初始化一個MapTasks類的對象:
var mapTasks = MapTasks()
現在,我們通過導航找到findAddress(_:) IBAction方法。在工具欄的左邊的按鈕被按下時,此方法被調用。在此方法內,我們會創建一個帶文本輸入框的警告控制器,向用戶請求地址輸入。我們將提供兩個動作按鈕,一個用來啟動地址搜索,一個用來取消控制器。當第一個按鈕按下時,我們會調用之前創建的方法,然後我們將處理完成句柄的結果。讓我們看看代碼實現:
@IBAction func findAddress(sender: AnyObject) { let addressAlert = UIAlertController(title: "Address Finder", message: "Type the address you want to find:", preferredStyle: UIAlertControllerStyle.Alert) addressAlert.addTextFieldWithConfigurationHandler { (textField) -> Void in textField.placeholder = "Address?" } let findAction = UIAlertAction(title: "Find Address", style: UIAlertActionStyle.Default) { (alertAction) -> Void in let address = (addressAlert.textFields![0] as UITextField).text as String self.mapTasks.geocodeAddress(address, withCompletionHandler: { (status, success) -> Void in }) } let closeAction = UIAlertAction(title: "Close", style: UIAlertActionStyle.Cancel) { (alertAction) -> Void in } addressAlert.addAction(findAction) addressAlert.addAction(closeAction) presentViewController(addressAlert, animated: true, completion: nil) }
在完成處理函數的函數體中,我們必須處理這些結果。如果成功標識是false,那麼我們將在控制台程序上顯示此狀態。更多的,如果狀態的值等於"ZERO_RESULTS",我們將顯示另一個警告控制器,告訴用戶此地址沒有找到(參見Google文檔中的狀態碼)。另一方面,如果一切正常,我們將使地圖以新位置為中心:
@IBAction func findAddress(sender: AnyObject) { ... self.mapTasks.geocodeAddress(address, withCompletionHandler: { (status, success) -> Void in if !success { println(status) if status == "ZERO_RESULTS" { self.showAlertWithMessage("The location could not be found.") } } else { let coordinate = CLLocationCoordinate2D(latitude: self.mapTasks.fetchedAddressLatitude, longitude: self.mapTasks.fetchedAddressLongitude) self.viewMap.camera = GMSCameraPosition.cameraWithTarget(coordinate, zoom: 14.0) } }) ... }
showAlertWithMessage(_:)是另一個自定義方法,僅用於以給定的消息來顯示一個警告控制器。它由明確的可復用的代碼組成:
func showAlertWithMessage(message: String) { let alertController = UIAlertController(title: "GMapsDemo", message: message, preferredStyle: UIAlertControllerStyle.Alert) let closeAction = UIAlertAction(title: "Close", style: UIAlertActionStyle.Cancel) { (alertAction) -> Void in } alertController.addAction(closeAction) presentViewController(alertController, animated: true, completion: nil) }
我們至此准備好了。再次測試此應用,並且這次輸入地址,此地址將指向地圖上某處。如果你正確的輸入地址,那麼地圖會以此地址為中心。
添加標記
在地圖上添加一個標記是一項十分簡單的工作,因為僅需兩行代碼即可完成。然而,有多種屬性可以配置,從而使得標記能根據你的需求或偏好來進行定制。在Google Maps SDK中,標記是GMSMarker類的對象。當初始化此對象時,你必須指定以CLLocationCoordinate2D類型表示的經度和緯度。在之前章節中我們創建了這樣的一個對象,如下代碼行:
let coordinate = CLLocationCoordinate2D(latitude: self.mapTasks.fetchedAddressLatitude, longitude: self.mapTasks.fetchedAddressLongitude)
只要標記對象被初始化了,地圖SDK就知道在哪裡放置它,從而為標記對象指定一個地圖對象就是必要的了,此標記會添加到此地圖上。
讓我們來看看這在代碼裡如何完成的,但首先讓我們先聲明一個標記屬性,從而我們可以持有對它的強引用。到ViewController類的開始處並添加如下代碼:
var locationMarker: GMSMarker!
現在讓我們來定義以下方法。我們會在每次自定義地址定位到地圖時調用它,因此我們將位置指定給它:
func setupLocationMarker(coordinate: CLLocationCoordinate2D) { }
所需的坐標對象會以參數的形式提供給此方法。讓我們初始化此標記:
func setuplocationMarker(coordinate: CLLocationCoordinate2D) { locationMarker = GMSMarker(position: coordinate) locationMarker.map = viewMap }
讓我們返回到findAddress(_:) IBAction方法並再加一行代碼,從而使得當找到自定義的位置,並且地圖調整到以此位置為中心時,調用上圖中的方法。
@IBAction func findAddress(sender: AnyObject) { ... self.mapTasks.geocodeAddress(address, withCompletionHandler: { (status, success) -> Void in ... else { let coordinate = CLLocationCoordinate2D(latitude: self.mapTasks.fetchedAddressLatitude, longitude: self.mapTasks.fetchedAddressLongitude) self.viewMap.camera = GMSCameraPosition.cameraWithTarget(coordinate, zoom: 14.0) self.setupLocationMarker(coordinate) } }) ... }
再次運行此應用,並輸入自定義地址。現在,一個標記會正好指向此新位置。
如同我在此部分開頭所說的,標記有多種屬性可用來實現某種程度的定制。例如,你可以指定標記的顏色,當接觸標記時顯示某些文字,或者甚至於改變標記的不透明度。讓我們通過配置其中一些屬性的方式來擴展上述方法:
func setuplocationMarker(coordinate: CLLocationCoordinate2D) { ... locationMarker.title = mapTasks.fetchedFormattedAddress locationMarker.appearAnimation = kGMSMarkerAnimationPop locationMarker.icon = GMSMarker.markerImageWithColor(UIColor.blueColor()) locationMarker.opacity = 0.75 }
通過使用title屬性,當標記被觸碰時,從mapTasks對象獲取的格式化地址會被顯示在標記之上。appearAnimation指定標記是否以動畫方式來顯示,而我們賦值給它的僅僅是地圖SDK提供的選項。關於標記的顏色,如你所看到的,它不能直接被改變;相反,我們需要訪問它的icon屬性並以圖像形式提供所需顏色,然而接下來全是由GMSMarker類來處理的;我們不用去關心這些。最後,我們也改變了標記的不透明度。以上代碼行會導致有點透明的藍色標記動態地顯示在地圖上。
當轉動地圖或通過手勢改變攝影機位置時,標記仍在原位。然而此行為是有可能改變的,通過指示來告訴SDK你希望標記是平的,從而允許標記根據執行的手勢而移動。
除此之外,在觸碰標記時彈出的氣泡框中添加額外文字也是可能的。此額外文字必須賦值給標記的snippet屬性。
這裡是以上提到的兩個功能的代碼實現:
func setuplocationMarker(coordinate: CLLocationCoordinate2D) { ... locationMarker.flat = true locationMarker.snippet = "The best place on earth." }
如果你再次運行應用,你可以看到所有修改的屬性產生的效果。
在我們進入下一部分之前,還有一個細節我必須要說明。如我之前提到的,我們在以上方法中建立的標記,會在每次新的自定義地址被找到時添加到地圖上。然而,此過程不能正常工作,除非我們先移除它(如果已經存在),然後再添加它。因此,我們需要做的是到此方法的開始處,增加以下條件:
func setuplocationMarker(coordinate: CLLocationCoordinate2D) { if locationMarker != nil { locationMarker.map = nil } ... }
以上展現的屬性並不是Google Maps SDK中的全部屬性。我鼓勵你去看看官方文檔。你肯定會找到有用的東西來讀一下的。
繪制路線
除了Geocode API之外,Google也提供了其他web服務能夠與Maps SDK進行組合。其中之一是Directions API,它能夠幫助我們在地圖上創建路線。路線實際上是連接兩個地點的線段,而且除了起始和目的節點外,它也可以包含中間位置,這些中間位置也稱為航路點。
我們向Directions API發出請求的方法和向Geocode API發出請求的方式十分相似。這意味著我們必須為web服務提供所需的參數值,並在之後處理返回的數據,此數據可能是JSON或XML格式。我想現在是查看Directions API文檔的最好時機,而且特別是sample responses這一節,在這節中你能找到JSON響應的一個十分好的例子。
在我們進行實現之前,理解關於路線的一些基本情況,以及從API返回給我們的數據是非常重要的。在這裡我僅強調某一些方面,但你總能參考官方文檔中的更多信息。那麼首先,從Directions API返回的單個響應可向我們給出從A點到B點的多條可能路線(在此示例應用中,我們僅使用返回的第一條路線,忽略掉所有剩下的路線)。每條路線由多條路程組成,在Directions API響應的描述中,每條路程只是整條旅程的一部分。
現在,每條路程由多個步程組成,在這裡步程是一個方向單元,即指示著有關路線應該遵循的方向。步程中含有關於自身的有用數據,比如經過的距離,旅程在此步程中的耗時,此步程的開始和結束位置,在地圖上繪制步程所需的點。還有更多的東西,但是我有意僅提及這些,因此你在我們繼續往下講時能把它們記在腦中。
比路程和步程更進一步的,路線也包含了其他有用的數據。其中的一塊是Google文檔網站上給出的響應樣例中標記為"概況折線(overview_polyline)"的數據,它包含了地圖繪制路線所需要的所有點(polyline,折線,是在地圖上創建一條路徑所需的線段的集合)。在此演示應用中我們將使用此數據,但我必須預先提醒你,此線路的概況是不准確的;它是真正路線的一種近似。對於包含少數路程的相對短的距離,它似乎能很好的工作,然而有些情況下,基於此概況折線的點而繪制的路線漏掉了一些轉彎或道路。實際上,路程和步程越多,此概況折線越不准確。此問題的解決方案是,循環訪問和獲取到每條路程中每條步程的折線點,然後基於這些點一段段的繪制路線。然而,我們不會在這裡應用此方案,因為這會讓我們得到一個非常復雜的應用,而我們必須保持此應用的簡單,讓一切都容易理解。我們將堅持使用概況折線,因此你可以看到它是如何使用的,然後你就能夠對你自己的代碼做一些修改和改進。
現在讓我們寫一些代碼吧,轉到MapTasks.swift文件。首先,我們必須聲明如下的類屬性:
let baseURLDirections = "https://maps.googleapis.com/maps/api/directions/json?" var selectedRoute: Dictionary! var overviewPolyline: Dictionary! var originCoordinate: CLLocationCoordinate2D! var destinationCoordinate: CLLocationCoordinate2D! var originAddress: String! var destinationAddress: String!
讓我們來看看以上屬性都是用來什麼的:
在selectedRoute中我們會存儲從Directions API返回的第一條路線(從全部數量的路線之中)。它是一個字典,包含了其他的字典和數組。
在overviewPolyline屬性中我們會持有概況折線的字典,此字典中存放了另一個字典,其中包含了需要繪制的線段的所有點。
originCoordinate和destinationCoordinate都是CLLocationCoordinate2D對象,相應地表示了起始位置和目的位置的經緯度。
originAddress和destinationAddress將會以字符串形式持有起始地址和目的地址,並包含在API響應中。
以上全部聲明完之後,我們能繼續定義以下方法:
func getDirections(origin: String!, destination: String!, waypoints: Array!, travelMode: AnyObject!, completionHandler: ((status: String, success: Bool) -> Void)) { }
除了起始和目的位置(以地址表示)之外,以上方法也接受以下兩個參數,航路點(路線中的中間點)數組以及地圖上將繪制的旅程的行進模式。使用行進模式,我們能夠定義以何種方式來作方向指引,駕車,步行還是騎自行車。最後的參數是(已知的)完成處理器,當我們在地圖上使用了數據時會調用它。
目前我們不用理會航路點和行進模式,我們將只使用起始和目的地址。如我之前所說,至少在開始的時候,你會看到一些與Geocode API相似的東西。因此,首先我們必須使用此兩個地址組成請求的URL字符串,然後將它轉換成NSURL對象:
func getDirections(origin: String!, destination: String!, waypoints: Array!, travelMode: AnyObject!, completionHandler: ((status: String, success: Bool) -> Void)) { if let originLocation = origin { if let destinationLocation = destination { var directionsURLString = baseURLDirections + "origin=" + originLocation + "&destination=" + destinationLocation directionsURLString = directionsURLString.stringByAddingPercentEscapesUsingEncoding(NSUTF8StringEncoding)! let directionsURL = NSURL(string: directionsURLString) } else { completionHandler(status: "Destination is nil.", success: false) } } else { completionHandler(status: "Origin is nil", success: false) } }
值得注意的是,在起始或目的地址是nil的情況下,我們在相應的else分支中使用了完成處理器,指示獲取方向指引失敗,並在同時傳遞了自定義的消息。
下一步驟是使用上述我們剛創建的URL向Directions API請求方向指引。如我們所說,返回的數據會是JSON格式,並且我們所有的工作都會是異步發生的。在下面的代碼片段中,首先我們取得JSON數據,然後我們將它轉換成字典。我們也處理了可能發生的錯誤:
dispatch_async(dispatch_get_main_queue(), { () -> Void in let directionsData = NSData(contentsOfURL: directionsURL!) var error: NSError? let dictionary: Dictionary = NSJSONSerialization.JSONObjectWithData(directionsData!, options: NSJSONReadingOptions.MutableContainers, error: &error) as Dictionary if (error != nil) { println(error) completionHandler(status: "", success: false) } else { } })
以上所述並沒有新東西或困難之處。有趣的部分是我們在else分支添加的代碼如下:
let status = dictionary["status"] as String if status == "OK" { self.selectedRoute = (dictionary["routes"] as Array)[0] self.overviewPolyline = self.selectedRoute["overview_polyline"] as Dictionary let legs = self.selectedRoute["legs"] as Array let startLocationDictionary = legs[0]["start_location"] as Dictionary self.originCoordinate = CLLocationCoordinate2DMake(startLocationDictionary["lat"] as Double, startLocationDictionary["lng"] as Double) let endLocationDictionary = legs[legs.count - 1]["end_location"] as Dictionary self.destinationCoordinate = CLLocationCoordinate2DMake(endLocationDictionary["lat"] as Double, endLocationDictionary["lng"] as Double) self.originAddress = legs[0]["start_address"] as String self.destinationAddress = legs[legs.count - 1]["end_address"] as String self.calculateTotalDistanceAndDuration() completionHandler(status: status, success: true) } else { completionHandler(status: status, success: false) }
讓我們先通講一下代碼。首先,我們獲得響應的狀態,並且我們僅當它是"OK"的值時才往下繼續。在此情況下,我們所做的第一件事情是讓selectedRoute字典持有第一條找到的路線(為了入門教程的簡單起見,如果有更多路線則都被忽略了)。使用此字典,我們直接訪問折線概況,因此我們獲得了繪制路線所需的點(即使是近似的)。然後,在局部聲明的路程數組裡,我們存儲了路線的所有路程,因為我們將需要它們,從而獲得關於開始和結束位置的一些數據。值得注意的是,我們如何訪問起始和目的地的坐標,並且當它們從響應中返回時我們如何獲取它們的名字。注意,對於開始位置我們使用了路線的第一條路程,而對於結束位置我們使用了路線的最後一條路程(legs.count - 1)。
就在我們調用完成處理器之前,我們調用了一個自定義方法,你在這兒還是第一次見到此方法,calculateTotalDistanceAndDuration()。我們會稍後再見到它,而你可以假定它能幫助我們計算旅程的距離和行進的時間。
在此,我向你給出此方法的實現:
func getDirections(origin: String!, destination: String!, waypoints: Array!, travelMode: AnyObject!, completionHandler: ((status: String, success: Bool) -> Void)) { if let originLocation = origin { if let destinationLocation = destination { var directionsURLString = baseURLDirections + "origin=" + originLocation + "&destination=" + destinationLocation directionsURLString = directionsURLString.stringByAddingPercentEscapesUsingEncoding(NSUTF8StringEncoding)! let directionsURL = NSURL(string: directionsURLString) dispatch_async(dispatch_get_main_queue(), { () -> Void in let directionsData = NSData(contentsOfURL: directionsURL!) var error: NSError? let dictionary: Dictionary = NSJSONSerialization.JSONObjectWithData(directionsData!, options: NSJSONReadingOptions.MutableContainers, error: &error) as Dictionary if (error != nil) { println(error) completionHandler(status: "", success: false) } else { let status = dictionary["status"] as String if status == "OK" { self.selectedRoute = (dictionary["routes"] as Array)[0] self.overviewPolyline = self.selectedRoute["overview_polyline"] as Dictionary let legs = self.selectedRoute["legs"] as Array let startLocationDictionary = legs[0]["start_location"] as Dictionary self.originCoordinate = CLLocationCoordinate2DMake(startLocationDictionary["lat"] as Double, startLocationDictionary["lng"] as Double) let endLocationDictionary = legs[legs.count - 1]["end_location"] as Dictionary self.destinationCoordinate = CLLocationCoordinate2DMake(endLocationDictionary["lat"] as Double, endLocationDictionary["lng"] as Double) self.originAddress = legs[0]["start_address"] as String self.destinationAddress = legs[legs.count - 1]["end_address"] as String self.calculateTotalDistanceAndDuration() completionHandler(status: status, success: true) } else { completionHandler(status: status, success: false) } } }) } else { completionHandler(status: "Destination is nil.", success: false) } } else { completionHandler(status: "Origin is nil", success: false) } }
現在,就在我們實現此新的自定義方法來計算距離和時間之前,我們需要在類的頂部聲明如下屬性:
var totalDistanceInMeters: UInt = 0 var totalDistance: String! var totalDurationInSeconds: UInt = 0 var totalDuration: String!
讓我們現在實現此新方法。不幸的是,沒有計算路線的距離和時間的直接方法,相反的,我們必須遍歷所有路程,並且一段段的把它們加起來。距離是以米來計算的,而時間是以秒來計算的。在下面的實現中你會注意到,在我們計算了相關的數據和之後,我們將距離轉換為以千米為單位,而時間轉換為為天,小時,分鐘和秒的形式。如下:
func calculateTotalDistanceAndDuration() { let legs = self.selectedRoute["legs"] as Array totalDistanceInMeters = 0 totalDurationInSeconds = 0 for leg in legs { totalDistanceInMeters += (leg["distance"] as Dictionary)["value"] as UInt totalDurationInSeconds += (leg["duration"] as Dictionary)["value"] as UInt } let distanceInKilometers: Double = Double(totalDistanceInMeters / 1000) totalDistance = "Total Distance: \(distanceInKilometers) Km" let mins = totalDurationInSeconds / 60 let hours = mins / 60 let days = hours / 24 let remainingHours = hours % 24 let remainingMins = mins % 60 let remainingSecs = totalDurationInSeconds % 60 totalDuration = "Duration: \(days) d, \(remainingHours) h, \(remainingMins) mins, \(remainingSecs) secs" }
讓我們回到ViewController類,並且首先讓我們聲明如下屬性:
var originMarker: GMSMarker! var destinationMarker: GMSMarker! var routePolyline: GMSPolyline!
開始的兩個標記將指示起始和目的位置,並且routePolyline是表示路線的折線。
現在在createRoute(_:) IBAction中,我們會顯示一個帶有兩個文本輸入框的警告控制器。用戶在第一個裡輸入起始位置,在第二個裡輸入目的位置。通過接受這兩個值來創建路線,我們將調用Directions API,並基於會返回的結果,我們將設計此路線且添加標記。
@IBAction func createRoute(sender: AnyObject) { let addressAlert = UIAlertController(title: "Create Route", message: "Connect locations with a route:", preferredStyle: UIAlertControllerStyle.Alert) addressAlert.addTextFieldWithConfigurationHandler { (textField) -> Void in textField.placeholder = "Origin?" } addressAlert.addTextFieldWithConfigurationHandler { (textField) -> Void in textField.placeholder = "Destination?" } let createRouteAction = UIAlertAction(title: "Create Route", style: UIAlertActionStyle.Default) { (alertAction) -> Void in let origin = (addressAlert.textFields![0] as UITextField).text as String let destination = (addressAlert.textFields![1] as UITextField).text as String self.mapTasks.getDirections(origin, destination: destination, waypoints: nil, travelMode: nil, completionHandler: { (status, success) -> Void in if success { self.configureMapAndMarkersForRoute() self.drawRoute() self.displayRouteInfo() } else { println(status) } }) } let closeAction = UIAlertAction(title: "Close", style: UIAlertActionStyle.Cancel) { (alertAction) -> Void in } addressAlert.addAction(createRouteAction) addressAlert.addAction(closeAction) presentViewController(addressAlert, animated: true, completion: nil) }
正如你注意到的,如果此方向指引請求成功了,我們會調用三個新的自定義方法。它們的用途從名字易見,因此讓我們一個個地實現它們。
在configureMapAndMarkersForRoute()方法中,我們將完成三項不同的任務:
我們將地圖調整到以路線的開始點為中心。
我們在起始點增加一個標記。
我們在目的點增加一個標記。
func configureMapAndMarkersForRoute() {
viewMap.camera = GMSCameraPosition.cameraWithTarget(mapTasks.originCoordinate, zoom: 9.0)
originMarker = GMSMarker(position: self.mapTasks.originCoordinate) originMarker.map = self.viewMap originMarker.icon = GMSMarker.markerImageWithColor(UIColor.greenColor()) originMarker.title = self.mapTasks.originAddress destinationMarker = GMSMarker(position: self.mapTasks.destinationCoordinate) destinationMarker.map = self.viewMap destinationMarker.icon = GMSMarker.markerImageWithColor(UIColor.redColor()) destinationMarker.title = self.mapTasks.destinationAddress
注意,我們對標記設置了不同的顏色。更進一步的,這兒有一些新東西。
在drawRoute()方法中,我們將在地圖上繪制路線的線段。值得注意的是,利用GMSPath和GMSPolyline類,以及我們才能夠MapTasks類中獲取的路線點,創建路線是一件十分容易的事情。
func drawRoute() { let route = mapTasks.overviewPolyline["points"] as String let path: GMSPath = GMSPath(fromEncodedPath: route) routePolyline = GMSPolyline(path: path) routePolyline.map = viewMap }
在上面的代碼片段的結尾,我們有必要將地圖設置給routePolyline屬性。
此displayRouteInfo()方法十分簡單。我們僅在屏幕底部的標簽上顯示了計算出的距離和位置:
func displayRouteInfo() { lblInfo.text = mapTasks.totalDistance + "\n" + mapTasks.totalDuration }
這就是全部了。我們已經經歷許多新的東西,但是一旦你了解了它們的含義,管理這一切就十分容易了。現在你可以再次測試此應用。指定起始和目的地址,讓路線被創建出來。我必須重申的是,此概況折線在某些情況下並不是你所期待的那樣准確,因此如果這對你來說還不夠好的話,考慮一下實現之前我描述過的方案,遍歷所有路程的所有步程,收集所有步程中的點,並將它們一個個地在地圖上畫出來。
向路線中添加路徑點
航路點是路線的起始和目的點之間的位置,並且實際上是路線的一部分。目前,我們設法創建的路線,是開始和結束點之間沒有任何東西的。現在我們將要添加航路點特性。因此,我們會使應用變得更加有趣,而這次不再使用警告控制器來輸入路線的航路點的地址,我們僅需觸碰地圖,路線就會被重新計算。只要上述情況發生了,一個新的標記會被放置在地圖上,指向觸碰的位置。
記住上述內容,我們在這需要做的第一件事情是在類中聲明和初始化兩個新的數組:
var markersArray: Array= [] var waypointsArray: Array= []
第一個數組持有指向航路點的所有標記。第二個數組,我們將航路點作為字符串對象的形式持有。
現在往下繼續,為了處理地圖上的觸碰並得到確切的觸碰位置,我們需要使用GMSMapViewDelegate協議(存在於Google Maps SDK中)的一個代理方法。在我們完全實現它之前,我們需要繼承此協議:
class ViewController: UIViewController, CLLocationManagerDelegate, GMSMapViewDelegate { ... }
我們也必須將ViewController類作為地圖視圖的代理。在viewDidLoad中添加如下簡單的一行:
override func viewDidLoad() { ... viewMap.delegate = self }
讓我們看看上述的代理方法,然後我們將對它進行討論:
func mapView(mapView: GMSMapView!, didTapAtCoordinate coordinate: CLLocationCoordinate2D) { if let polyline = routePolyline { let positionString = String(format: "%f", coordinate.latitude) + "," + String(format: "%f", coordinate.longitude) waypointsArray.append(positionString) recreateRoute() } }
在這裡第一重要的細節是,確保有這麼一條路線我們能夠向它添加航路點。這很容易檢查,因為我們只需確認路線的折線不是nil。在此情況下,我們很容易地使用坐標參數來組成一個字符串,包含了觸碰位置的緯度和經度。請確保字符串裡沒有空格符,否則向Directions API的請求會失敗。一旦此描述位置的字符串准備好了,我們就將它添加到waypointsArray中,並調用一個全新的自定義方法,recreateRoute()(顯而易見,此方法會重新創建路線)。
在我們重新繪制包含了新的航路點的路線之前,我們必須清除已有的路線。因此,我們首先創建了一個名為clearRoute()的新的函數,而沒有實現recreateRoute()方法。讓我們來看看:
func clearRoute() { originMarker.map = nil destinationMarker.map = nil routePolyline.map = nil originMarker = nil destinationMarker = nil routePolyline = nil if markersArray.count > 0 { for marker in markersArray { marker.map = nil } markersArray.removeAll(keepCapacity: false) } }
正如你所見到的,繪制過的路線中所有的對象都變為了nil並從地圖中移除了。注意,我們甚至將markersArray中存在的標記的map屬性都設置成了nil(我們還沒有編寫添加標記到此數組的代碼,但是我們還是能做以上的事情)。
現在,我們能實現recreateRoute()了:
func recreateRoute() { if let polyline = routePolyline { clearRoute() mapTasks.getDirections(mapTasks.originAddress, destination: mapTasks.destinationAddress, waypoints: waypointsArray, travelMode: nil, completionHandler: { (status, success) -> Void in if success { self.configureMapAndMarkersForRoute() self.drawRoute() self.displayRouteInfo() } else { println(status) } }) } }
首先,我們要確保有一條路線,使我們能夠清除它。然後我們向Directions API發出請求,並且注意到這次我們也指定了waypointsArray參數。如果一切正常,我們會調用之前章節裡相同的方法,從而使路線被繪制出來。
現在大部分的工作都做好了,但我們還是有兩項任務需要完成。第一件事情是訪問configureMapAndMarkersForRoute()方法,並且使它也具有添加航路點標記的能力。讓我們來看看所需的添加能力:
func configureMapAndMarkersForRoute() { ... if waypointsArray.count > 0 { for waypoint in waypointsArray { let lat: Double = (waypoint.componentsSeparatedByString(",")[0] as NSString).doubleValue let lng: Double = (waypoint.componentsSeparatedByString(",")[1] as NSString).doubleValue let marker = GMSMarker(position: CLLocationCoordinate2DMake(lat, lng)) marker.map = viewMap marker.icon = GMSMarker.markerImageWithColor(UIColor.purpleColor()) markersArray.append(marker) } } }
請注意我們是怎樣將每個航路點字符串分解成多個字段的,然後我們將經度和緯度轉換成雙精度字節(double)值,從而我們可以在初始化新的標記時使用它們。一旦匹配每個航路點的標記配置好了,我們就將它加入到markersArray數組裡。
第二項仍需要完成任務是,更新MapTasks.swift文件中的getDirections(…) 方法。在方法的開始處,強制性的更新directionsURLString字符串,從而在將要發出的請求中包含我們需要的航路點。注意,除了傳遞了航路點之外,我們也在查詢中指定了另一個參數,請求當使用航路點時對路線進行優化:
func getDirections(origin: String!, destination: String!, waypoints: Array!, travelMode: AnyObject!, completionHandler: ((status: String, success: Bool) -> Void)) { if let originLocation = origin { if let destinationLocation = destination { var directionsURLString = baseURLDirections + "origin=" + originLocation + "&destination=" + destinationLocation if let routeWaypoints = waypoints { directionsURLString += "&waypoints=optimize:true" for waypoint in routeWaypoints { directionsURLString += "|" + waypoint } } ... } ... } ... }
現在你可以再試一試此應用。在你創建一條線路之後,觸碰它周圍任意一點,從而使得觸碰位置作為航路點添加到路線中去。
行進模式
創建一條路線時,Directions API默認使用的是駕車模式。如我已經說過的,如下是Google Maps SDK在iOS中支持的行進模式:
駕車模式
步行模式
自行車模式
改變行進模式是一件有趣的事情,並且這也將是此示例應用十分好的一個特性,因此讓我們在此部分實現它。我們要做的第一件事是創建一個枚舉(enum),此枚舉包含所有支持的行進模式。在ViewController.swift文件中,在文件頂部的類定義之前,增加如下代碼行:
enum TravelModes: Int { case driving case walking case bicycling }
現在讓我們在類中聲明默認的行進模式:
var travelMode = TravelModes.driving
為了改變行進模式,我們將用到changeTravelMode(_:) IBAction方法。在此方法中,我們新建了一個action sheet,提供給用戶所有可能的選項。並且根據用戶做出的選擇,我們相應的設置行進模式。讓我們看看代碼:
@IBAction func changeTravelMode(sender: AnyObject) { let actionSheet = UIAlertController(title: "Travel Mode", message: "Select travel mode:", preferredStyle: UIAlertControllerStyle.ActionSheet) let drivingModeAction = UIAlertAction(title: "Driving", style: UIAlertActionStyle.Default) { (alertAction) -> Void in self.travelMode = TravelModes.driving self.recreateRoute() } let walkingModeAction = UIAlertAction(title: "Walking", style: UIAlertActionStyle.Default) { (alertAction) -> Void in self.travelMode = TravelModes.walking self.recreateRoute() } let bicyclingModeAction = UIAlertAction(title: "Bicycling", style: UIAlertActionStyle.Default) { (alertAction) -> Void in self.travelMode = TravelModes.bicycling self.recreateRoute() } let closeAction = UIAlertAction(title: "Close", style: UIAlertActionStyle.Cancel) { (alertAction) -> Void in } actionSheet.addAction(drivingModeAction) actionSheet.addAction(walkingModeAction) actionSheet.addAction(bicyclingModeAction) actionSheet.addAction(closeAction) presentViewController(actionSheet, animated: true, completion: nil) }
如你所注意到的,在每個action中我們設置了相關的行進模式,並且我們調用了之前部分中實現的recreateRoute()方法。當然,我們在關閉動作(close action)中不會這樣做,而只是取消掉action sheet。
以上所述都還不會產生效果,因為recreateRoute()方法需要更新。在當前實現裡,當調用MapTasks類的getDirections(…)方法時,我們不會將行進模式作為參數來提供給此方法;相反的,我們將行進模式設置為nil。因此,讓我們做些修正:
func recreateRoute() { if let polyline = routePolyline { ... mapTasks.getDirections(mapTasks.originAddress, destination: mapTasks.destinationAddress, waypoints: waypointsArray, travelMode: travelMode, completionHandler: { (status, success) -> Void in ... }) } }
這裡唯一改變了的是travelMode參數。現在,我們也需要更新getDirections(…)方法,從而使它考慮我們設置的行進模式。因此,來到MapTasks.swift文件中,作如下增添:
func getDirections(origin: String!, destination: String!, waypoints: Array!, travelMode: TravelModes!, completionHandler: ((status: String, success: Bool) -> Void)) { if let originLocation = origin { if let destinationLocation = destination { var directionsURLString = baseURLDirections + "origin=" + originLocation + "&destination=" + destinationLocation ... if let travel = travelMode { var travelModeString = "" switch travelMode.rawValue { case TravelModes.walking.rawValue: travelModeString = "walking" case TravelModes.bicycling.rawValue: travelModeString = "bicycling" default: travelModeString = "driving" } directionsURLString += "&mode=" + travelModeString } ... } ... } ... }
注意:比增加if let travel = travelMode{ … } 語句更進一步的,我們也需要改變travelMode的參數類型,從AnyObject! 變為TravelModes!(我們在之前創建的枚舉類型)。
現在我們做得夠好了,可以再次測試此應用了。去改變一下行進模式,然後注意到路線信息,特別是旅程的時間,是如何改變的。
最後的調試
在此我們的示例應用幾乎完成了。我說幾乎是因為還有兩個細節需要增加到我們的代碼中,從而使應用盡可能好的工作。
我們必須做的第一件事,是在將要創建新線路之前,清除存在的路線。因此,在createRoute(_:)方法的createRouteAction塊中,我們需要檢查是否已經存在一條路線,然後將它清除:
@IBAction func createRoute(sender: AnyObject) { ... let createRouteAction = UIAlertAction(title: "Create Route", style: UIAlertActionStyle.Default) { (alertAction) -> Void in if let polyline = self.routePolyline { self.clearRoute() self.waypointsArray.removeAll(keepCapacity: false) } ... } ... }
注意:除了清除掉路線之外,我們也從waypointsArray中溢出了所有對象。因為我們要創建一條新路線,沒有理由保存任何已有的航路點。
第二件事情是再次指定行進模式作為IBAction方法的一個參數,因為目前我們僅將相關參數的值設置為了nil:
@IBAction func createRoute(sender: AnyObject) { ... let createRouteAction = UIAlertAction(title: "Create Route", style: UIAlertActionStyle.Default) { (alertAction) -> Void in ... self.mapTasks.getDirections(origin, destination: destination, waypoints: nil, travelMode: self.travelMode, completionHandler: { (status, success) -> Void in ... } ... }
在完成上述兩項任務之後,可以說我們的示例應用最終完成了。
總結
在此入門教程的各部分裡,我們設法將開發者使用地圖時的最重要的任務都做了一遍,並且在此使用的是iOS版本的Google Maps SDK。如果你要使用Google地圖,那麼你已閱讀過的上述所有章節能將你帶上正確的軌道,而且現在你肯定有了一個工作的起點。無需置疑的是,有許多關於Google Maps SDK的細節在此(僅一篇)入門教程中無法被提及。然而,你現在已經知道了此SDK基本的和最重要的方面,你可以投入精力到Google文檔中,查詢你所需的任何額外的信息。當然,使用地圖是十分有趣的課題,並且無論可能出現什麼困難,到最後它依然是值得使用的東西。無論如何,如果你當前正在使用或者如果你計劃使用Google Maps SDK,我希望你對此帖子提供的信息感到有用。將它當作一篇指南,沒什麼可以阻擋你深入其中並充分利用所有給出的API。
作為一項參考,你可以在此處找到完整的Xcode工程。像往常一樣,請給我評論並分享你對此入門教程的想法。