#31 天氣App | part 2, 串接OpenWeatherAPI

這次利用CocoaPods來管理google place的套件,並使用兩種API,分別是google套件以及抓取天氣預報的API,雖然天氣預報免費的只有近五天每三小時顯示一次,一開始抓完資料顯示出來的溫度看起來都差不多,還想說是不是壞了,後來對照一下手機上面的其實沒有差多少,還是蠻準的啦~

⬇️ 成品預覽

✏️ App主要功能

  • 搜尋各城市的當地天氣與時間
  • 刪除或移動地點列表
  • 顯示未來五天/每三小時的天氣預報
  • 請求定位權限

✏️ 使用到的技術

  • UserDefaults
  • OpenWeather API(Current weather data、5 day / 3 hour forecast data)

https://openweathermap.org/api ,上述兩種是免費使用

  • CoreLocation、locationManager
  • freezing first table view cell

✏️ 程式說明

用StoryBoard設計畫面

  • 第一頁為顯示天氣的詳細資訊,上半部為當日天氣,下半部為近五天每三小時的天氣預報,當第一次開啟時會請求定位權限,並利用page control 進到下一頁
  • 第二頁為新增查詢的地點,點選地點後,將地點的天氣資訊回傳到前一頁
  • 建立pageViewController,以利後續page control的使用

1. 首先處理新增地點的table view

搜尋程式位置的列表,以tableView呈現

extension LocationListViewController: UITableViewDataSource, UITableViewDelegate {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
weatherLocations.count
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)

cell.textLabel?.text = weatherLocations[indexPath.row].name
cell.detailTextLabel?.text = "經度:\(weatherLocations[indexPath.row].latitude), 緯度:\(weatherLocations[indexPath.row].longitude)"

return cell
}

func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
if editingStyle == .delete {
weatherLocations.remove(at: indexPath.row)
tableView.deleteRows(at: [indexPath], with: .fade) //swipe left

}

}

func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
let itemToMove = weatherLocations[sourceIndexPath.row] //move from source
weatherLocations.remove(at: sourceIndexPath.row)
weatherLocations.insert(itemToMove, at: destinationIndexPath.row) //move to destination

}

將第一行cell固定住,不讓下方地點取代或刪除第一行

// 預防第一個被刪掉
func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
// return true 則可以編輯這一行
return indexPath.row != 0 ? true : false
}
// 預防移動到第一個
func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool {
//與canEditRowAt回傳相同的結果,表示不能移動indexPath.row
return indexPath.row != 0 ? true : false
}

//將某些內容移到建議的IndexPath.row,若等於0 則返回到原本的地方,若不等於0則移動到目標的IndexPath
func tableView(_ tableView: UITableView, targetIndexPathForMoveFromRowAt sourceIndexPath: IndexPath, toProposedIndexPath proposedDestinationIndexPath: IndexPath) -> IndexPath {
return proposedDestinationIndexPath.row == 0 ? sourceIndexPath : proposedDestinationIndexPath
}

透過UserDefault進行資料儲存,為 key-value 格式的類別,這個類別會將資料儲存成檔案並放在 Library/Preferences 目錄下,副檔名為 plist,使用於簡易儲存

 //UserDefault是一個用來將資料儲存為 key-value 格式的類別,這個類別會將資料儲存成檔案並放在 Library/Preferences 目錄下,副檔名為 plist,使用於簡易儲存
func saveLocation(){
let encoder = JSONEncoder()
if let encoded = try? encoder.encode(weatherLocations) {
UserDefaults.standard.set(encoded, forKey: "weatherLocations") //forKey就是這筆資料的名稱,資料型態就限定為String
} else {
print("😡 ERROR: saving encode didnt work!")
}
}

// 將UserDefaults儲存的資料,傳至tableview上進行讀取
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
seletedLocationIndex = tableView.indexPathForSelectedRow!.row
saveLocation()
}

2. 回到pageViewController

先載入地點,將UserDefaults的資料轉換成data存取出來,接著將本身array中的資料序列進行解碼並轉成自定義的地點序列,判斷此App的地點是否為空的,若為空則提供一個最近的地點,模擬器以蘋果總部為地點

func loadLocations() {
//forKey就是這筆資料的名稱,資料型態就限定為String ,轉型為Data
guard let locationEncoded = UserDefaults.standard.value(forKey: "weatherLocations") as? Data else {
print("⚠️ waring")

//TODO: 取得用戶的第一筆位置
//weatherLocations.append(WeatherLocation(name: "最近地點", latitude: 00.00, longitude: 00.00))
return
}

let decoder = JSONDecoder()
if let weatherLocations = try? decoder.decode(Array.self, from: locationEncoded) as [WeatherLocation] {
self.weatherLocations = weatherLocations
} else {
print("😡 Error: 無法從UserDefaults取得解碼資料")
}
if weatherLocations.isEmpty {
//TODO: 加入最近的地點,模擬器內建是蘋果總部位置
weatherLocations.append(WeatherLocation(name: "最近地點", latitude: 00.00, longitude: 00.00))
}
}

// 傳遞 page的index到LocationDetailViewController,以顯示該位置的名稱
func createLocationDetailViewController (forPage page: Int) -> LocationDetailViewController {

// 須建立storyboard ID 以用來初始化
let detailViewController = storyboard?.instantiateViewController(withIdentifier: "LocationDetailViewController") as! LocationDetailViewController
detailViewController.locationIndex = page
return detailViewController

}

建立createLocationDetailViewController的function,將 page的index回到LocationDetailViewController,以顯示該位置的名稱

func createLocationDetailViewController (forPage page: Int) -> LocationDetailViewController {

// 須建立storyboard ID 以用來初始化
let detailViewController = storyboard?.instantiateViewController(withIdentifier: "LocationDetailViewController") as! LocationDetailViewController
detailViewController.locationIndex = page
return detailViewController

}

在viewDidLoad寫入下列程式碼,設定 pageViewControoler 的首頁,先傳入頁碼第一頁,及後續處理左滑及右滑的功能

 setViewControllers([createLocationDetailViewController(forPage: 0)], direction: .forward, animated: false, completion: nil)

將PageViewController遵從 UIPageViewControllerDelegate, UIPageViewControllerDataSource,實現左滑上一頁,右滑下一頁的功能

extension PageViewController: UIPageViewControllerDelegate, UIPageViewControllerDataSource {
//向左滑(上一頁)
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {

if let currentViewController = viewController as? LocationDetailViewController {
if currentViewController.locationIndex > 0 {
return createLocationDetailViewController(forPage: currentViewController.locationIndex - 1 )
}
}
return nil
}

//向右滑(下一頁)
func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {

if let currentViewController = viewController as? LocationDetailViewController {
if currentViewController.locationIndex < weatherLocations.count - 1 { //是否為最後一頁
return createLocationDetailViewController(forPage: currentViewController.locationIndex + 1 )
}
}
return nil
}

}

PageViewController參考文章

3. 顯示天氣的詳細資訊的頁面

在updateUI中存取PageViewController傳過來的資訊及取得Current weather data、5 day / 3 hour forecast data的解碼結果

首先,從UIApplication取得rootViewController並轉型為PageViewController,透過PageViewController存取地理位置的陣列,並傳入從pageViewController傳過來的index

將經緯度存入變數中,將變數實體化,建立pageControl的數量 及 當前頁面為locationIndex,取得Current weather的解碼資料顯示於畫面,5 day / 3 hour forecast的寫法雷同就不示範囉,唯一不同的是畫面用tableView呈現,需增加reloadData,以重新載入畫面

let pageViewController = UIApplication.shared.windows.first?.rootViewController as! PageViewController

//在目前位置取得locationIndex
let weatherLocation = pageViewController.weatherLocations[locationIndex]

// 經緯度儲存到weatherDetail
weatherDetail = WeatherDetail(name: weatherLocation.name, latitude: weatherLocation.latitude, longitude: weatherLocation.longitude)

// 建立pageControl的數量 及 當前頁面為locationIndex
pageControl.numberOfPages = pageViewController.weatherLocations.count
pageControl.currentPage = locationIndex

weatherDetail.getData {
DispatchQueue.main.async {
dateFormatter.timeZone = TimeZone(secondsFromGMT: self.weatherDetail.timezone)
let usebleDate = Date(timeIntervalSince1970: self.weatherDetail.currentTime)

self.dateLable.text = dateFormatter.string(from: usebleDate)
self.placeLable.text = self.weatherDetail.name
self.temperatureLable.text = "\(self.tempFormate(ºC: self.weatherDetail.tempature) )º"
self.summaryLable.text = self.weatherDetail.summary
self.imageView.image = UIImage(named: self.weatherDetail.dailyIcon)

}
}

透過unwind Segue 將新增地點列表中的位置傳回主要頁面

 @IBAction func unwindFromLocaionListViewController(segue: UIStoryboardSegue) {
let source = segue.source as! LocationListViewController
locationIndex = source.seletedLocationIndex //將用戶選到的位置存回locationIndex

let pageViewController = UIApplication.shared.windows.first?.rootViewController as! PageViewController

pageViewController.weatherLocations = source.weatherLocations

// 將用戶選到的locationIndex顯示在畫面中
pageViewController.setViewControllers([pageViewController.createLocationDetailViewController(forPage: locationIndex)], direction: .forward, animated: false, completion: nil)

updateUI()
}

4. 請求定位權限

import CoreLocation,並將要顯示授權定位的ViewController遵從CLLocationManagerDelegate

extension LocationDetailViewController: CLLocationManagerDelegate {

func getLocation() {
// 初始化locationManager,委託給CLLocationManager,確認用戶使否授權位置服務
locationManager = CLLocationManager()
locationManager.delegate = self
}

// 告訴delegate裝置的授權狀態,當建立位置管理器 及 授權狀態改變時
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
print("👮‍♀️確認授權狀態")
handleAuthenticalStatus(status: status)
}

// 第一次啟動app,詢問是否允許使用位置服務
func handleAuthenticalStatus(status: CLAuthorizationStatus) {
switch status {
case .notDetermined:
locationManager.requestWhenInUseAuthorization() //使用app期間允許使用位置服務
case .restricted:
oneBtnAlert(title: "不允許使用定位服務", message: "若要開啟定位服務請前往設定")
case .denied: // 是否改變授權
// showAlertToPrivacySettings(title: "使用者無授權定位服務", message: "請至設定>隱私權與安全性>定位服務")
break
case .authorizedAlways, .authorizedWhenInUse:
locationManager.requestLocation() //允許一次最近位置
@unknown default:
print("😡未知狀態\(status)")
}
}

// 若無授權定位服務則跳出提醒,跳轉至設定修改授權
func showAlertToPrivacySettings(title: String, message: String) {
let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert)
guard let settingUrl = URL(string: UIApplication.openSettingsURLString) else {
print("😡設定位置錯誤")
return
}
let settingAction = UIAlertAction(title: "設定", style: .default) { (_) in
UIApplication.shared.open(settingUrl, options: [:], completionHandler: nil)
}
let cancelAction = UIAlertAction(title: "取消", style: .cancel, handler: nil)
alertController.addAction(settingAction)
alertController.addAction(cancelAction)
present(alertController, animated: true, completion: nil)
}

//TODO: 當新的位置出現時,告訴代理人可以開始更新地點了
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
print("update location")

//儲存當目前位置等於array中的最後一筆,因last為optional 若不幸回傳nil,則傳入一個零座標的位置
let currentLocation = locations.last ?? CLLocation()
print("當前位置是\(currentLocation.coordinate.latitude)、\(currentLocation.coordinate.longitude)")
}

//TODO: 無法搜尋位置時的錯誤處理
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
print("❌\(error)")
}
}

參考文章

🔆 Gif

--

--