iT邦幫忙

2025 iThome 鐵人賽

DAY 25
0
Mobile Development

Swift iOS 開發新手村:從入門到 AI 聊天室系列 第 25

Day 25|Xcode 滾動選取:PickerView & NASA 實戰應用(第二天)

  • 分享至 

  • xImage
  •  

昨天我們完成了 NASA App 所有關鍵的幕後準備工作,就像是把 App 的引擎(NetworkManager)和儀表板藍圖(UI 設計)都準備就緒。今天,就是進入駕駛艙,將所有線路接上,讓 App 真正啟動的時刻!

我們將全程聚焦在 MainViewController.swift 這位「總指揮」身上,賦予 PickerView 生命、串連 NASA API,並親眼見證第一張太空美照出現在我們的 App 畫面上。準備好迎接這個激動人心的成果了嗎?

今日學習重點

  • 實作日期選單:深入 UIPickerViewdataSourcedelegate,打造一個三欄式、邏輯完整的日期選取器
  • 串連 API 請求:學習在按下按鈕後,組合日期、呼叫 NetworkManager,並處理回傳的 Result
  • 非同步載入圖片:掌握從 URL 下載圖片,並在主線程 (DispatchQueue.main.async) 安全地更新 UIImageView
  • 完成 App 核心流程:將所有零件組合起來,完成從「使用者操作」到「畫面呈現」的完整互動

一、建立 IBOutlet

將昨天已經擺放好的三個元件分別建立 IBOutlet(可參考之前的教學文章):

@IBOutlet weak var imgNASA: UIImageView!    // 顯示 NASA 天文圖
@IBOutlet weak var pkvDate: UIPickerView!   // 用戶選擇日期
@IBOutlet weak var btnEnter: UIButton!      // 確認按鈕

按著 control 從 .xib 拉到 .swift

二、初始化 viewDidLoad()

接下來設定 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 指定給 DataSourceDelegate
  • PickerView 預設滾動到昨天的日期。
  • 幫按鈕加上事件 → 點擊就會抓取 NASA API。
  • 預設載入昨天的 NASA 天文圖。

這段程式碼需搭配變數與方法定義,否則會報錯。接下來會逐步補齊。

三、PickerView 資料來源、天數更新

宣告 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 號。

四、DataSource & Delegate

我們用 extension 來分別實作資料來源與互動行為。

DataSource → 告訴 PickerView 有幾欄、每欄有幾列

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)。

Delegate → 決定要顯示什麼內容,以及選取時的反應

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:更新使用者選擇,並調整天數。

五、發送 API 請求:fetchData()

當我們選好日期並點擊按鈕,就會觸發 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)")
            }
        }
    }
}
  • 先組成「YYYY-MM-DD」字串,對應 NASA API 日期格式。
  • 使用 NetworkManager 發送請求。
  • 成功取得資料後,呼叫 loadImage() 將圖片顯示在介面上。

設計好 fetchData() 的功能後,我們再新增按鈕的 IBAction,讓按鈕點擊時執行抓取動作:

@IBAction func fetchButtonTapped(_ sender: UIButton) {
    fetchData()
}
  • 這裡把按鈕事件與 fetchData() 綁定。
  • 點擊按鈕就會抓取使用者選擇日期的 NASA 天文圖。

六、下載圖片:loadImage()

最後一步,把圖片載入並顯示在 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() 非同步下載圖片,不會阻塞主線程。
  • ⚠️更新 UI 必須在主執行緒 DispatchQueue.main,否則會當掉!

七、測試模擬器

模擬器啟動後,會自動顯示昨天的天文圖片:
https://ithelp.ithome.com.tw/upload/images/20251009/20177542272ejr55Lb.png
嘗試切換不同日期,圖片會隨著選擇的日期更新:
https://ithelp.ithome.com.tw/upload/images/20251009/20177542ouA0A9pO0z.png
二月份會自動調整到 28 或 29 天(閏年自動判斷):
https://ithelp.ithome.com.tw/upload/images/20251009/20177542LJlfDTRvkx.png
其他月份會自動調整到 30 或 31 天:
https://ithelp.ithome.com.tw/upload/images/20251009/201775426uo7gqo3Cm.png
如果順利完成測試,表示日期選擇器與天文圖片顯示功能正常,年份、月份、天數邏輯正確。

小結一下

恭喜!今天我們完成了從零到一的巨大飛躍,你第一個串連真實網路 API 的動態 App 誕生了!

我們今天扮演了「總指揮」的角色,在 MainViewController 中完美地將所有零件組裝起來。你不僅掌握了 UIPickerView 精密的 dataSourcedelegate 實作,更學會了如何在 App 中呼叫 NetworkManager、處理網路回傳的資料,以及最重要的——使用 DispatchQueue.main.async 進行非同步的 UI 更新。

🌟 明日預告

我們的 NASA App 現在功能完整,但體驗上還有一個小缺憾:當我們按下按鈕後,App 需要花點時間去網路上抓圖片,但畫面上卻沒有任何提示,使用者可能會以為 App 當掉了!

明天,我們將為這個 App 加上畫龍點睛的一筆——讀取中的等待動畫!你將學會如何使用 UIActivityIndicatorView(俗稱的 loading spinner),在網路請求開始時顯示、結束後隱藏,讓 App 的使用者體驗更專業、更友善。

敬請期待《Day 26|Xcode 擴充功能:PickerView & NASA 等待動畫與 UI 優化》


上一篇
Day 24|Xcode 滾動選取:PickerView & NASA 實戰應用(第一天)
下一篇
Day 26|Xcode 擴充功能:PickerView & NASA 等待動畫與 UI 優化
系列文
Swift iOS 開發新手村:從入門到 AI 聊天室27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言