昨天我們打開了通往聊天室的大門,成功建立了專案的入口頁面。今天,我們就要開始打造聊天室本身了!
我們將聚焦在 ChatViewController
,設計出一個經典的聊天介面:上方是用來顯示對話紀錄的 TableView
,下方則是包含「輸入框」和「傳送按鈕」的工具列。今天我們的重點會放在功能的實現上,先用最簡單的方式顯示對話,目標是打通從使用者輸入、串連 API 到更新畫面的完整流程!
TableView
顯示對話,並建立一個包含 UITextView
的輸入工具列。Cell
的文字對齊方式。NetworkManager
,並處理非同步回傳的結果。IBOutlet
設定首先,請打開 ChatViewController.xib
,並依照下圖的佈局,從元件庫中拖入所需元件:
UITableView
,用來顯示對話。UIView
作為底部的工具列背景。UITextView
,讓使用者輸入文字。UIButton
,作為傳送/中斷按鈕。Auto Layout 提示:請務必設定好約束!TableView
的上、左、右應貼齊 Safe Area,底部則貼齊 UIView
工具列的頂部。UIView
工具列則要釘選在畫面底部。
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
:因為這個方法是給 UIBarButtonItem
的 action
(一個 Objective-C API)使用的,所以前面需要加上 @objc
標記。popViewController(animated:)
:這是 UINavigationController
的方法,功能是「從導覽堆疊中移除最上層的 ViewController
」,也就是我們熟悉的「返回上一頁」。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)
}
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 聊天室的核心介面與互動邏輯!這是一次資訊量非常大的實作,但你成功地將所有部分組合了起來。
我們不僅打造了一個可以顯示兩種不同樣式 Cell
的 TableView
,還串連了 NetworkManager
來發送與接收真實的網路資料。最重要的是,你學會了用 insertRows
來實現流暢的列表動畫,並透過 UITextViewDelegate
監聽使用者輸入,打造了完整的聊天體驗。
今天我們假設 ChatMessage
和 NetworkManager
已經存在。明天,我們就要來補齊這些「幕後」的基礎建設!
我們將會:
ChatMessage
這個資料模型。NetworkManager
中真正的 getGeminiResponse
方法,串接 Google Gemini API。敬請期待《Day 29|Xcode 專案實戰:打造 Gemini AI 聊天室!(三)》