iT邦幫忙

2025 iThome 鐵人賽

DAY 28
0
Mobile Development

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

Day 28|Xcode 專案實戰:打造 Gemini AI 聊天室!(二)

  • 分享至 

  • xImage
  •  

昨天我們打開了通往聊天室的大門,成功建立了專案的入口頁面。今天,我們就要開始打造聊天室本身了!

我們將聚焦在 ChatViewController,設計出一個經典的聊天介面:上方是用來顯示對話紀錄的 TableView,下方則是包含「輸入框」和「傳送按鈕」的工具列。今天我們的重點會放在功能的實現上,先用最簡單的方式顯示對話,目標是打通從使用者輸入、串連 API 到更新畫面的完整流程!

今日學習重點

  • 打造聊天介面:使用 TableView 顯示對話,並建立一個包含 UITextView 的輸入工具列。
  • 實現簡易對話樣式:學習根據訊息來源(使用者 vs. AI),改變系統內建 Cell 的文字對齊方式。
  • 串連 Gemini API:實作傳送訊息的邏輯,呼叫 NetworkManager,並處理非同步回傳的結果。
  • 優化互動體驗:實現傳送按鈕的動態圖示、對話列表的自動滾動,以及流暢的新訊息插入動畫。

一、畫面元件與 IBOutlet 設定

首先,請打開 ChatViewController.xib,並依照下圖的佈局,從元件庫中拖入所需元件:

  1. 一個 UITableView,用來顯示對話。
  2. 一個 UIView 作為底部的工具列背景。
  3. 一個 UITextView,讓使用者輸入文字。
  4. 一個 UIButton,作為傳送/中斷按鈕。

Auto Layout 提示:請務必設定好約束!TableView 的上、左、右應貼齊 Safe Area,底部則貼齊 UIView 工具列的頂部。UIView 工具列則要釘選在畫面底部。

https://ithelp.ithome.com.tw/upload/images/20251010/20177542TFsHO5P66d.png

UI 提示:為了讓介面更貼近真實的 Gemini App,你可以在左側放上幾個仿刻用的按鈕。在這次的實作中,我們只會為最右側的「傳送」按鈕加上功能。

完成後,將這些元件與 ChatViewController.swift 建立 IBOutlet 連線:

@IBOutlet weak var txvText: UITextView!
@IBOutlet weak var vToolBar: UIView!
@IBOutlet weak var btnSend: UIButton!
@IBOutlet weak var tbvChat: UITableView!

二、viewDidLoad:完成所有前置設定

viewDidLoad 是我們進行所有初始設定的最佳時機。這裡我們將完成導覽列、TableView 註冊、代理設定與 UI 美化。

var messages: [ChatMessage] = []
var isResponding = false
var currentTask: URLSessionDataTask?

override func viewDidLoad() {
    super.viewDidLoad()
    
    self.title = "Gemini"
    self.navigationItem.hidesBackButton = true
    
    // 自訂返回按鈕
    let menuButton = UIBarButtonItem(
        image: UIImage(systemName: "line.horizontal.3"),
        style: .plain,
        target: self,
        action: #selector(menuTapped)
    )
    menuButton.tintColor = .gray
    self.navigationItem.leftBarButtonItem = menuButton
    
    // 註冊系統內建的 UITableViewCell
    tbvChat.register(UITableViewCell.self, forCellReuseIdentifier: "chatCell")
    
    // 設定代理
    tbvChat.delegate = self
    tbvChat.dataSource = self
    txvText.delegate = self
    
    // UI 設定
    tbvChat.separatorStyle = .none
    tbvChat.rowHeight = UITableView.automaticDimension // 啟用自動高度計算
    tbvChat.estimatedRowHeight = 44
    
    // 設定輸入工具列邊框
    vToolBar.layer.borderColor = UIColor.lightGray.cgColor
    vToolBar.layer.borderWidth = 1.0
    vToolBar.layer.cornerRadius = 25.0
    vToolBar.layer.masksToBounds = true
    
    updateSendButtonIcon() // 設定按鈕初始圖示
}

程式碼說明

  • register:今天我們先使用系統內建的 UITableViewCell 來快速顯示文字,專注於功能實現。
  • rowHeight = UITableView.automaticDimension:這是最關鍵的一行!它告訴 TableView:「請根據 Cell` 內容,自動計算每一列的高度。」這樣才能顯示長短不一的對話訊息。

處理導覽列返回事件

接續剛才在 viewDidLoad 中自訂了一個左上角的 menu 按鈕,現在需要實作它被點擊後要執行的 menuTapped 方法。

// 處理返回按鈕
@objc func menuTapped() {
    self.navigationController?.popViewController(animated: true)
}

程式碼說明

  • @objc:因為這個方法是給 UIBarButtonItemaction(一個 Objective-C API)使用的,所以前面需要加上 @objc 標記。
  • popViewController(animated:):這是 UINavigationController 的方法,功能是「從導覽堆疊中移除最上層的 ViewController」,也就是我們熟悉的「返回上一頁」。

三、實作 UITableViewDataSource:提供對話資料

DataSource 負責告訴 TableView 要顯示「幾則訊息」和「每一則訊息的長相」。

extension ChatViewController: UITableViewDataSource {
    
    // 總共有幾則訊息
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return messages.count
    }

    // 設定每一則訊息的 Cell
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "chatCell", for: indexPath)
        let message = messages[indexPath.row]
        
        // 讓 Cell 的文字可以自動換行
        cell.textLabel?.numberOfLines = 0
        cell.textLabel?.text = message.text
        
        // 根據訊息來源,改變文字的對齊方式
        if message.isUser {
            // 使用者的訊息靠右
            cell.textLabel?.textAlignment = .right
        } else {
            // AI 的訊息靠左
            cell.textLabel?.textAlignment = .left
        }
        
        return cell
    }
}

程式碼說明

  • 這裡我們暫時用 textAlignment 來區分使用者和 AI 的訊息。這是一個快速實現左右對話外觀的方式,讓我們能專注在 API 串接的核心功能上。

四、核心功能:傳送訊息與接收回應

這是 App 最核心的邏輯,全部集中在 sendMessage 這個 IBAction 中。

@IBAction func sendMessage(_ sender: UIButton) {
    // 如果正在等待 AI 回覆,此按鈕的功能變為「中斷」
    if isResponding {
        currentTask?.cancel()
        isResponding = false
        updateSendButtonIcon()
        return
    }
    
    // 正常傳送模式
    guard let text = txvText.text, !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
    
    // 1. 更新 UI
    txvText.text = ""
    updateSendButtonIcon()
    
    // 2. 更新資料模型 (使用者訊息)
    let userMessage = ChatMessage(text: text, isUser: true)
    messages.append(userMessage)
    
    // 3. 以動畫更新 TableView
    let userIndexPath = IndexPath(row: messages.count - 1, section: 0)
    tbvChat.insertRows(at: [userIndexPath], with: .automatic)
    scrollToBottom()
    
    // 4. 進入等待 AI 回覆的狀態
    isResponding = true
    updateSendButtonIcon()

    // 5. 呼叫 Gemini API
    currentTask = NetworkManager.getGeminiResponse(for: text) { [weak self] responseText in
        DispatchQueue.main.async {
            guard let self = self else { return }
            
            // 6. 結束等待狀態
            self.isResponding = false
            self.updateSendButtonIcon()
            
            // 7. 更新資料模型 (AI 訊息)
            let geminiMessage = ChatMessage(text: responseText, isUser: false)
            self.messages.append(geminiMessage)
            
            // 8. 以動畫更新 TableView
            let geminiIndexPath = IndexPath(row: self.messages.count - 1, section: 0)
            self.tbvChat.insertRows(at: [geminiIndexPath], with: .automatic)
            self.scrollToBottom()
        }
    }
}

程式碼說明

  • insertRows(at:...):我們不再使用生硬的 reloadData(),而是精準地告訴 TableView「在最底部插入一列」,這會帶來流暢的動畫效果。
  • [weak self]`:由於閉包會被 `NetworkManager` 持有,為了避免循環參考,我們使用 [weak self]``。
  • DispatchQueue.main.async:網路回呼是在背景線程,所有更新 UI 的程式碼都必須放在主線程中執行。

五、輔助功能與體驗優化

我們還需要一些輔助函式,來讓 App 體驗更好。

根據狀態更新傳送按鈕

private func updateSendButtonIcon() {
    if isResponding {
        // 等待 AI 回覆時,顯示「停止」圖示
        btnSend.setImage(UIImage(systemName: "stop.circle.fill"), for: .normal)
    } else if let text = txvText.text, !text.isEmpty {
        // 輸入框有文字時,顯示「傳送」圖示
        btnSend.setImage(UIImage(systemName: "arrowshape.up.circle.fill"), for: .normal)
    } else {
        // 輸入框沒文字時,顯示「語音」圖示(此功能待擴充)
        btnSend.setImage(UIImage(systemName: "waveform.circle"), for: .normal)
    }
}

自動滾動到列表底部

private func scrollToBottom() {
    guard messages.count > 0 else { return }
    let indexPath = IndexPath(row: messages.count - 1, section: 0)
    tbvChat.scrollToRow(at: indexPath, at: .bottom, animated: true)
}

監聽文字輸入與鍵盤 Enter 鍵

extension ChatViewController: UITextViewDelegate {
    // 當文字變動時,更新按鈕圖示
    func textViewDidChange(_ textView: UITextView) {
        updateSendButtonIcon()
    }
    
    // 讓使用者可以按 Enter 送出訊息
    func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
        if text == "\n" {
            sendMessage(btnSend) // 呼叫傳送訊息的方法
            return false // 阻止 UITextView 輸入換行符
        }
        return true
    }
}

小結一下

今天我們完成了 Gemini 聊天室的核心介面與互動邏輯!這是一次資訊量非常大的實作,但你成功地將所有部分組合了起來。

我們不僅打造了一個可以顯示兩種不同樣式 CellTableView,還串連了 NetworkManager 來發送與接收真實的網路資料。最重要的是,你學會了用 insertRows 來實現流暢的列表動畫,並透過 UITextViewDelegate 監聽使用者輸入,打造了完整的聊天體驗。

🌟 明天預告

今天我們假設 ChatMessageNetworkManager 已經存在。明天,我們就要來補齊這些「幕後」的基礎建設!

我們將會:

  1. 定義 ChatMessage 這個資料模型。
  2. 實作 NetworkManager 中真正的 getGeminiResponse 方法,串接 Google Gemini API。
  3. 處理 API Key 的安全存放問題。

敬請期待《Day 29|Xcode 專案實戰:打造 Gemini AI 聊天室!(三)》


上一篇
Day 27|最終專案實戰:打造你的 Gemini AI 聊天室!(一)
系列文
Swift iOS 開發新手村:從入門到 AI 聊天室28
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言