昨天我們完成了 NASA App 所有關鍵的幕後準備工作,就像是把 App 的引擎(NetworkManager
)和儀表板藍圖(UI 設計)都準備就緒。今天,就是進入駕駛艙,將所有線路接上,讓 App 真正啟動的時刻!
我們將全程聚焦在 MainViewController.swift
這位「總指揮」身上,賦予 PickerView
生命、串連 NASA API,並親眼見證第一張太空美照出現在我們的 App 畫面上。準備好迎接這個激動人心的成果了嗎?
UIPickerView
的 dataSource
與 delegate
,打造一個三欄式、邏輯完整的日期選取器NetworkManager
,並處理回傳的 Result
DispatchQueue.main.async
) 安全地更新 UIImageView
將昨天已經擺放好的三個元件分別建立 IBOutlet
(可參考之前的教學文章):
@IBOutlet weak var imgNASA: UIImageView! // 顯示 NASA 天文圖
@IBOutlet weak var pkvDate: UIPickerView! // 用戶選擇日期
@IBOutlet weak var btnEnter: UIButton! // 確認按鈕
按著 control 從
.xib
拉到.swift
接下來設定 viewDidLoad()
,這段程式碼會在畫面載入時自動執行:
override func viewDidLoad() {
super.viewDidLoad()
pkvDate.dataSource = self
pkvDate.delegate = self
setupYears()
// 預設選擇為昨天
let calendar = Calendar.current
let yesterday = calendar.date(byAdding: .day, value: -1, to: Date())!
selectedYear = calendar.component(.year, from: yesterday)
selectedMonth = calendar.component(.month, from: yesterday)
selectedDay = calendar.component(.day, from: yesterday)
updateDays()
// 設定 picker 預設選擇
if let yearIndex = years.firstIndex(of: selectedYear!),
let monthIndex = months.firstIndex(of: selectedMonth!),
let dayIndex = days.firstIndex(of: selectedDay!) {
pkvDate.selectRow(yearIndex, inComponent: 0, animated: false)
pkvDate.selectRow(monthIndex, inComponent: 1, animated: false)
pkvDate.selectRow(dayIndex, inComponent: 2, animated: false)
}
btnEnter.addTarget(self, action: #selector(fetchButtonTapped), for: .touchUpInside)
fetchData()
}
pkvDate
指定給 DataSource
與 Delegate
。PickerView
預設滾動到昨天的日期。這段程式碼需搭配變數與方法定義,否則會報錯。接下來會逐步補齊。
var years: [Int] = []
let months: [Int] = Array(1...12)
var days: [Int] = []
var selectedYear: Int?
var selectedMonth: Int?
var selectedDay: Int?
years
:年份清單(例如 2005~2025)。months
:固定 12 個月。days
:根據「年 + 月」決定天數(2 月可能 28 或 29 天)。selectedYear
/ Month
/ Day
:記錄使用者目前選的日期。func setupYears() {
let calendar = Calendar.current
let currentYear = calendar.component(.year, from: Date())
years = Array((currentYear-20)...currentYear) // 過去 20 年到今年
}
這裡用
Calendar
取出「今年」,再建立一個區間,例如今年是 2025,那麼就會得到[2005, 2006, …, 2025]
。
func updateDays() {
guard let year = selectedYear, let month = selectedMonth else {
days = Array(1...31)
return
}
var comps = DateComponents()
comps.year = year
comps.month = month
let calendar = Calendar.current
if let date = calendar.date(from: comps),
let range = calendar.range(of: .day, in: .month, for: date) {
days = Array(range)
} else {
days = Array(1...31)
}
// 如果目前選的 day 大於最大天數,調整為最大天
if let day = selectedDay, day > days.count {
selectedDay = days.last
}
}
這段程式可以保證「月份與天數正確」:
- 例如 2025/02 → 自動變成 28 天。
- 如果選了 31 號,但換到 4 月(只有 30 天),就會自動改成 30 號。
我們用 extension
來分別實作資料來源與互動行為。
extension MainViewController: UIPickerViewDataSource {
func numberOfComponents(in pickerView: UIPickerView) -> Int {
return 3 // 年、月、日三欄
}
func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
switch component {
case 0: return years.count
case 1: return months.count
case 2: return days.count
default: return 0
}
}
}
UIPickerViewDataSource
負責告訴PickerView
:
- 一共有 3 欄(年 / 月 / 日)。
- 每一欄有多少列(rows)。
extension MainViewController: UIPickerViewDelegate {
func pickerView(_ pickerView: UIPickerView, widthForComponent component: Int) -> CGFloat {
// 三欄平均寬度,可依需求調整
return pickerView.bounds.width / 3 - 10
}
func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
switch component {
case 0: return "\(years[row])"
case 1: return "\(months[row])"
case 2: return "\(days[row])"
default: return nil
}
}
func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
switch component {
case 0:
selectedYear = years[row]
updateDays()
pkvDate.reloadComponent(2) // 重新載入日欄,因為天數可能變動
// 確保日欄不會超出範圍
if let day = selectedDay,
let dayIndex = days.firstIndex(of: day) {
pkvDate.selectRow(dayIndex, inComponent: 2, animated: true)
} else {
selectedDay = days.first
pkvDate.selectRow(0, inComponent: 2, animated: true)
}
case 1:
selectedMonth = months[row]
updateDays()
pkvDate.reloadComponent(2)
if let day = selectedDay,
let dayIndex = days.firstIndex(of: day) {
pkvDate.selectRow(dayIndex, inComponent: 2, animated: true)
} else {
selectedDay = days.first
pkvDate.selectRow(0, inComponent: 2, animated: true)
}
case 2:
selectedDay = days[row]
default:
break
}
}
}
titleForRow
:決定每一列要顯示什麼字。didSelectRow
:更新使用者選擇,並調整天數。當我們選好日期並點擊按鈕,就會觸發 fetchData()
:
func fetchData() {
guard let year = selectedYear,
let month = selectedMonth,
let day = selectedDay else { return }
// 組成 yyyy-MM-dd 格式字串
let dateString = String(format: "%04d-%02d-%02d", year, month, day)
print("Fetch NASA data for date: \(dateString)")
NetworkManager.shared.fetchNASAData(for: dateString) { [weak self] result in
DispatchQueue.main.async {
switch result {
case .success(let data):
self?.loadImage(from: data.url, into: self!.imgNASA)
case .failure(let error):
print("Error fetching data: \(error.localizedDescription)")
}
}
}
}
NetworkManager
發送請求。loadImage()
將圖片顯示在介面上。設計好 fetchData()
的功能後,我們再新增按鈕的 IBAction
,讓按鈕點擊時執行抓取動作:
@IBAction func fetchButtonTapped(_ sender: UIButton) {
fetchData()
}
fetchData()
綁定。最後一步,把圖片載入並顯示在 ImageView
:
func loadImage(from urlString: String, into imageView: UIImageView) {
guard let url = URL(string: urlString) else {
print("URL 格式錯誤")
return
}
URLSession.shared.dataTask(with: url) { data, _, error in
if let error = error {
print("下載圖片失敗: \(error.localizedDescription)")
return
}
guard let data = data else {
print("圖片資料為空")
return
}
DispatchQueue.main.async {
imageView.image = UIImage(data: data)
}
}.resume()
}
loadImage()
非同步下載圖片,不會阻塞主線程。DispatchQueue.main
,否則會當掉!模擬器啟動後,會自動顯示昨天的天文圖片:
嘗試切換不同日期,圖片會隨著選擇的日期更新:
二月份會自動調整到 28 或 29 天(閏年自動判斷):
其他月份會自動調整到 30 或 31 天:
如果順利完成測試,表示日期選擇器與天文圖片顯示功能正常,年份、月份、天數邏輯正確。
恭喜!今天我們完成了從零到一的巨大飛躍,你第一個串連真實網路 API 的動態 App 誕生了!
我們今天扮演了「總指揮」的角色,在 MainViewController
中完美地將所有零件組裝起來。你不僅掌握了 UIPickerView
精密的 dataSource
和 delegate
實作,更學會了如何在 App 中呼叫 NetworkManager
、處理網路回傳的資料,以及最重要的——使用 DispatchQueue.main.async
進行非同步的 UI 更新。
我們的 NASA App 現在功能完整,但體驗上還有一個小缺憾:當我們按下按鈕後,App 需要花點時間去網路上抓圖片,但畫面上卻沒有任何提示,使用者可能會以為 App 當掉了!
明天,我們將為這個 App 加上畫龍點睛的一筆——讀取中的等待動畫!你將學會如何使用 UIActivityIndicatorView
(俗稱的 loading spinner),在網路請求開始時顯示、結束後隱藏,讓 App 的使用者體驗更專業、更友善。
敬請期待《Day 26|Xcode 擴充功能:PickerView & NASA 等待動畫與 UI 優化》