第五课:实现Google地图和地理定位

Google Maps和移动应用是绝配。Google Maps API本身就是一黑科技来的,当你和设备组合起来的时候以为这移动性,不是静止的,他打开了一扇新世界的大门。现今市面上有很多使用Google Maps武装起来的牛逼应用。
即使地图功能不是你的应用的核心功能,他们经常作为补充功能出现(例如在地图上显示一个商业地址)。
本课中我们将在Location页上实现Google Maps。我们实际上要做的事情如下:

  • 展示一个地图
  • 允许用户设置在地图上当前的位置
  • 显示上一次在地图上标记的位置
  • 激活导航给用户回到预设位置

在应用中设置Google Maps SDK以及使用他是非常简单的事情。你只需要简单的加载Google Maps SDK脚本,然后与其API交互。事情变得稍微有点复杂,因为我们有一个主要的问题:
如果用户没有联网咋办?
如果用户因为没有联网而用不了地图是不合理的,但是如何友好的处理这个问题呢?我们不希望报错和调处应用(因为Google Maps SDK没有加载)或者甚至造成地图没法工作,我们需要考虑以下几点:

  • 如果用户没有网络连接怎么办?
  • 如果用户在开始没有联网但是后面又有了?
  • 如果用话开始有联网但是后面又没有了?

为处理上以上的情景,我们的解决方案需要实现如下几点:

  • 不直接加载Google Maps SDK,等待到有网络连接再加载
  • 在网络断开的时候,禁用Google Maps功能
  • 网络再次连上的时候,启用Google Maps功能

为了让代码更清晰,我们将抽象出大量的功能到早先生成的Google Maps提供者中。这样在别的应用中就能很简单的重用这些代码了。
注意:我们这节课中将会使用Google Maps JavaScript SDK,但是你需要知道你也可以通过Cordova插件使用它们的本机SDK:https://github.com/mapsplugin/cordova-plugin-googlemaps
这节课会变得很大,所以我们还是尽快开始吧。我们先从实现一个Connectivity服务开始,也就是我们早先生成的另一个提供者。

Connectivity服务

这将是一个用来检查网络连接的快速简单的服务。如果用的是真机的话,我们可以使用之前安装的network information插件(这个更准确),但是如果应用是通过普通浏览器运行的话,我们使用onLine属性来检查联网(没插件那么准确)。
> 修改 src/providers/connectivity.ts 为如下:

  1. import { Injectable } from '@angular/core';
  2. import { Network } from 'ionic-native';
  3. import { Platform } from 'ionic-angular';
  4. declare var Connection;
  5. @Injectable()
  6. export class Connectivity {
  7. onDevice: boolean;
  8. constructor(public platform: Platform){
  9. this.onDevice = this.platform.is('cordova');
  10. }
  11. isOnline(): boolean {
  12. if(this.onDevice && Network.connection){
  13. return Network.connection !== Connection.NONE;
  14. } else {
  15. return navigator.onLine;
  16. }
  17. }
  18. isOffline(): boolean {
  19. if(this.onDevice && Network.connection){
  20. return Network.connection === Connection.NONE;
  21. } else {
  22. return !navigator.onLine;
  23. }
  24. }
  25. }

这个服务直白明了。我们创建了一个onDevice便用,然后通过Platform来检查应用是否运行在真机上。然后我们使用onDevice变量来检查我们是否要检查navigator.connection.type来确认使用哪种网络信息插件,或者只是检查navigator.onLine属性。
我们定义了两个函数isOnlineisOffline,这样我们在任何导入了此服务的类里都可以调用这两个方法。技术层面上,你只需要定义其中一个方法就可以了(如果isOnline返回false的话我们显然就已经知道是离线状态),但是我觉得用两个其实也蛮好的。
这就是这个服务的全部,我们还是接着高Google Maps 服务吧。

Google Maps 服务

这个服务将持有咱们地图功能的大部分逻辑。这是一个很大的服务所以我们先来创建一点骨架然后功能一个一个的去实现。
> 修改 src/providers/google-maps.ts 为如下:

  1. import { Injectable } from '@angular/core';
  2. import { Connectivity } from './connectivity';
  3. import { Geolocation } from 'ionic-native';
  4. declare var google;
  5. @Injectable()
  6. export class GoogleMaps {
  7. mapElement: any;
  8. pleaseConnect: any;
  9. map: any;
  10. mapInitialised: boolean = false;
  11. mapLoaded: any;
  12. mapLoadedObserver: any;
  13. currentMarker: any;
  14. apiKey: string;
  15. constructor(public connectivityService: Connectivity) {
  16. }
  17. init(mapElement: any, pleaseConnect: any): Promise<any> {
  18. }
  19. loadGoogleMaps(): Promise<any> {
  20. }
  21. initMap(): Promise<any> {
  22. }
  23. disableMap(): void {
  24. }
  25. enableMap(): void {
  26. }
  27. addConnectivityListeners(): void {
  28. }
  29. changeMarker(lat: number, lng: number): void {
  30. }
  31. }

注意,我们把刚制作的Connectivity服务导入进来了,然后作为服务注入到了构造器(译者:原文可能有误,说是added it as a provider in the decorator)。我们也从Ionic Native中导入了Geolocation插件。我们也在导入语句后面添加了declare var google — 这样TypeScript编译器就不会大逃到我们了。由于我们动态加载Google Maps SDK,编译器不知道google是什么,在我们不声明(declare)这个变量的情况下,会想我们抛出错误。
你应该也注意到了有些函数返回Promise。这是因为我们想追踪地图完成加载的时机,所有这些函数组成一条链(一个接一个的调用),所以实际上我们可以菊花链这些promise到最初的那个,也就是在最初的那个中我们可以设置一个处理器来处理地图加载完成的之后的逻辑。
最后,我们创建了很多函数,我们一个一个实现并解释。
> 修改 init 函数如下:

  1. init(mapElement: any, pleaseConnect: any): Promise<any> {
  2. this.mapElement = mapElement;
  3. this.pleaseConnect = pleaseConnect;
  4. return this.loadGoogleMaps();
  5. }

我们可以在导入这个服务的地方随时调用init函数来触发地图的加载流程。我们简单的返回loadGoogleMaps函数,这样将会执行这个方法并返回他的结果(也就是一个Promise)。
由于我们会从location.ts调用这个函数,我们就可以传入早先用@ViewChild获取的mappleaseConnect元素。我们这里接受他们作为参数,作为成员变量这样我们可以在类里面任何地方访问到他们。
现在,我们来实现loadGoogleMaps函数。
> 修改 loadGoogleMaps 函数如下:

  1. loadGoogleMaps(): Promise<any> {
  2. return new Promise((resolve) => {
  3. if(typeof google == "undefined" || typeof google.maps == "undefined"){
  4. console.log("Google maps JavaScript needs to be loaded.");
  5. this.disableMap();
  6. if(this.connectivityService.isOnline()){
  7. window['mapInit'] = () => {
  8. this.initMap().then(() => {
  9. resolve(true);
  10. });
  11. this.enableMap();
  12. }
  13. let script = document.createElement("script");
  14. script.id = "googleMaps";
  15. if(this.apiKey){
  16. script.src = 'http://maps.google.com/maps/api/js?key=' +this.apiKey + '&callback=mapInit';
  17. } else {
  18. script.src = 'http://maps.google.com/maps/api/js?callback=mapInit';
  19. }
  20. document.body.appendChild(script);
  21. }
  22. }
  23. else {
  24. if(this.connectivityService.isOnline()){
  25. this.initMap();
  26. this.enableMap();
  27. }
  28. else {
  29. this.disableMap();
  30. }
  31. }
  32. this.addConnectivityListeners();
  33. });
  34. }

这个函数看起来蛮复杂的,但实际上很直白。首先我们通过检查googlegoogle.maps是否可用来检查Google Maps是否加载,因为如果这两个变量可用的话就意味这SDK已经加载好了。
如果SDK没有加载完成的话,我们将触发加载流程。由于SDK还没有加载完成,我们首先调用了disableMap函数,这个函数告诉用户地图目前不可用。然后我们通过connectivity服务来检查用户是否在线,如果在线的话我们通过给应用添加script元素来注入Google Maps SDK。注意,URL加入了一个&callback=mapInit。这孕育我们在应用完成Google Maps SDK的加载后触发一个函数,在当前应用中,我们在完成加载后调用的是initMapenableMap函数(马上就实现)。注意,我们这是了另一个Promise这样一来我们可以等到initMap完成之后再返回。
如果SDK已经紧挨在完成,那么我们检查用户是否在线。如果在线的话,我们初始化并激活地图,如果不在线的话那么禁用地图。
最后一行调用的函数addConnectivityListener函数稍后实现。这个函数会监听上线和离线时间,这样我们是到何时启用和禁用地图,当用户打开应用初始化的时候是离线状态想要加载SDK的时候也一样。
接下来是另一个函数。
> 修改 initMap 函数如下:

  1. initMap(): Promise<any> {
  2. this.mapInitialised = true;
  3. return new Promise((resolve) => {
  4. Geolocation.getCurrentPosition().then((position) => {
  5. let latLng = new google.maps.LatLng(position.coords.latitude,position.coords.longitude);
  6. let mapOptions = {
  7. center: latLng,
  8. zoom: 15,
  9. mapTypeId: google.maps.MapTypeId.ROADMAP
  10. }
  11. this.map = new google.maps.Map(this.mapElement, mapOptions);
  12. resolve(true);
  13. });
  14. });
  15. }

现在Google Maps SDK加载完成了,这个函数用于使用SDK设置一个新地图。我们想以用户当前位置来居中显示地图,我们我先调用了Geolocation插件的getCurrentPosition函数。一旦Promise返回解析完成,他传入的将是一个position对象,这个对象包含了用户当前的latitude和longitude。我们通过这些值,随同其他一些设置(缩放级别和地图类型)来创建一个新的地图实例。
这个地图将会被创建到传入的元素内(#map)。所以,这段代码运行后,Google Maps将会被添加到我们的Location页模板上。
此刻,地图已经准备好进行交互了,所以我们解析了promise链中的最后的promise,他将触发解析所有的promise,此时我们知道地图已经准备好了。
虽然我们已经加载完地图了,但是还需要创建一些函数。
> 修改 disableMap 和 enableMap函数如下:

  1. disableMap(): void {
  2. if(this.pleaseConnect){
  3. this.pleaseConnect.style.display = "block";
  4. }
  5. }
  6. enableMap(): void {
  7. if(this.pleaseConnect){
  8. this.pleaseConnect.style.display = "none";
  9. }
  10. }

我们这里做的是展示和隐藏Google Maps区域上的覆盖层,这样在用户没有连接到互联网的时候就不能使用地图,以及显示一个信息“Please connect to the Internet…”。以上代码用在在用户获得和失去网络连接的时候展示和隐藏一个信息元素。
我们需要给这个覆盖层元素进行自定义样式。
> 修改 src/pages/location/location.scss 为如下:

  1. page-location {
  2. #please-connect {
  3. position: absolute;
  4. background-color: #000;
  5. opacity: 0.5;
  6. width: 100%;
  7. height: 100%;
  8. z-index: 1;
  9. }
  10. #please-connect p {
  11. color: #fff;
  12. font-weight: bold;
  13. text-align: center;
  14. position: relative;
  15. font-size: 1.6em;
  16. top: 30%;
  17. }
  18. }

现在,当用户没有联网的时候,会看到这样的屏幕:
断网

在达到这点之前我们还需要做一些事情。接下来我们来到了addConnectivityListeners函数。
> 修改 src/providers/google-maps.ts 的 addConnectivityListeners 函数如下:

  1. addConnectivityListeners(): void {
  2. document.addEventListener('online', () => {
  3. console.log("online");
  4. setTimeout(() => {
  5. if(typeof google == "undefined" || typeof google.maps == "undefined"){
  6. this.loadGoogleMaps();
  7. }else {
  8. if(!this.mapInitialised){
  9. this.initMap();
  10. }
  11. this.enableMap();
  12. }
  13. }, 2000);
  14. }, false);
  15. document.addEventListener('offline', () => {
  16. console.log("offline");
  17. this.disableMap();
  18. }, false);
  19. }

如我所述,这个函数负责处理用户的联网状态在离线和上线之间的切换。我们在这里监听了‘online’和‘offline’事件,他们会在用户上线和离线的时候触发。
当用户上线的时候,我们检查Google Maps是否已经加载好了,如果没有的话就加载他。否则,检查地图是否初始化没有的话初始化他,有的话激活地图。注意,我们在这里有用到一个setTimeout,这些代码在用户上线2秒后运行一次 — 之前即刻触发会遇到问题,所以我给连接一点点稳定下来的时间。
当用户离线的时候,我们禁用了地图。最后一个需要实现的函数是changeMaker函数。
> 修改 changeMaker函数如下:

  1. changeMarker(lat: number, lng: number): void {
  2. let latLng = new google.maps.LatLng(lat, lng);
  3. let marker = new google.maps.Marker({
  4. map: this.map,
  5. animation: google.maps.Animation.DROP,
  6. position: latLng
  7. });
  8. if(this.currentMarker){
  9. this.currentMarker.setMap(null);
  10. }
  11. this.currentMarker = marker;
  12. }

所有其他看书都很通用可以在其他任何要用到Google Maps的应用中直接使用,但是这个函数是这个应用才能使用的。在addMarker函数之上,这个函数会移除之前的marker添加一个新的marker。我们只希望用户设置一个露营地点。
我们只需要把需要添加marker的地方的latitude和longitude传入进去创建一个marker就可以了。如果有已经存在的marker的话我们就先移除他,然后添加刚创建的marker。
现在我们已经完成了Google Maps服务的设置,我们只要用它他就可以了!

实现Google Maps

我们已经做了大量的工作让地图正常工作,但是我们还一点点的路要走。现在我们要修改Location类定义来使用Google Maps服务。我们已经导入了这个服务,也将他添加到了providers数组以及给他创建了引用,这样我们就可以开始使用他了。
> 修改 src/pages/location/location.ts 的 ionViewDidLoad 函数:

  1. ionViewDidLoad(): void {
  2. this.maps.init(this.mapElement.nativeElement,this.pleaseConnect.nativeElement).then(() => {
  3. //this.maps.changeMarker(this.latitude, this.longitude);
  4. });
  5. }

首先,我们通过调用this.maps.init方法来触发地图的加载,这个函数将会返回一个promise。这个promise在地图完成加载的时候解析,当他解析完成的时候我们调用changeMarker函数。
现在是不会正常工作的因为latitude和longitude现在是undefined,所以我把它注释掉了。稍后,我们将将在保存在存储中的latitude和longitude。
这时候,地图应该加载完成了,且在屏幕上可见了,但是现在显示应该是错误的。首先,我们需要在.scss文件中添加一些样式来使他显示正常。
> 修改 location.scss 为如下:

  1. page-location {
  2. #please-connect {
  3. position: absolute;
  4. background-color: #000;
  5. opacity: 0.5;
  6. width: 100%;
  7. height: 100%;
  8. z-index: 1;
  9. }
  10. #please-connect p {
  11. color: #fff;
  12. font-weight: bold;
  13. text-align: center;
  14. position: relative;
  15. font-size: 1.6em;
  16. top: 30%;
  17. }
  18. .scroll {
  19. height: 100%;
  20. }
  21. #map {
  22. width: 100%;
  23. height: 100%;
  24. }
  25. }

接下来我们实现setLocation函数, 这个函数用于将用户的露营设置为当前地点。
> 修改 src/pages/location/location.ts 的 setLocation 函数如下:

  1. setLocation(): void {
  2. Geolocation.getCurrentPosition().then((position) => {
  3. this.latitude = position.coords.latitude;
  4. this.longitude = position.coords.longitude;
  5. this.maps.changeMarker(position.coords.latitude, position.coords.longitude);
  6. let data = {
  7. latitude: this.latitude,
  8. longitude: this.longitude
  9. };
  10. //this.dataService.setLocation(data);
  11. let alert = this.alertCtrl.create({
  12. title: 'Location set!',
  13. subTitle: 'You can now find your way back to your camp site from anywhere by clicking the button in the top right corner.',
  14. buttons: [{text: 'Ok'}]
  15. });
  16. alert.present();
  17. });
  18. }

注意:再一次,我们注释掉了还未实现的数据服务。
在这里,我们用到了Geolocation来获取用户当前位置。一旦得到这些信息,我们将this.latitudethis.longitude改为用户当前位置,我们也用这个位置调用了changeMarker函数。我们也希望应用可以记住当前这个位置,所以我们为这个位置创建了一个对象传给数据服务去保存(记住,咱们还未实现此功能)。
一旦这些流程都完成了,我们出发一个警告框告诉用户位置设置成功。现在我们还剩下唯一的一个函数需要去定义。
> 修改 src/pages/location/location.ts 的 takeMeHome 函数如下:

  1. takeMeHome(): void {
  2. if(!this.latitude || !this.longitude){
  3. let alert = this.alertCtrl.create({
  4. title: 'Nowhere to go!',
  5. subTitle: 'You need to set your camp location first. For now, want tolaunch Maps to find your own way home?',
  6. buttons: ['Ok']
  7. });
  8. alert.present();
  9. }
  10. else {
  11. let destination = this.latitude + ',' + this.longitude;
  12. if(this.platform.is('ios')){
  13. window.open('maps://?q=' + destination, '_system');
  14. } else {
  15. let label = encodeURI('My Campsite');
  16. window.open('geo:0,0?q=' + destination + '(' + label + ')','_system');
  17. }
  18. }
  19. }

这个函数的作用是给用户展示他当前的位置和营地的位置。
首先,我们检查了this.latitudethis.longitude是否设置好了,如果没有的话我们会弹出警告框提醒他们需要先设置他们的地点。
如果已经设置好了的话,我们通过Google Maps和Apple Maps URL配置来启动应用,并且向他们提供坐标信息。如果我们运行在iOS上的话,那么我们会通过maps://配置来启动Apple Maps,如果我们是使用的Android应用的话,我们会通过geo:配置来启动Google Maps。现在,当用户触发此函数的时候,将会弹出一个地图给用户指示如何回到露营地点。
完成了!我们完成了我们的地图功能了。用户现在应该可以看到地图了,设置好他们的地址,触发回营方法。我们还有一个很重要的事情需要去处理,也就是用户回到应用的时候需要获取之前的保存的数据。下节课中,我们马上就处理这个事情,同时还有保存表单数据。