規劃與實作

請先在 Xcode 打開這個專案( apps/taipeitravel/TaipeiTravel )以供後續與文章內容比對檢視,本小節說明僅會提示部分內容,不會將所有程式碼都寫出來,請以專案程式碼為主。

首先介紹遊玩臺北應用程式可以操作的動作,先看下圖流程:

intro

上圖由 UITabBarController 的第一個 Tab 景點為應用程式預設起始頁,列表中會顯示與目前定位位置的距離(如果有授權定位功能),點擊項目後進入這個景點的詳細頁,會列出相關的資訊,當 API 有提供這個地點的位置時,則可以點擊第一列地圖以進入地圖頁。

其餘三個公園、廁所及住宿的運作方式與景點相同。

第五個 Tab 關於頁則是可以前往支援網頁與來源網頁,並提供簡單說明。

前置作業

首先在 Xcode 裡,新建一個 Single View Application 類型的專案,取名為 TaipeiTravel 。

一開始先依照程式之外的設定的步驟設定好。以及將需要的 Swift 檔案、圖示檔案都先加入至專案中:

  • BaseMainViewController.swift
  • DetailViewController.swift
  • MapViewController.swift
  • HotelMainViewController.swift
  • HotelDetailViewController.swift
  • LandmarkMainViewController.swift
  • LandmarkDetailViewController.swift
  • ParkMainViewController.swift
  • ParkDetailViewController.swift
  • ToiletMainViewController.swift
  • ToiletDetailViewController.swift
  • InfoMainViewController.swift
  • icons 目錄中的五個圖示檔案

AppDelegate.swift

在進入應用程式的畫面前,需要在 AppDelegate.swift 中先做些設定:

  • 向使用者要求定位功能權限。(請參考地圖與定位內容。)
  • 設置導覽列的一些預設樣式。
  • 依照標籤列控制器 UITabBarController 的說明,將根視圖設為一個 UITabBarController ,接著再設置每個 Tab 的根視圖控制器為 UINavigationController ,最後依序為每個 Tab 設置 LandmarkMainViewController, ParkMainViewController, ToiletMainViewController, HotelMainViewController 為首頁。

此專案結合 標籤列控制器 UITabBarController 與 導覽控制器 UINavigationController ,所以必須先設置 UITabBarController 再設置 UINavigationController ,以達到有標籤列也有導覽列的功能。

景點首頁

因為前四個 Tab 內容差不多,這邊以景點頁面作範例,其餘三個則是類似的方式進行。

Coordinate.swift

在進入到首頁之前,先新增一個用來依照距離遠近排序景點的檔案。先以新增檔案的方式加入這個檔案,檔案類型要選擇iOS > Source > Swift File。

因為會使用到定位功能,所以記得先引入定位函式庫:

import CoreLocation

接著新增一個名為 Coordinate 的結構( Struct ),有三個屬性,依序為景點的索引值、緯度及經度,如下:

struct Coordinate {
    var index: Int
    var latitude: Double
    var longitude: Double
}

然後為這個結構 Coordinate 擴展 Comparable 協定( Comparable 是一個 Swift 內建的協定,常見型別如 Int, Double 都有實作),讓型別為 Coordinate 的變數可以互相比較:

extension Coordinate: Comparable {}

當擴展 Comparable 協定時,必須實作下列兩個方法(==為比較兩者是否相等,<則是比較前者是否小於後者),才可以讓變數彼此比較:

func ==(a: Coordinate, b: Coordinate) -> Bool {
    let myUserDefaults =
      NSUserDefaults.standardUserDefaults()

    // 是否取得定位權限
    let locationAuth = 
      myUserDefaults.objectForKey("locationAuth") 
      as? Bool

    if locationAuth != nil && locationAuth! {
        // 取得目前使用者座標
        let userLatitude = 
          myUserDefaults.objectForKey("userLatitude") 
          as? Double
        let userLongitude =
        myUserDefaults.objectForKey("userLongitude") 
          as? Double
        let userLocation = CLLocation(
          latitude: userLatitude!, 
          longitude: userLongitude!)

        // 兩點的座標
        let aLocation = CLLocation(
          latitude: a.latitude, 
          longitude: a.longitude)
        let bLocation = CLLocation(
          latitude: b.latitude, 
          longitude: b.longitude)

        return
        aLocation.distanceFromLocation(userLocation) 
        ==
        bLocation.distanceFromLocation(userLocation)
    } else {
        return a.index == b.index
    }

}

func <(a: Coordinate, b: Coordinate) -> Bool {
    let myUserDefaults =
      NSUserDefaults.standardUserDefaults()

    // 是否取得定位權限
    let locationAuth =
      myUserDefaults.objectForKey("locationAuth") 
        as? Bool

    if locationAuth != nil && locationAuth! {
        // 取得目前使用者座標
        let userLatitude =
          myUserDefaults.objectForKey("userLatitude") 
            as? Double
        let userLongitude =
        myUserDefaults.objectForKey("userLongitude") 
            as? Double
        let userLocation = CLLocation(
          latitude: userLatitude!, 
          longitude: userLongitude!)

        // 兩點的座標
        let aLocation = CLLocation(
          latitude: a.latitude, 
          longitude: a.longitude)
        let bLocation = CLLocation(
          latitude: b.latitude, 
          longitude: b.longitude)

        return 
        aLocation.distanceFromLocation(userLocation) 
        < bLocation.distanceFromLocation(userLocation)
    } else {
        return a.index < b.index
    }
}

上述兩個實作的方法中,會先取得 iPhone 目前所在的定位位置,再與兩個型別為 Coordinate 的變數分別以distanceFromLocation()方法取得距離,再以這個距離判斷哪一個景點較近,後續會再以索引值取得景點的其餘資訊。

BaseMainViewController.swift

這邊將重複的動作寫在繼承自 UIViewController 的 BaseMainViewController 中,再讓四個首頁繼承自他。

先注意以下 BaseViewController 的幾個屬性:

  • 建立屬性refreshDays,限制向遠端 API 要求資料的次數,超過這個天數才會再次取得新的資訊。
  • 建立屬性apiDataAll存放取得的所有景點資訊。
  • 建立屬性apiData存放依距離較近排序的有限景點資訊,以簡化顯示內容。
  • 建立屬性apiDataForDistance存放景點的經緯度資訊,以供依距離遠近排序的功能。
viewWillAppear()

在viewWillAppear()方法中,先確認是否取得定位權限,再依據是否有定位位置來排序景點資料。(當無定位權限時,會依照 API 提供的排序列出所有資料,有定位權限時,則是只列出較近距離的景點。)

viewDidDisappear()

離開這個頁面時,要記得停止定位自身位置。

其他方法

addData()方法是用來區分是否要取得遠端 API 資料,這邊會限制一個天數,如果超過這個天數才會重新取得資料,否則就是直接將已經存在的 JSON 檔案拿出來使用。

如果需要向遠端 API 取得資料,則是使用normalGet()方法來取得(請參考取得遠端 API 資料並儲存),在委任方法中會執行下載完成後的動作,因為會以一個閉包( closure )執行,所以必須使用dispatch_async(dispatch_get_main_queue(), {/* add table */})來以主執行緒更新 UITableView ,如下:

// 下載完成
func URLSession(session: NSURLSession, 
  downloadTask: NSURLSessionDownloadTask,
  didFinishDownloadingToURL location: NSURL) {
    let targetUrl = NSURL(string: self.targetUrl)!
    let data = NSData(contentsOfURL: location)
    if ((data?.writeToURL(
      targetUrl, atomically: true)) != nil) {
        print("普通獲取遠端資訊的方式:儲存資訊成功")

        // 更新獲取資料的日期
        self.myUserDefaults.setObject(
          self.todayDateInt, 
          forKey: "\(self.fetchType)FetchDate")
        self.myUserDefaults.synchronize()

        dispatch_async(dispatch_get_main_queue(), {
            self.addTable(self.targetUrl)
        })

    } else {
        print("普通獲取遠端資訊的方式:儲存資訊失敗")
    }
}

在addTable()方法中,會先以jsonParse()方法解析取得的 JSON 檔案,並將資料存在屬性apiDataAll。

接著在refreshAPIData()方法中,以reloadAPIData()方法來將屬性apiDataAll的內容依據限制再轉存至屬性apiData及設置另外的屬性apiDataForDistance(有定位權限時,依距離遠近排序有限數目。無權限時則是全部內容。)。

其中屬性apiDataForDistance是一個型別為[Coordinate]!的陣列,這個陣列會在fillIntoAPIDataForDistanceAndSort()方法中使用apiDataForDistance.sortInPlace(<)方法依照距離排序內部成員(請參考稍前介紹的 Coordinate.swift 內容),以供後續顯示在 UITableView 中。

UITableView 的委任方法

tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell方法是用來顯示每筆事項的內容,一開始會先以apiDataForDistance[indexPath.row].index取得已經依距離遠近排序的索引值,再以這個索引值到景點資料陣列apiData中取得目前要顯示的景點資料,如下:

let thisData =
  apiData[apiDataForDistance[indexPath.row].index]

取得景點資料之後,再作顯示名稱、距離多遠的後續處理。

LandmarkMainViewController.swift

繼承自 BaseMainViewController 的 LandmarkMainViewController 只需要再將前往細節頁的部份加上就差不多了。

在goDetail()方法中,會先取得目前所點擊的景點資訊,再將細節頁要顯示的資訊存在 NSUserDefaults 中,便可以前往細節頁。

景點 細節頁

細節頁也是如同首頁,將重複的動作寫在一隻檔案中,再各自繼承自他。

DetailViewController.swift

先注意以下 DetailViewController 的屬性:

  • 建立屬性hasMap,來區分目前這筆景點資訊有沒有經緯度資訊,有的話才可以進入地圖頁。
UITableView 的委任方法

tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath)方法負責點擊項目時的動作。這部份加上一個點擊時可展開顯示全部文字內容的功能。

LandmarkDetailViewController.swift

繼承自 DetailViewController 的 LandmarkDetailViewController 將要顯示的資料從 NSUserDefaults 取回並填入陣列中即可。

地圖頁

這頁會使用到地圖功能,所以要先引入地圖函式庫(請參考地圖與定位內容。):

import MapKit

在viewDidLoad()中取得景點資訊,並在地圖上顯示景點的位置,以及使用者自己的位置(如果有取得定位授權的話)。

公園、廁所與住宿頁面與上述內容類似,便不再重複介紹。

關於頁

這頁使用 UITableView 的 .Grouped 特性各別列出不同的功能:

  1. 前往外部 Facebook 網頁。
  2. 前往外部 資料與圖示資源 網頁。
  3. 應用程式說明。

前往外部網頁時,使用下列方式:

let requestUrl = NSURL(string:
  "https://www.facebook.com/1640636382849659")
UIApplication.sharedApplication().openURL(requestUrl!)

範例

此應用程式範例程式碼放在 apps/taipeitravel/TaipeiTravel

你也可以在 App Store 中找到這個應用程式,名稱為遊玩臺北 icon 。

results matching ""

    No results matching ""