模仿 iOS WorldClock

Julia
彼得潘的 Swift iOS / Flutter App 開發教室
20 min readFeb 20, 2022

資料儲存練習,用 delegate 傳資料

這次作業模仿 iPhone內建的 worldClock 來練習 table view controller 的新增,刪除,和儲存資料

1. 取得世界城市 timezone 的清單

let knownTimeZoneID = TimeZone.knownTimeZoneIdentifiers.filter { ID in
ID.contains("/")
}

2. 建立列表上所要顯示的資料

  • identify : 從 TimeZone.knownTimeZoneIdentifiers 裡得到的 ID
  • cityName : 取 id / 後面的城市名
  • cityPrefix : 取城市的第一個字母,在 searchTableViewController 裡當 section 的名字用
  • relativeDate : 相對local 的日期,顯示 昨天、今天、明天
  • relativeHour:相對 local 的小時差 Ex: +- HRs
  • localTime : 當地時間
struct cityInfo: Codable{


var identify: String
var cityName: String {
let fullName = identify.replacingOccurrences(of: "_", with: " ").components(separatedBy: "/")
if fullName.count > 2{
return fullName[1] + ", " + fullName[2]
}

return fullName[1]
}
var cityPrefix : String{
return String(cityName.prefix(1))
}

var relativeDate : String{
let dateformatter = DateFormatter()
dateformatter.timeZone = TimeZone(identifier: identify)
dateformatter.dateStyle = .medium
dateformatter.timeStyle = .none
dateformatter.doesRelativeDateFormatting = true

return dateformatter.string(from: .now) + ","

}

var relativeHour : String {

let hrInterval = (TimeZone(identifier: identify)!.secondsFromGMT() - TimeZone.current.secondsFromGMT())/3600
if hrInterval == 1 || hrInterval == -1 {
return hrInterval.description + "HR"
}
return hrInterval.description + "HRS"
}

var localTime : String{
let dateformatter = DateFormatter()
dateformatter.timeZone = TimeZone(identifier: identify)
dateformatter.dateFormat = "HH:mm"
return dateformatter.string(from: .now)
}
}

3. 建立搜索的頁面

  • 幫城市建立一個 dictionary, key 是 cityPrefix (也是 section header), value 是 以那個字母為開頭的 array of cityInfo
var delegate : SearchTableViewControllerDelegate?
var cityDictionary = [String : [cityInfo]]()
var cityDictinaryKey = [String]()
var searching = false
lazy var filterCityList = knownCityList
var knownCityList = [cityInfo]()
var city : cityInfo?

func sectionHeaderSetting(){
for cityID in knownTimeZoneID{
let city = cityInfo(identify: cityID)
knownCityList.append(city)

if cityDictionary.keys.contains(city.cityPrefix){
cityDictionary[city.cityPrefix]!.append(city)
}else{
cityDictionary[city.cityPrefix] = [city]
}
}
cityDictinaryKey = Array(cityDictionary.keys).sorted(by: <)
}
  • 設定 number of sections and number of rows in section 和要顯示的內容

在搜尋的時候沒有按字母分 section,所以設一個 var boolean variable 來看當時有沒有在搜尋,並用 if statement 把他分成兩個狀況顯示

override func numberOfSections(in tableView: UITableView) -> Int {
if searching {
return 1
}
return cityDictionary.count
}

override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if searching {
return filterCityList.count
}
return cityDictionary[cityDictinaryKey[section]]!.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: "\(SearchTableViewCell.self)", for: indexPath) as? SearchTableViewCell else {return UITableViewCell()}

if searching {
let cityId = filterCityList[indexPath.row]
cell.SearchCityLabel.text = cityId.cityName
}else{
let cityId = cityDictionary[cityDictinaryKey[indexPath.section]]![indexPath.row]

cell.SearchCityLabel.text = cityId.cityName
}

return cell
}

override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {

if searching {
return nil
}

return cityDictinaryKey[section]
}
  • 參考下面那篇文章,在navigationBar 上設立 searchBar
func searchBarSetting(){
let searchController = UISearchController()
searchController.searchResultsUpdater = self
navigationItem.searchController = searchController
navigationItem.hidesSearchBarWhenScrolling = false
searchController.searchBar.barStyle = .black
searchController.searchBar.searchTextField.textColor = .white
searchController.automaticallyShowsCancelButton = true
}
  • 實現 search 的時候 下面的資料也會跟著 搜索關鍵字 update

會用到之前的 lazy var filterCityList = knownCityList

記得最後要讓 tableView reload data ,畫面才會 update

extension SearchTableViewController : UISearchResultsUpdating{
func updateSearchResults(for searchController: UISearchController) {

if let searchText = searchController.searchBar.text,
searchText.isEmpty == false{
searching = true
filterCityList = knownCityList.filter({ city in
city.cityName.localizedStandardContains(searchText)
})


}else{
searching = false
filterCityList = knownCityList
}

tableView.reloadData()

}
}
  • 把選到的資料傳回上一頁 (unwind segue)
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if searching{
guard let row = tableView.indexPathForSelectedRow?.row else {return }
city = filterCityList[row]
}else {
guard let section = tableView.indexPathForSelectedRow?.section else {return }
guard let row = tableView.indexPathForSelectedRow?.row else {return }
city = cityDictionary[cityDictinaryKey[section]]![row]
}
}

3. 建立世界時鐘的清單頁面

  • 儲存使用者選擇的城市資料

struct 要是 codable才能讀取跟存資料 ,並把資料存在 document 資料夾裡

struct cityInfo: Codable{
// save and load data
static let documentDictionary = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!

static func loadData() -> [cityInfo]?{
let url = documentDictionary.appendingPathComponent("cityList")
guard let data = try? Data(contentsOf: url) else {return nil}
let decoder = JSONDecoder()
return try? decoder.decode([cityInfo].self, from: data)

}

static func saveInfo(info :[cityInfo]){
let encoder = JSONEncoder()
guard let data = try? encoder.encode(info) else {return }
let url = documentDictionary.appendingPathComponent("cityList")
try? data.write(to: url)
}
}

在 worldClockTableViewController 裡在叫出function 讀取還有存資料

didSet 會在新的值存進去的時候被叫出來

記得讓 timer 每一秒重新 reload data ,時間才會跟著動

var cityList = [cityInfo](){
didSet{
cityInfo.saveInfo(info: cityList)
}
}
override func viewDidLoad() {
super.viewDidLoad()

if let cityList = cityInfo.loadData(){
self.cityList = cityList
}

tableView.rowHeight = 90
tableView.separatorStyle = .singleLine
tableView.separatorColor = .gray

let timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
self.tableView.isEditing ? nil : self.tableView.reloadData()
}
timer.fire()


}
  • 讓使用者可以刪除或移動想要的順序 並設定 Edit button

super.setEditing( ) 可以讓我們edit table view

isEditing 可以告訴我們當下有沒有正在 edit (boolean value)

override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
cityList.remove(at: indexPath.row)
tableView.deleteRows(at: [indexPath], with: .automatic)
}
// Override to support conditional editing of the table view.
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
// Return false if you do not want the specified item to be editable.
return true
}
// make the cell become movable
// Override to support conditional rearranging of the table view.
override func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool {
// Return false if you do not want the item to be re-orderable.
return true
}

// Override to support rearranging the table view.
override func tableView(_ tableView: UITableView, moveRowAt fromIndexPath: IndexPath, to: IndexPath) {
let removeItem = cityList[fromIndexPath.row]
cityList.remove(at: fromIndexPath.row)
cityList.insert(removeItem, at: to.row)
}@IBAction func clickEditButton(_ sender: UIBarButtonItem) {
// set whether the view controller show the editable view
super.setEditing(!tableView.isEditing , animated: true)
sender.title = isEditing ? "Done": "Edit"

tableView.allowsSelectionDuringEditing = true
tableView.reloadData()

}

4. 用 delegate 把資料傳回前一頁

本來這裡我是用 unwind segue傳資料的,但發現用 search bar 搜尋城市後,要點兩次才能傳回前一頁,卡了很久後,再經過 Peter 的神救援,改用 tableView(_:didSelectRowAt:) 和 delegate傳資料

  • 建立 protocol並呼叫裡面的 function

In SearchViewTableViewController,

protocol SearchTableViewControllerDelegate {
func searchTableViewController( _ controller : SearchTableViewController, addCity city:cityInfo)
}
class SearchTableViewController: UITableViewController {

var delegate : SearchTableViewControllerDelegate?
var city : cityInfo?

override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
if searching{
city = filterCityList[indexPath.row]
}else {
city = cityDictionary[cityDictinaryKey[indexPath.section]]![indexPath.row]
}
delegate?.searchTableViewController(self, addCity: city!)

navigationController?.popViewController(animated: true)
}
}
  • 連一個 IBSegueAction, 在裡面設定 delegate,記得也要繼承 SearchTableViewControllerDelegate

In WorldClockTableViewController,

@IBSegueAction func addCity(_ coder: NSCoder) -> SearchTableViewController? {
let searchTableViewController = SearchTableViewController(coder: coder)
searchTableViewController?.delegate = self
return searchTableViewController
}

繼承 SearchTableViewControllerDelegate ,寫一個一樣的function 名,並在 function 裡面放想做的事

extension WorldClockTableViewController : SearchTableViewControllerDelegate{

func searchTableViewController( _ controller : SearchTableViewController, addCity city:cityInfo){

if !(cityList.contains(where: { cityInfo in
cityInfo.cityName == city.cityName
})){
cityList.append(city)
}
tableView.reloadData()
}
}

最後,特別感謝 Peter 的神救援,還有下面這位前輩的作業參考,才能成功將這個作業做出來!!!

Link:

GitHub:

--

--