HW#47 串接You bike API + 資料存到Core Data

這次的練習目的,主要是想要承接上次練習JSON作業中,能夠做出顯示Youbike 站名 / 空車位 / 可使用車輛 / 更新時間等資料,找到附近Youbike站,並且能夠導航到,並能夠將這些資料儲存在Core data裡面,希望藉此能夠做出比較實用一點的App。

由於之前我有使用過Terry CK所製作的追垃圾車的App,因為這個App真的蠻實用的,我自己也會想要做點實用的產品,所以我因爲這個App了解到不少關於MapKit的相關知識(好奇心真的會害死人),真的很感謝Terry的啟發!

  • #19 串接第三方 API,解析 JSON 資料,轉換成自訂型別顯示

功能如下:

目錄
1. 串接Api內容,並將完整內容顯示在mapView上。
2. 客制UIView,顯示目前車輛數 / 車輛空缺數 / 更新時間 / 導航按鈕 / 加入最愛清單,並且在用#Preview marco的功能,能夠及時更新UI的畫面。
3. 透過點擊navigationButton,並用CLLocationManager找到user的定位
4. 點擊routeButton之後,自動跳出導航頁面。
5. 點擊favoriteButton,將資料儲存到FavoriteListTableView,並將資料儲存到Core data,並將資料儲存到Favorite tableView VC。
6. 如何制定Protocol & Delegate?

串接Api內容,並將完整內容顯示在mapView上。

將API網址丟到Postman裡面,並且認識一下資料內的型別,準備定義資料型別。

  • 定義model 資料型別:
import UIKit
import MapKit
import CoreLocation

struct Youbike: Codable {
var sna: String // YouBike中文站名
var snaen: String // YouBike英文站名
var tot: Int // 場站總車格
var sbi: Int // 場站目前車輛數,現在作為可選字段
var bemp: Int // 目前空位數量,現在作為可選字段
var lat: Double // 緯度
var lng: Double // 經度
var sarea: String // 市區名
var ar: String // 路名
var sareaen: String // 英文市區名
var aren: String // 英文路名
var srcUpdateTime: String
var updateTime: String

// MKAnnotation requires a 'coordinate' property
var coordinate: CLLocationCoordinate2D { CLLocationCoordinate2D(latitude: lat, longitude: lng) }

enum CodingKeys: String, CodingKey {
case sna, snaen, sarea, ar, sareaen, aren, srcUpdateTime, updateTime
case tot = "total"
case sbi = "available_rent_bikes"
case bemp = "available_return_bikes"
case lat = "latitude"
case lng = "longitude"
}

// 使用 `decodeIfPresent` 方法來解碼可選字段
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
sna = try container.decode(String.self, forKey: .sna)
snaen = try container.decode(String.self, forKey: .snaen)
tot = try container.decode(Int.self, forKey: .tot)
sbi = try container.decodeIfPresent(Int.self, forKey: .sbi) ?? 0
bemp = try container.decodeIfPresent(Int.self, forKey: .bemp) ?? 0
lat = try container.decode(Double.self, forKey: .lat)
lng = try container.decode(Double.self, forKey: .lng)
sarea = try container.decode(String.self, forKey: .sarea)
ar = try container.decode(String.self, forKey: .ar)
sareaen = try container.decode(String.self, forKey: .sareaen)
aren = try container.decode(String.self, forKey: .aren)
srcUpdateTime = try container.decode(String.self, forKey: .srcUpdateTime)
updateTime = try container.decode(String.self, forKey: .updateTime)
}
}

基本上我們可以從政府官方資料上找到我們會使用到的資訊。

以下為解析官方所提供的資料:

srcUpdateTime:微笑單車系統發布資料更新的時間

mday:微笑單車各場站來源資料更新時間

updateTime:北市府交通局數據平台經過處理後將資料存入DB的時間

infoTime:微笑單車各場站來源資料更新時間

infoDate:微笑單車各場站來源資料更新時間

tot: 場站總停車格

sbi: 場站目前車輛數量

bemp: 空位數量

act: 全站禁用狀態(0:禁用、1:啟用)

再來我們先運用JSON Decoder解析API網址以後,把資料存到YouBike data 這個常數裡面,並且定義一個variable為youbikeAnnotation的array,然後我再用for-loop將youbikeData 每ㄧ筆資料儲存到station裡面。

並且將annotation 定義為YoubikeAnnotation的類別做存取,再將annotations.append的方式,再將資料加到annotations這個常數裡面。

然後再用DispatchQueue.main.async的寫法,將UI部署在main thread上,並且將每個站名資料直接更新在mapView上。
  • Code snippet:
    // MARK: - fetchYoubikeData
func fetchYoubikeData() {
if let urlString = "https://tcgbusfs.blob.core.windows.net/dotapp/youbike/v2/youbike_immediate.json".addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let url = URL(string: urlString) {
URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
guard let self = self else { return }

if let data = data {
do {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let youbikeData = try decoder.decode([Youbike].self, from: data)

// Prepare to update the mapView with new annotations
var annotations = [YoubikeAnnotation]() // Use your custom annotation class
for station in youbikeData {
// Create an instance of YoubikeAnnotation for each station
let annotation = YoubikeAnnotation(stationData: station)
annotations.append(annotation)
}
// Update UI by using DispatchQueue.main.async
DispatchQueue.main.async {
// Remove existing annotations to avoid duplicates
self.mapView.removeAnnotations(self.mapView.annotations)
// Add the new set of annotations
self.mapView.addAnnotations(annotations)
}
} catch {
print(error)
}
}
}.resume()
}
}

Reference :

客制UIView,顯示目前車輛數 / 車輛空缺數 / 更新時間 / 導航按鈕 / 加入最愛清單,並且在用#Preview marco的功能,能夠及時更新UI的畫面:

  • Information View:

功能一:顯示站名資料。

功能二:點擊favoriteBtn ,將所選站名儲存到Core data裡面。

功能三:點選站名,存取站名資料,並且點擊routeBtn導航到想要的站名。

  • Code snippet:
//
// InformationView.swift
// HW#44-JSON Decoder
//
// Created by Dawei Hao on 2024/3/17.
//

import UIKit

// Protocol definition
protocol InformationViewDelegate: AnyObject {
func favoriteButtonDidTap()
func routeBtnDidTap ()
}


class InformationView: UIView {

weak var delegate: InformationViewDelegate?

static let informationView: UIView = UIView()

let bikeImageView: UIImageView = UIImageView()
let docksImageView: UIImageView = UIImageView()
let bikeLabel: UILabel = UILabel()
let docksLabel: UILabel = UILabel()

var bikeQtyLabel: UILabel = UILabel()
var dockQtyLabel: UILabel = UILabel()

var stationNameLabel: UILabel = UILabel()
var addressLabel: UILabel = UILabel()
var bikeVacanciesLabel: UILabel = UILabel()
var distanceLabel: UILabel = UILabel()
var updateTimeLabel: UILabel = UILabel()

let favoriteButton: UIButton = UIButton(type: .system)
let routeButton: UIButton = UIButton(type: .system)

let bikeStackView: UIStackView = UIStackView()
let dockStackView: UIStackView = UIStackView()

let bikeStatusStackView: UIStackView = UIStackView()

let buttonStackView: UIStackView = UIStackView()
let labelsStackView: UIStackView = UIStackView()

let contentStackView: UIStackView = UIStackView()

// MARK: - Life Cycle
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
addTargets ()
}

required init?(coder: NSCoder) {
super.init(coder: coder)
fatalError("Unable to load the InformationView.")
}

// MARK: - Functions
func setupUI () {
self.backgroundColor = Colors.infoViewBackgroundColor

configureBikeQtyLabel()
configureDockQtyLabel()

configureBikeLabel()
configureDocksLabel ()
configureBikeImageView()
configureParkingSignImageView ()

configureStationNameLabel ()
configureAddressLabel ()
configureDistanceLabel ()
configureUpdateTimeLabel ()
configureRouteButton()
configureFavoriteButton ()

configureBikeStackView()
configureDockStackView ()

configureButtonStackView()
configureLabelsStackView ()

configureBikeStatusStackView ()
configureContentStackView ()

constraintsButtonStackView()
constraintsBikeStatusStackView ()
}

func addTargets () {
favoriteButton.addTarget(self, action: #selector(favoriteBtnTapped), for: .touchUpInside)
routeButton.addTarget(self, action: #selector(routeBtnTapped), for: .touchUpInside)
}

func configureBikeImageView () {
bikeImageView.image = Images.bike
bikeImageView.tintColor = Colors.lightGray
bikeImageView.contentMode = .scaleAspectFit
self.addSubview(bikeImageView)
}

func configureParkingSignImageView () {
docksImageView.image = Images.parkingSign
docksImageView.tintColor = Colors.lightGray
docksImageView.contentMode = .scaleAspectFit
self.addSubview(docksImageView)
}

func configureBikeQtyLabel () {
bikeQtyLabel.text = "0"
bikeQtyLabel.textColor = Colors.lightGray
bikeQtyLabel.font = UIFont.boldSystemFont(ofSize: 15)
bikeQtyLabel.numberOfLines = 0
bikeQtyLabel.textAlignment = .left
bikeQtyLabel.adjustsFontSizeToFitWidth = true
self.addSubview(bikeQtyLabel)
}

func configureDockQtyLabel () {
dockQtyLabel.text = "0"
dockQtyLabel.textColor = Colors.lightGray
dockQtyLabel.font = UIFont.boldSystemFont(ofSize: 15)
dockQtyLabel.numberOfLines = 0
dockQtyLabel.textAlignment = .left
dockQtyLabel.adjustsFontSizeToFitWidth = true
self.addSubview(dockQtyLabel)
}

func configureBikeLabel () {
bikeLabel.text = "Bikes"
bikeLabel.textColor = Colors.lightGray
bikeLabel.font = UIFont.systemFont(ofSize: 8)
bikeLabel.numberOfLines = 0
bikeLabel.textAlignment = .left
bikeLabel.adjustsFontSizeToFitWidth = true
self.addSubview(bikeLabel)
}

func configureDocksLabel () {
docksLabel.text = "Docks"
docksLabel.textColor = Colors.lightGray
docksLabel.font = UIFont.systemFont(ofSize: 8)
docksLabel.numberOfLines = 0
docksLabel.textAlignment = .left
docksLabel.adjustsFontSizeToFitWidth = true
self.addSubview(docksLabel)
}

func configureStationNameLabel () {
stationNameLabel.text = "Loading..."
stationNameLabel.textColor = Colors.white
stationNameLabel.font = UIFont.boldSystemFont(ofSize: 18)
stationNameLabel.numberOfLines = 0
stationNameLabel.textAlignment = .left
stationNameLabel.adjustsFontSizeToFitWidth = true
self.addSubview(stationNameLabel)
}

func configureAddressLabel () {
addressLabel.text = "Loading..."
addressLabel.textColor = Colors.lightGray
addressLabel.font = UIFont.systemFont(ofSize: 10)
addressLabel.numberOfLines = 0
addressLabel.textAlignment = .left
addressLabel.adjustsFontSizeToFitWidth = true
self.addSubview(addressLabel)
}

func configureDistanceLabel () {
distanceLabel.text = "Loading..."
distanceLabel.textColor = Colors.white
distanceLabel.font = UIFont.boldSystemFont(ofSize: 15)
distanceLabel.numberOfLines = 0
distanceLabel.textAlignment = .left
distanceLabel.adjustsFontSizeToFitWidth = true
self.addSubview(distanceLabel)
}

func configureUpdateTimeLabel () {
updateTimeLabel.text = "Loading..."
updateTimeLabel.textColor = Colors.lightGray
updateTimeLabel.font = UIFont.systemFont(ofSize: 10)
updateTimeLabel.numberOfLines = 0
updateTimeLabel.textAlignment = .left
updateTimeLabel.adjustsFontSizeToFitWidth = true
self.addSubview(updateTimeLabel)
}

func configureRouteButton () {
var title = AttributedString("Route")
title.font = UIFont.boldSystemFont(ofSize: 12)

var config = UIButton.Configuration.filled()
config.cornerStyle = .large
config.attributedTitle = title
config.image = Images.arrowTurnUpRight
config.imagePlacement = .leading
config.imagePadding = 5
config.buttonSize = UIButton.Configuration.Size.mini
config.baseForegroundColor = Colors.darkGray
config.background.backgroundColor = Colors.white

routeButton.configurationUpdateHandler = { routeButton in
routeButton.alpha = routeButton.isHighlighted ? 0.5 : 1
print("DEBUG PRINT: routeBtn isHighlighted")
}

routeButton.configuration = config
routeButton.changesSelectionAsPrimaryAction = true
}

func configureFavoriteButton () {
var title = AttributedString("Favorite")
title.font = UIFont.boldSystemFont(ofSize: 12)

var config = UIButton.Configuration.filled()
config.cornerStyle = .large
config.attributedTitle = title
config.image = Images.starFill
config.imagePlacement = .leading
config.imagePadding = 2
config.buttonSize = UIButton.Configuration.Size.mini
config.baseForegroundColor = Colors.white
config.background.backgroundColor = Colors.systemYellow

favoriteButton.configurationUpdateHandler = { favoriteButton in
favoriteButton.alpha = favoriteButton.isHighlighted ? 0.5 : 1
print("DEBUG PRINT: favoriteBtn isHighlighted")
}

favoriteButton.configuration = config
favoriteButton.changesSelectionAsPrimaryAction = true
}

func configureBikeStackView () {
bikeStackView.axis = .vertical
bikeStackView.alignment = .center
bikeStackView.spacing = 0
bikeStackView.distribution = .fill
bikeStackView.addArrangedSubview(bikeImageView)
bikeStackView.addArrangedSubview(bikeLabel)
}

func configureDockStackView () {
dockStackView.axis = .vertical
dockStackView.alignment = .center
dockStackView.spacing = 0
dockStackView.distribution = .fill
dockStackView.addArrangedSubview(docksImageView)
dockStackView.addArrangedSubview(docksLabel)
}

func configureBikeStatusStackView () {
bikeStatusStackView.axis = .horizontal
bikeStatusStackView.alignment = .lastBaseline
bikeStatusStackView.spacing = 5
bikeStatusStackView.distribution = .fill
bikeStatusStackView.addArrangedSubview(bikeStackView)
bikeStatusStackView.addArrangedSubview(bikeQtyLabel)
bikeStatusStackView.addArrangedSubview(dockStackView)
bikeStatusStackView.addArrangedSubview(dockQtyLabel)
}

func configureButtonStackView () {
buttonStackView.axis = .horizontal
buttonStackView.alignment = .center
buttonStackView.spacing = 10
buttonStackView.distribution = .fill
buttonStackView.addArrangedSubview(routeButton)
buttonStackView.addArrangedSubview(favoriteButton)
}

func configureLabelsStackView () {
labelsStackView.axis = .vertical
labelsStackView.distribution = .fill
labelsStackView.spacing = 5
labelsStackView.addArrangedSubview(stationNameLabel)
labelsStackView.addArrangedSubview(addressLabel)
labelsStackView.addArrangedSubview(distanceLabel)
labelsStackView.addArrangedSubview(updateTimeLabel)
}

func configureContentStackView () {
contentStackView.axis = .vertical
contentStackView.distribution = .fill
contentStackView.alignment = .leading
contentStackView.spacing = 10
contentStackView.addArrangedSubview(bikeStatusStackView)
contentStackView.addArrangedSubview(labelsStackView)
}

// MARK: - Actions:
@objc func routeBtnTapped (_ sender: UIButton) {
print("DEBUG PRINT: routeBtnTapped")
delegate?.routeBtnDidTap()
}

@objc func favoriteBtnTapped (_ sender: UIButton) {
print("DEBUG PRINT: favoriteBtnTapped")
delegate?.favoriteButtonDidTap()
}

// MARK: - Layout Constraints:
func constraintsButtonStackView () {
self.addSubview(buttonStackView)
buttonStackView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
buttonStackView.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -20),
buttonStackView.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -20),
routeButton.widthAnchor.constraint(equalToConstant: 100),
routeButton.heightAnchor.constraint(equalToConstant: 40),
favoriteButton.widthAnchor.constraint(equalToConstant: 100),
favoriteButton.heightAnchor.constraint(equalToConstant: 40),
])
}

func constraintsBikeStatusStackView () {
stationNameLabel.heightAnchor.constraint(equalToConstant: 20).isActive = true
addressLabel.heightAnchor.constraint(equalToConstant: 15).isActive = true
distanceLabel.heightAnchor.constraint(equalToConstant: 15).isActive = true
updateTimeLabel.heightAnchor.constraint(equalToConstant: 15).isActive = true

bikeImageView.widthAnchor.constraint(equalToConstant: 30).isActive = true
bikeImageView.heightAnchor.constraint(equalToConstant: 20).isActive = true
docksImageView.widthAnchor.constraint(equalToConstant: 30).isActive = true
docksImageView.heightAnchor.constraint(equalToConstant: 20).isActive = true

bikeQtyLabel.heightAnchor.constraint(equalToConstant: 20).isActive = true
dockQtyLabel.heightAnchor.constraint(equalToConstant: 20).isActive = true

bikeLabel.widthAnchor.constraint(equalToConstant: 25).isActive = true
docksLabel.widthAnchor.constraint(equalToConstant: 25).isActive = true

self.addSubview(contentStackView)
contentStackView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
contentStackView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 30),
contentStackView.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -20),
])
}

}

// MARK: - Preview:
#Preview(traits: .fixedLayout(width: 420, height: 160), body: {
let informationView = InformationView()
return informationView
})
  • Custom MKAnnotation:

Youbike Annotation:

為了要在地圖上的某個座標得到一個車站的資訊,必須從API裡面找到所需的資料,所以必須設定這個類別(Class)為一個MKAnnotation 的物件,同時也必須定義為NSObject

NSObject是從Objective-C來的,基本上所有的物件都是從它建立而來的,可以說是物件的始祖。

官方解釋NSObject

我從在mapVC裡面設定一個名叫stationData 的array,好讓API的資料可以存在裡面,並且將stationData的陣列裡面取出資料出來。

import UIKit
import MapKit

class YoubikeAnnotation: NSObject, MKAnnotation {
var title: String?
var subtitle: String?
var coordinate: CLLocationCoordinate2D
var image: UIImage?
var stationData: Youbike
var address: String?

init(stationData: Youbike) {
self.title = stationData.sna.replacingOccurrences(of: "YouBike2.0_", with: "")
self.subtitle = "剩餘車輛: \(stationData.sbi) / 剩餘車位: \(stationData.bemp)"
self.coordinate = CLLocationCoordinate2D(latitude: stationData.lat, longitude: stationData.lng)
self.stationData = stationData
self.address = stationData.ar
super.init()
}
}

點擊navigationButton並透過CLLocationManager找到user的定位

Demo:

功能目的:

主要目的是透過CLLocationManager,是為了找到user的位置,能透過自身的位置,找到附近的腳踏車站。

  • 建立一個locationManager。
    // Create the location manager.
let locationManager = CLLocationManager()
  • 建立location Manager的function

首先先import locationManager的delegate(代理機制),再來設定下列相關Method。

Screen shot from Apple Doc.
  • kCLLocationAccuracyBest為最佳的精準度方法,是否要使用這個設定可以根據自身需求去調整。
Screen shot from Apple Doc.
  • requestAlwaysAuthorization 這個功能主要是跟使用者請求,當使用者在使用app的時候,可以得到相關地點的資料的Method。
Screen shot from Apple Doc.
Screen shot from Apple Doc
Screen shot from Apple Doc
  • Code snippet:
    // MARK: 設定Location Manager
func setupLocationManager () {
// import delegate
locationManager.delegate = self
// Define the accuracy
locationManager.desiredAccuracy = kCLLocationAccuracyBest
locationManager.requestAlwaysAuthorization()
locationManager.requestWhenInUseAuthorization()
locationManager.requestLocation()
mapView.showsUserLocation = true
}
  • Info.plist設定Privacy — Location 相關資料

Reference:

點擊routeButton之後,自動跳出導航頁面:

Demo:

簡單來講一下,navigation功能大致上的做法,基本上我是用MKMapviewDelegate裡面的didSelect的protocol來達成這個功能。

基本上,我先宣告一個變數為CLLocationCoordinate2D的型別,叫didSelectedLocation。

然後我選到Youbike站的座標之後,運用didSelect的delegate存到座標位置,然後因為MapItem 需要透過 MKPlacemark 初始化,所以我們現在需要將我們存取的座標轉換成一個 MKPlacemark ,之後再轉換成 MKMapItem

你會覺得以上我是在供三小呢? 由於Mapkit比較複雜,以下是不同型別的解釋!

Ans: MKPlacemark是由coordinate定義出來的一個Class(類別),這個Class(類別)是由CLLocationCoordinate2D所組成的。

MKPlacemark在Apple上的解釋

Ans: MKMapItem是一個NSObject的Class(類別),是可以透過MKMapItem轉換到MKPlacemark得到coordinate的相關資訊的做法,同時也可以用 openMaps(with:launchOptions:)方法,打開Maps app並且選取特定的地點做導航。

MKMapItem在Apple上的解釋
  • Code snippet:
    // MARK: - Import the InfoView Delegate:
func routeBtnDidTap() {
print("DEBUG PRINT: InfoView's RouteBtn")

// 取得目的地的座標
let targetCoordinate = didSelectedLocation

// 取得目的地的座標後,將座標改成placeamark的型別.
let targetPlacemark = MKPlacemark(coordinate: targetCoordinate!)

// 將 placeamark的型別改成 MKMapItem,並命名為targetItem
let targetItem = MKMapItem(placemark: targetPlacemark)
targetItem.name = didSelectedStationName

// 取得使用者的座標
let userMapItem = MKMapItem.forCurrentLocation()

// Build the routes from userMapItem to targetItem
let routes = [userMapItem, targetItem]

// Default setting for user by using walking to the destination.
MKMapItem.openMaps(with: routes, launchOptions: [MKLaunchOptionsDirectionsModeKey : MKLaunchOptionsDirectionsModeWalking])
}

Reference :

  • MKMapViewDelegate:
  • MKMapItem:

點擊favoriteButton,將資料儲存到FavoriteListTableView,並將資料儲存到Core data,並將資料儲存到Favorite tableView VC。

點擊favoriteButton:

MapViewController裡面建立InformationView裡面專屬的delegate叫InformationViewDelegate,讓user在使用這個功能的時候,可以點擊favoriteButton,將選取的annotation的資料從定義好的YoubikeAnnotation傳送到FavoriteListViewController裡面。

    func favoriteButtonDidTap() {
guard let selectedAnnotation = selectedPlaces as? YoubikeAnnotation else {
print("DEBUG PRINT: No selected annotation to save.")
return
}

// 直接保存到 CoreData
savePlaceToCoreData(annotation: selectedAnnotation)
print("DEBUG PRINT: Annotation saved.")

// 打印 selectedAnnotation 的所有详细信息
if let title = selectedAnnotation.title, let subtitle = selectedAnnotation.subtitle {
print("""
DEBUG PRINT: Title: \(title), Subtitle: \(subtitle)
DEBUG PRINT: Coordinate: \(selectedAnnotation.coordinate.latitude), \(selectedAnnotation.coordinate.longitude)
""")
} else {
print("DEBUG PRINT: Annotation lacks title or subtitle")
}

favoriteButtonCount += 1
print("favoriteBtnCount: \(favoriteButtonCount)")
}

資料儲存到Core data:

如何建立core data?

首先,如果我們有需要使用core data,就必須建立一個data model(資料模型),這個data model會根據工程師會依照需求所設定!

可以按下cmd + n,可以看到title上看到Core data裡面有一個data model的檔案,按下去就可以了~

根據資料的需求,建立attribute儲存的資料型別(資料型別可以是Int / Float / Double / string / date)。

Build the attribute in data model.

記得手動將Codegen改成Manual。(通常是未修改前的Codegen是class definition)

建立完.xcdatamodeld完之後,再來就是要用程式去存取資料,首先我建立一個Core data的class(類別),叫Core Data Stack。(命名的方式,是參考Apple官方的命名法)

它的結構圖,可以參考下方的圖解,基本上會有一個NSPersistentContainer去做資料的管理,管理下方三個物件,三個物件分別為Model / Context / Store coordinator。

Core Data stack structure from Apple Doc.

所以我參考了Paul的講解,確保資料有被永久被保存在我的資料庫Model裡面,首先建立一個persistentContainerNSPersistentContainer,並且將NSPersistentContainername 設定為原本設定data model的名稱"FavoriteListDataModel",因為 Core Data 使用這個名稱來查找和加載您的 .xcdatamodeld 文件。

再來就是使用 loadPersistentStores 方法來執行底層的存儲(通常是 SQLite 數據庫)。

error時,我使用處理 if let 語法來抓load資料時,可能產生出來的錯誤。如果發生錯誤,這時會產生fatalError(),以便於了解確保這段程式碼在執行階段有沒有出了什麼問題。

lazy var persistentContainer: NSPersistentContainer = {
let container = NSPersistentContainer(name: "FavoriteListDataModel")
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
return container
}()
  • 儲存 Context

實現儲存上下文功能:在 CoreDataStack 中建立一個 saveContextfunction,該function負責檢查 viewContext 是否有未保存的變更。

如果有,則儲存這些變更。這是使用 context.save() 完成的,如果沒有儲存成功,則透過 fatalError 產生一個未解決錯誤的訊息。

// MARK: - Core Data Saving support
func saveContext () {
let context = persistentContainer.viewContext
if context.hasChanges {
do {
try context.save()
} catch {
let nserror = error as NSError
fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
}
}
}
  • Code snippets:
import UIKit
import CoreData

class CoreDataStack {
lazy var persistentContainer: NSPersistentContainer = {
let container = NSPersistentContainer(name: "FavoriteListDataModel")
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
return container
}()

// MARK: - Core Data Saving support
func saveContext () {
let context = persistentContainer.viewContext
if context.hasChanges {
do {
try context.save()
} catch {
let nserror = error as NSError
fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
}
}
}
}

Reference:

https://www.hackingwithswift.com/read/38/3/adding-core-data-to-our-project-nspersistentcontainer

  • FavoriteListViewController:

建立一個fetchedResultsController ,這是一個Generic的寫法,這是一個Controller用來管理Core data的fetch request以及展示資料給user的一個controller。

func initializeFetchedResultsController() {
let request: NSFetchRequest<FavoriteListData> = FavoriteListData.fetchRequest()
let sortDescriptor = NSSortDescriptor(key: "stationName", ascending: true)
request.sortDescriptors = [sortDescriptor]
fetchedResultsController = NSFetchedResultsController(fetchRequest: request, managedObjectContext: self.managedObjectContext, sectionNameKeyPath: nil, cacheName: nil)
fetchedResultsController.delegate = self
do {
try fetchedResultsController.performFetch()
} catch {
print("Failed to initialize FetchedResultsController: \(error)")
}
}
NSFetchedResultsController in Apple Doc.

針對程式的部分,基本上可以參考Apple文件內的寫法。

另外,我有一個NSManagedObjectContext的variable,可以做為管理及儲存物件的context,然後呼叫我上方所制定的CoreDataStack的Class,讓managedObjectContext可以作為coreDataStack裡面的persistentContainer的物件內容。

managedObjectContext = coreData.persistentContainer.viewContext

由於FavoriteList的資料顯示是要從Core data裡面找到顯示的資料,所以必須從Core data裡的sections的section裡面找到資料,並且運用UITableViewDelegate & UITableViewDataSource 這兩個protocol,將儲存在Core data裡面的資料內容顯示出來,再根據我所建立好的FavoriteListTableViewCell顯示資料內容。

// UITableViewDataSource
func numberOfSections(in tableView: UITableView) -> Int {
return fetchedResultsController.sections?.count ?? 1
}

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return fetchedResultsController.sections?[section].numberOfObjects ?? 0
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: FavoriteListTableViewCell.identifier, for: indexPath) as! FavoriteListTableViewCell
configureCell(cell, at: indexPath)
cell.backgroundColor = Colors.white
return cell
}

func configureCell(_ cell: FavoriteListTableViewCell, at indexPath: IndexPath) {
let favoriteListData = fetchedResultsController.object(at: indexPath)
// 使用安全的可选链或提供默认值来避免崩溃
cell.stationNameLabel?.text = favoriteListData.stationName ?? "Unknown Station"
cell.addressLabel?.text = favoriteListData.address ?? "No Address"
cell.bikeQtyLabel?.text = favoriteListData.bikeQty ?? "N/A"
cell.dockQtyLabel?.text = favoriteListData.dockQty ?? "N/A"
}
  • Code snippets:
class FavoriteListViewController: UIViewController, UITableViewDataSource, UITableViewDelegate, NSFetchedResultsControllerDelegate {

var fetchedResultsController: NSFetchedResultsController<FavoriteListData>!

var coreData: CoreDataStack = CoreDataStack()

var managedObjectContext: NSManagedObjectContext!

let favoriteListTableView: UITableView = UITableView()
let refreshControl: UIRefreshControl = UIRefreshControl()

override func viewDidLoad() {
super.viewDidLoad()
managedObjectContext = coreData.persistentContainer.viewContext

setupUI()
initializeFetchedResultsController()
}

func initializeFetchedResultsController() {
let request: NSFetchRequest<FavoriteListData> = FavoriteListData.fetchRequest()
let sortDescriptor = NSSortDescriptor(key: "stationName", ascending: true)
request.sortDescriptors = [sortDescriptor]
fetchedResultsController = NSFetchedResultsController(fetchRequest: request, managedObjectContext: self.managedObjectContext, sectionNameKeyPath: nil, cacheName: nil)
fetchedResultsController.delegate = self
do {
try fetchedResultsController.performFetch()
} catch {
print("Failed to initialize FetchedResultsController: \(error)")
}
}

func setupUI() {
configureFavoriteListTableView()
configureNavigationBar()
setupRefreshControl()
}

func configureNavigationBar() {
let appearance = UINavigationBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.backgroundColor = Colors.lightGray
appearance.titleTextAttributes = [NSAttributedString.Key.foregroundColor: Colors.darkGray]

navigationController?.navigationBar.standardAppearance = appearance
navigationController?.navigationBar.tintColor = Colors.systemYellow

navigationItem.title = "Favorite Station List"
view.backgroundColor = Colors.white
}

func setupRefreshControl() {
refreshControl.attributedTitle = NSAttributedString(string: "Pull to refresh")
refreshControl.addTarget(self, action: #selector(refresh(_:)), for: .valueChanged)
favoriteListTableView.addSubview(refreshControl)
}

@objc func refresh(_ sender: AnyObject) {
// Code to refresh table view
refreshControl.endRefreshing()
favoriteListTableView.reloadData()
}

func configureFavoriteListTableView() {
favoriteListTableView.backgroundColor = Colors.white
favoriteListTableView.dataSource = self
favoriteListTableView.delegate = self
favoriteListTableView.rowHeight = 100
favoriteListTableView.register(FavoriteListTableViewCell.nib(), forCellReuseIdentifier: FavoriteListTableViewCell.identifier)
favoriteListTableView.frame = view.bounds
view.addSubview(favoriteListTableView)

favoriteListTableView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
favoriteListTableView.topAnchor.constraint(equalTo: view.topAnchor),
favoriteListTableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
favoriteListTableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
favoriteListTableView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}

// UITableViewDataSource
func numberOfSections(in tableView: UITableView) -> Int {
return fetchedResultsController.sections?.count ?? 1
}

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return fetchedResultsController.sections?[section].numberOfObjects ?? 0
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: FavoriteListTableViewCell.identifier, for: indexPath) as! FavoriteListTableViewCell
configureCell(cell, at: indexPath)
cell.backgroundColor = Colors.white
return cell
}

func configureCell(_ cell: FavoriteListTableViewCell, at indexPath: IndexPath) {
let favoriteListData = fetchedResultsController.object(at: indexPath)
// 使用安全的可选链或提供默认值来避免崩溃
cell.stationNameLabel?.text = favoriteListData.stationName ?? "Unknown Station"
cell.addressLabel?.text = favoriteListData.address ?? "No Address"
cell.bikeQtyLabel?.text = favoriteListData.bikeQty ?? "N/A"
cell.dockQtyLabel?.text = favoriteListData.dockQty ?? "N/A"
}

// UITableViewDelegate
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
}

// Swipe to delete
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
let deleteAction = UIContextualAction(style: .destructive, title: "Delete") { action, view, completionHandler in
self.deleteFavorite(at: indexPath)
completionHandler(true)
}
deleteAction.backgroundColor = Colors.red
return UISwipeActionsConfiguration(actions: [deleteAction])
}

func deleteFavorite(at indexPath: IndexPath) {
let favoriteToDelete = fetchedResultsController.object(at: indexPath)
managedObjectContext.delete(favoriteToDelete)
do {
try managedObjectContext.save()
} catch {
print("Error saving context after deletion: \(error)")
}
}

// NSFetchedResultsControllerDelegate
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
DispatchQueue.main.async {
self.favoriteListTableView.reloadData()
}
}
}

#Preview {
UINavigationController(rootViewController: FavoriteListViewController())
}

Reference:

  • 第一次使用Core Data就上手:

https://www.tpisoftware.com/tpu/articleDetails/2448?source=post_page-----a78949791a4e--------------------------------

  • Core Data教學:
  • 什麼是NSFetchedResultsController:
  • Invalid redeclaration on CoreData classes
  • [APP開發-使用Swift] 19–1. 修改為Core Data 架構
  • Core data or Userdefault?
  • Core Data 學習: 數據模型

https://read01.com/gx0yka.html

  • NSFetchedResultsController

建立FavoriteListTableView的tableViewCell:

  • Custom FavoriteTableViewCell:

這次的Favorite List TableViewCell,基本上我是運用Xib這個Class所製作的,這可以讓我在Interface builder裡面可以快速的建立UI。

Interface builder in Xcode.
  • Code snippet:

FavoriteListTableViewCell:

import UIKit

class FavoriteListTableViewCell: UITableViewCell {

static let identifier = "FavoriteListTableViewCell"

@IBOutlet weak var stationNameLabel: UILabel!
@IBOutlet weak var bikeImageView: UIImageView!
@IBOutlet weak var dockImageView: UIImageView!
@IBOutlet weak var bikeQtyLabel: UILabel!
@IBOutlet weak var dockQtyLabel: UILabel!
@IBOutlet weak var addressLabel: UILabel!

let bikeStackView: UIStackView = UIStackView()
let contentStackView: UIStackView = UIStackView()

override func awakeFromNib() {
super.awakeFromNib()
// Initialization code

configureLabel()
configureImageView()
configureStackView()
self.backgroundColor = Colors.white
}

override func setSelected(_ selected: Bool, animated: Bool) {
super.setSelected(selected, animated: animated)
}

override func prepareForReuse() {
super.prepareForReuse()
print("DEBUG PRINT: prepareForReuse")
}

func configureStackView () {
bikeImageView.widthAnchor.constraint(equalToConstant: 30).isActive = true
bikeImageView.heightAnchor.constraint(equalToConstant: 30).isActive = true
dockImageView.widthAnchor.constraint(equalToConstant: 30).isActive = true
dockImageView.heightAnchor.constraint(equalToConstant: 30).isActive = true

bikeQtyLabel.widthAnchor.constraint(equalToConstant: 20).isActive = true
bikeQtyLabel.heightAnchor.constraint(equalToConstant: 20).isActive = true
dockQtyLabel.widthAnchor.constraint(equalToConstant: 20).isActive = true
dockQtyLabel.heightAnchor.constraint(equalToConstant: 20).isActive = true

bikeStackView.axis = .horizontal
bikeStackView.distribution = .fill
bikeStackView.alignment = .center
bikeStackView.spacing = 5
bikeStackView.addArrangedSubview(bikeImageView)
bikeStackView.addArrangedSubview(bikeQtyLabel)
bikeStackView.addArrangedSubview(dockImageView)
bikeStackView.addArrangedSubview(dockQtyLabel)
bikeStackView.addArrangedSubview(addressLabel)

contentStackView.axis = .vertical
contentStackView.distribution = .fill
contentStackView.alignment = .leading
contentStackView.spacing = 5
contentStackView.addArrangedSubview(stationNameLabel)
contentStackView.addArrangedSubview(bikeStackView)

self.addSubview(contentStackView)
contentStackView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
contentStackView.topAnchor.constraint(equalTo: self.topAnchor, constant: 20),
contentStackView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 15),
contentStackView.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -15),
contentStackView.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -10),
contentStackView.centerXAnchor.constraint(equalTo: self.centerXAnchor),
])
}

func configureLabel () {
stationNameLabel.text = "0"
stationNameLabel.textColor = Colors.darkGray
stationNameLabel.textAlignment = .center
stationNameLabel.numberOfLines = 0
stationNameLabel.font = UIFont.boldSystemFont(ofSize: 20)

bikeQtyLabel.text = "0"
bikeQtyLabel.textColor = Colors.darkGray
bikeQtyLabel.textAlignment = .center
bikeQtyLabel.numberOfLines = 0
bikeQtyLabel.font = UIFont.systemFont(ofSize: 15)

dockQtyLabel.text = "0"
dockQtyLabel.textColor = Colors.darkGray
dockQtyLabel.textAlignment = .center
dockQtyLabel.numberOfLines = 0
dockQtyLabel.font = UIFont.systemFont(ofSize: 15)

addressLabel.text = "0"
addressLabel.textColor = Colors.darkGray
addressLabel.textAlignment = .center
addressLabel.numberOfLines = 1
addressLabel.font = UIFont.systemFont(ofSize: 15)
addressLabel.adjustsFontSizeToFitWidth = false
}

func configureImageView () {
bikeImageView.image = Images.bikeWithCycle
bikeImageView.tintColor = Colors.systemYellow
bikeImageView.contentMode = .scaleAspectFit

dockImageView.image = Images.parkingsignWithCycle
dockImageView.tintColor = Colors.systemYellow
dockImageView.contentMode = .scaleAspectFit
}

static func nib () -> UINib {
return UINib(nibName: "FavoriteListTableViewCell", bundle: nil)
}

}

Reference:

如何制定Protocol & Delegate?

最後,我在這次的練習當中,學習到了如何自訂Protocol & Delegate,

  • 首先在InfomationView的Class上方,制定一個protocolInfomationViewDelegate
// Protocol definition
protocol InformationViewDelegate: AnyObject {
func favoriteButtonDidTap()
func routeBtnDidTap ()
}
  • 再來就是在InformationView的Class裡面,撰寫一個weak var delegate叫做InfomationViewDelegate ,然後之後我們就可以在任何一個view Controller,帶入這個Protocol!
class InformationView: UIView {

weak var delegate: InformationViewDelegate?
}
  • 新增InformationViewDelegateMapViewController.
class MapViewController: UIViewController, InformationViewDelegate {

// MARK: - Import the InfoView Delegate:
func routeBtnDidTap() {
print("DEBUG PRINT: InfoView's RouteBtn")

// 取得目的地的座標
let targetCoordinate = didSelectedLocation
// 取得目的地的座標後,將座標改成placeamark的型別.
let targetPlacemark = MKPlacemark(coordinate: targetCoordinate!)

// 將 placeamark的型別改成 MKMapItem,並命名為targetItem
let targetItem = MKMapItem(placemark: targetPlacemark)
targetItem.name = didSelectedStationName

// 取得使用者的座標
let userMapItem = MKMapItem.forCurrentLocation()

// Build the routes from userMapItem to targetItem
let routes = [userMapItem, targetItem]

// Default setting for user by using walking to the destination.
MKMapItem.openMaps(with: routes, launchOptions: [MKLaunchOptionsDirectionsModeKey : MKLaunchOptionsDirectionsModeWalking])
}

func favoriteButtonDidTap() {
guard let selectedAnnotation = selectedPlaces as? YoubikeAnnotation else {
print("DEBUG PRINT: No selected annotation to save.")
return
}

// 直接保存到 CoreData
savePlaceToCoreData(annotation: selectedAnnotation)
print("DEBUG PRINT: Annotation saved.")

// 打印 selectedAnnotation 的所有详细信息
if let title = selectedAnnotation.title, let subtitle = selectedAnnotation.subtitle {
print("""
DEBUG PRINT: Title: \(title), Subtitle: \(subtitle)
DEBUG PRINT: Coordinate: \(selectedAnnotation.coordinate.latitude), \(selectedAnnotation.coordinate.longitude)
""")
} else {
print("DEBUG PRINT: Annotation lacks title or subtitle")
}

favoriteButtonCount += 1
print("favoriteBtnCount: \(favoriteButtonCount)")
}

My GitHub Links:

--

--