iOS-Mapkit高级教程:自定义瓦片

翻译自:https://www.raywenderlich.com/158175/advanced-mapkit-tutorial-custom-tiles

地图在现代应用中无处不在。地图可以提供附近兴趣点(POI)的位置, 帮助用户导览一个商业区或者公园, 寻找附近的朋友, 旅游足迹追踪或者为增强现实游戏提供上下文。

遗憾的是, 这意味着嵌入在应用中的大多数地图看起来一样。

本教程介绍了如何添加手绘地图, 而不是编程式生成的地图, 如同口袋妖怪GO(PokemonGo)中地图。

手绘地图是一项重要的工作。考虑到地球的大小, 它仅仅适用于定义明确,小范围地理区域。 如果在你脑海中已经有了良好定义的区域地图, 那么自定义地图会为你的 app 赢得大量赞叹。

基本介绍

此处下载工程模板。

MapQuest是一款有趣的冒险游戏,我们就以它开始吧。英雄在中央公园,即现实生活中的纽约城(NYC)里到处乱跑, 但是从冒险开始, 大怪物和收集财宝都是在实境中完成。它的设计如此可爱和童稚,让玩家感觉很舒服并不感到乏味。

游戏中有几个兴趣点(POI)。这些定义位置的兴趣点可以让玩家与游戏进行交互。 这些可以是任务,怪物,商店或者其他游戏元素。 进入 POI 周围 10 米范围内即认为是相遇。为了本教程, 实际的游戏相对于地图渲染是次要的。

工程中包含两个重要文件:

MapViewController.swift: 管理地图视图,处理用户交互逻辑及状态变化。

Game.swift: 包含了游戏逻辑以及管理一些游戏对象的坐标。

游戏的主要视图是一个MKMapView。 在所有的缩放层级 MapKit 使用 瓦片来填充视图并且提供地理特征,道路等的信息。

地图视图既可以展示传统的公路地图也可以展示卫星地图。这在城市导航中非常有用,但是想象你在中世纪世界的冒险而言是无用的。然而, MapKit 允许你提供自己的地图图片来自定义地图展示效果及信息展示。

地图视图是由许多瓦片组成的,当你在视图周围平移时,它们会被动态加载。 瓦片是256像素256像素,被嵌入到一个网格中,对应一个墨卡托投影地图。

编译并运行 app, 查看运行中的地图。

哇! 多么美丽的商业区。 游戏的主要界面是定位, 这意味着在未到达中央公园时你什么也看不到也不能做任何操作。

位置测试

与其他教程不同的是, MapQuest 一开始就是一款功能性 app! 但是, 如果你不住在纽约市,  那就有点小小的遗憾。幸运的是, XCode 提供至少两种模拟位置的方法。

模拟一个地理位置

当 app 在 iPhone 模拟器处于运行状态时,设置用户的位置。

定位到Debug\Location\Custom Location….

设置维度值为40.767769,经度值为-73.971870。这将激活蓝色用户位置点并将地图定位到中央公园动物园。这里住着一只小妖精; 你将被迫进入战斗然后收集财宝。

在打败无助的小妖精后,你将会被定位到动物园内 (注意蓝色点)。

模拟一个冒险

对于许多基于位置的 app ,  静态位置测试是非常有用的。 然而,作为冒险的一部分这个游戏需要访问多个地点。模拟器可以为跑步,骑行和驾驶更改位置。这些预置的行程只适用于库比蒂诺(苹果电脑的全球总公司所在地,位于美国旧金山), 但是 MapQuest 只会在纽约相遇。

像这样的场合需要使用 GPX(GPS 交换格式) 文件来模拟位置。这个文件定义了许多导航点,模拟器将在它们之间生成一条路径。

创建此文件不在本教程范围之内, 但是示例工程已经自带了一个GPX测试文件供您使用。

在 Xcode 中通过选择Product\Scheme\Edit Scheme….  打开 scheme editor

在左面板中选择Run, 接着选择 右面板中的Options。 在Core Location区域, 点击并选中Allow Location Simulation。在Default Location下拉控件中选择Game Test

这意味着 app 将会在Game Test.gpx文件中定义的 导航点之间移动。

编译并运行。

模拟器将会从第五大道地铁移动到中央公园动物园,在那儿你将同小妖精进行战斗。 之后, 到达你最喜欢的水果公司旗舰店并在那里购买升级后的剑, 一次循环完成, 冒险会重新开始。

使用 OpenStreetMap 替换瓦片

OpenStreetMap是一个社区支持的开放地图数据库。 这些数据可以用来生成被苹果地图使用的同样类型的地图瓦片。OpenStreetMap 社区不仅仅提供基本公路地图,还包括专业地形地图, 单车及艺术渲染。

注意:Open Street Map 瓦片 使用协议对数据的使用,归属及 API 访问 有严格的要求。 对于教程来说比较友好, 但在生产环境的应用中使用 瓦片之前请检查合规政策。

创建一个新浮层

使用 一个MKTileOverlay替换地图瓦片并在默认的苹果地图上显示新瓦片。

打开MapViewController.Swift, 使用如下代码替换setupTileRenderer():

func setupTileRenderer() {

// 1

let template = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"

// 2

let overlay = MKTileOverlay(urlTemplate: template)

// 3

overlay.canReplaceMapContent = true

// 4

mapView.add(overlay, level: .aboveLabels)

//5

tileRenderer = MKTileOverlayRenderer(tileOverlay: overlay)

}

默认情况下,MKTileOverlay支持通过瓦片路径的 URL 链接来加载瓦片。

这是 Open Street Map 提供的用于下载地图瓦片的 API。{x},{y}和{z}在运行时会被各个瓦片的坐标来替换。 z-坐标, 或者缩放级别是由用户在地图中缩放了多少来指定的。x和y是给定所示地球部分的瓦片的索引。对于每个支持的缩放级别,每个瓦片都需要设置 x 和 y。

创建一个浮层.

设置瓦片为不透明并替换默认地图瓦片。

将浮层添加到mapView。 自定义瓦片可以在公路或标签(像 道路和地名)的上方。Open street map 瓦片为预先标记, 这些瓦片应该在苹果标签的上方。

创建一个瓦片渲染器用来绘制瓦片。

在瓦片展示之前, 必须创建瓦片渲染器用于绘制瓦片。

在viewDidLoad()方法末尾 添加如下代码:

mapView.delegate = self

设置MapViewController为mapView的代理。

接着, 在 MapView 代理扩展中,添加如下方法:

func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {

return tileRenderer

}

浮层渲染器 通知 地图视图 如何去绘制一个浮层。 瓦片渲染器(MKTileOverlayRenderer) 是浮层渲染器(MKOverlayRenderer)的子类,用于加载和绘制地图瓦片。

就是这些! 编译并运行观察替换标准苹果地图后的 Open Street Map。

此刻, 你会看到 苹果地图与开源地图的不同!

划分地球

瓦片浮层的魔力是将瓦片路径转换到图片资源。 瓦片的路径由他们的坐标:x,yz 来表示。  x 和 y 相当于地图表面的索引, 0,0 表示左上的瓦片。  z-坐标 代表缩放层级并决定了当前层级的地图上有多少个瓦片组成。

在缩放层级 0, 世界地图由一个 1*1 的网格表示, 需要一个瓦片:

在缩放层级 1, 世界地图被划分为 2*2 的网格。这需要4个瓦片:

在层级 2, 网格的行和列再次翻倍, 需要 16 个瓦片:

按照这用模式继续下去,每个缩放级别的细节水平和瓷砖数量都要翻一番,每个缩放层级需要 22*z个瓦片, 一直下去直到缩放层级 19 需要 274,877,906,944 个瓦片!

创建自定义瓦片

因为地图视图是按照用户的位置来设置的, 默认的缩放层级设置为 16,  这个层级可以更好的展示用户位置. 缩放层级 16 的整个地图需要 4,294,967,296 个瓦片! 这需要花费一生的时间来手绘这些瓦片。

拥有一个更小的区域,如商业区或者公园使得手绘自定义瓦片成为可能。 对于更大区域的位置,可以使用程序处理资源数据来生成瓦片。

因为本游戏中的瓦片是预渲染的并且包含在资源中, 你可以方便的加载它们。不幸的是, 一个通用的 URL 模板是不够的, 如果渲染器请求数十亿个瓦片中并不包含在应用资源中的任一瓦片,最好优雅的失败。

做到这一点, 你需要自定义一个MKTileOverlay的子类。 打开AdventureMapOverlay.swift并添加如下代码:

lass AdventureMapOverlay: MKTileOverlay {

override func url(forTilePath path: MKTileOverlayPath) -> URL {

let tileUrl = "https://tile.openstreetmap.org/(path.z)/(path.x)/(path.y).png"

return URL(string: tileUrl)!

}

}

这样就创建了一个子类, 并使用具有特定URL生成器的模板URL替换基本类。

保持 Open Street Map 瓦片可用,并测试自定义浮层。

打开MapViewController.swift,使用如下代码替换setupTileRenderer():

func setupTileRenderer() {

let overlay = AdventureMapOverlay()

overlay.canReplaceMapContent = true

mapView.add(overlay, level: .aboveLabels)

tileRenderer = MKTileOverlayRenderer(tileOverlay: overlay)

}

此处替换为自定义的子类。

再次编译并运行。 如果一切顺利, 展示的游戏与之前的一样。 哇!

加载预渲染瓦片

现在到了有趣的部分。打开AdventureMapOverlay.swift, 使用如下代码替换url(forTilePath:):

override func url(forTilePath path: MKTileOverlayPath) -> URL {

// 1

let tilePath = Bundle.main.url(

forResource: "(path.y)",

withExtension: "png",

subdirectory: "tiles/(path.z)/(path.x)",

localization: nil)

guard let tile = tilePath else {

// 2

return Bundle.main.url(

forResource: "parchment",

withExtension: "png",

subdirectory: "tiles",

localization: nil)!

}

return tile

}

本段代码为游戏加载自定义瓦片。

首先, 使用已知的命名方案在资源文件中找到一个匹配的文件。

如果某个瓦片没有提供, 使用一个羊皮纸样式的图片代替,这能使人幻想起世纪的感觉。 这也省却了需要每瓦的路径提供一个唯一的资源。

再次编译并运行。 现在展示自定义地图。

试着缩放地图,查看不同层级的地图细节。

设定缩放层级范围

不要缩放的太远, 否则你将会看不到整个地图。

幸运的是,这很好解决。打开MapViewController.swift,在setupTileRenderer() 方法末尾添加如下代码:

overlay.minimumZ = 13

overlay.maximumZ = 16

这会通知mapView瓦片只能在这些缩放层级中提供。 当缩放层级超过了限定范围,瓦片的图片由 app 提供。 没有提供更多的细节, 但至少现在显示的图片可以匹配.

创建瓦片

下一部分是可选的, 因为它介绍了如何绘制定义的瓦片。 跳过更多的MapKit 技术,跳到 "美化地图" 那部分。

本演示最困难的部分是创建大小合适的瓦片并把它们都排好。 要想绘制你自定义的瓦片,你需要一个数据源和图片编辑器。

打开项目目录同时查看MapQuest/tiles/14/4825/6156.png。这个瓦片图片显示了缩放层级为 14 时的中央公园地图底部地图图片。app 包含许多这样的小图片用以组成纽约市的地图,每个图片是采用基本的技术和工具手绘完成的。

你需要哪张瓦片?

第一步计算出将要绘制的是哪张瓦片。你可以从Open Street Map下载资源数据并使用类似MapNik的工具将其生成瓦片图片。不幸的是, 资源有 57GB ! 使用这个工具有点难度并且已经超出本教程的范围。

对于一个有界区域如中央公园, 有更简单的解决方案。

打开AdventureMapOverlay.swift,在url(forTilePath:)中 添加如下代码:

print("requested tile\tz:(path.z)\tx:(path.x)\ty:(path.y)")

编译并运行。 当你缩放并且平铺到地图上, 控制台会打印出 瓦片的路径。

接着 获取资源瓦片并进行自定义。你可以重用之前的 URL 方案 来获取 open street map 瓦片。

以下 terminal 命令将会下载文件并保存到本地。 你可以修改 URL, 使用指定的地图路径来替换 x, y 和 z。

curl --create-dirs -o z/x/y.pnghttps://tile.openstreetmap.org/z/x/y.png

使用中央公园南部地区, 试试:

curl --create-dirs -o14/4825/6156.pnghttps://tile.openstreetmap.org/14/4825/6156.png

zoom-level/x-coordinate/y-coordinate这个目录结构 使得后续查找和使用瓦片变得非常容易。

Customizing Appearances

自定义展示

下一步是使用定制的基础图像作为起点。在你喜欢的图片编辑器中打开瓦片图片。例如, 下图为使用 Pixelmator 打开文件的界面:

现在你可以使用刷子和铅笔工具绘制道路,路径及有趣的特征。

如果你的工具支持图层, 在不同的图层上绘制不同的特征将允许您调整它们以提供最佳的外观。使用图层使绘图更加容易些,因为您可以掩盖其他特征下面的凌乱线条。

现在在设置的所有瓦片上重复这样的操作。 正如你所看到的, 这将会花费不少时间。

你可以使这个过程更容易一些:

首先将整个层的所有瓦片组合在一起。

绘制自定义地图。

再把地图分割为瓦片。

瓦片存储位置

创建完新瓦片后, 将它们放回到项目的tiles/zoom-level/x-coordinate/y-coordinate目录结构下. 这样可以将资源分组管理并便于访问。

这意味着你能很方便的访问到瓦片, 在url(forTilePath:) 方法中添加如下代码.

let tilePath = Bundle.main.url(

forResource: "(path.y)",

withExtension: "png",

subdirectory: "tiles/(path.z)/(path.x)",

localization: nil)

这一阶段完成。现在你可以开始绘制一些漂亮的地图!

美化地图

地图看起来不错,符合游戏的审美。但是还有许多需要美化!

用蓝点表示英雄不太美观, 但是你可以使用自定义控件来代替当前的位置标注。

用户标注

打开MapViewController.swift,在 MapView 代理扩展中添加如下方法 :

func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {

switch annotation {

// 1

case let user as MKUserLocation:

// 2

let view = mapView.dequeueReusableAnnotationView(withIdentifier: "user")

?? MKAnnotationView(annotation: user, reuseIdentifier: "user")

// 3

view.image = #imageLiteral(resourceName: "user")

return view

default:

return nil

}

}

这段代码自定义了用户标注视图。

使用 MKUserLocation 标注用户位置。

MapViews 维护一个标注视图重用缓冲池,便于提高性能。如果重用池中没有标注视图,则会创建一个。

一个标准的MKAnnotationView相当灵活,但是此处仅仅展示一张代表冒险家的图片。

编译并运行。 将会有一个小的简笔人物画替代蓝点在屏幕上徘徊。

指定位置标注

MKMapView同时允许你去标注你感兴趣的位置。 MapQuest 连同纽约地铁,将地铁系统视为一个巨大的隧道网络。

在地铁站附近的地图上添加一些标记点。 打开MapViewController.swift, 在viewDidLoad()方法的末尾添加如下代码:

mapView.addAnnotations(Game.shared.warps)

编译并运行, 现在一些精选的地铁站使用大头针来表示。

如同 用户位置的蓝点, 这些标准大头针和游戏美学并不匹配 可以自定义标注。

在mapView(_:viewFor:)方法的 switch 语句 的 default case 之上添加如下的 case :

case let warp as WarpZone:

let view = mapView.dequeueReusableAnnotationView(withIdentifier: WarpAnnotationView.identifier)

?? WarpAnnotationView(annotation: warp, reuseIdentifier: WarpAnnotationView.identifier)

view.annotation = warp

return view

再次编译并运行。 自定义标注视图将使用模板图像,并为特定的地铁线路着色。

自定义浮层渲染

MapKit 提供了许多方法用于美化游戏的地图。接着

MapKit 提供了许多方法用于美化游戏地图。 接下来, 使用一个MKPolygonRenderer在 水库上绘制一个渐变的闪光效果。

使用如下代码替换setupLakeOverlay():

func setupLakeOverlay() {

// 1

let lake = MKPolygon(coordinates: &Game.shared.reservoir, count: Game.shared.reservoir.count)

mapView.add(lake)

// 2

shimmerRenderer = ShimmerRenderer(overlay: lake)

shimmerRenderer.fillColor = #colorLiteral(red: 0.2431372549, green: 0.5803921569, blue: 0.9764705882, alpha: 1)

// 3

Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in

self?.shimmerRenderer.updateLocations()

self?.shimmerRenderer.setNeedsDisplay()

}

}

这样会创建一个新浮层:

创建一个同水库形状一样的MKPolygon标注。这些坐标是写死到Game.swift文件中的。

创建一个自定义渲染器用于特定的效果。

因为浮层渲染器不会提供动画效果, 因此创建一个每 100ms 更新一次的定时器来更新浮层。

在下来, 使用如下代码替换mapView(_:rendererFor:):

func setupLakeOverlay() {

// 1

let lake = MKPolygon(coordinates: &Game.shared.reservoir, count: Game.shared.reservoir.count)

mapView.add(lake)

// 2

shimmerRenderer = ShimmerRenderer(overlay: lake)

shimmerRenderer.fillColor = #colorLiteral(red: 0.2431372549, green: 0.5803921569, blue: 0.9764705882, alpha: 1)

// 3

Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in

self?.shimmerRenderer.updateLocations()

self?.shimmerRenderer.setNeedsDisplay()

}

}

这将会为两个浮层中的每一个选择正确的渲染器。

再次编译并运行。 然后到水库去看波光粼粼的水!

下一步?

想要快速学习? 观看我们的视频课程以便节省时间

你可以下载本教程的完整项目。

创建手绘地图瓦片非常耗时, 但是可以给使用 app 的玩家一种身临其境的感觉。 除了创建资源,使用它们非常简单。

除了基本的瓦片, Open Street Map 列出了特定瓦片提供服务清单如骑行和地形。 Open Street Map 也提供了供你使用的数据,这些数据可以帮你以编程的方式设计你自己的瓦片。

如果你想自定义但有逼真的地图展示, 而不需要手绘所有东西, 可以使用第三方的工具,如MapBox。它价格设置合理,你可以方便的定制地图的外观。

更多关于自定义浮层和标注的信息, 参考另一篇教程

如果你有任何问题或评论关于本教程,请加入下面的讨论!

Open Street Map 数据和图片由 © OpenStreetMap 提供。

推荐阅读更多精彩内容