Episode 95— 聊天室畫面:Table View & Text View 搭配 AutoLayout

Shien
彼得潘的 Swift iOS / Flutter App 開發教室
18 min readMay 23, 2022

完整操作

Storyboard 製作聊天畫面

  • 這次使用 view controller 裡放一個 table view。設定 table view 為 Prototype Cells 後新增兩個 cell,表示兩個對話者。兩個 cell 裡各放一個頭像 image view 跟一個對話框 text view。

頭貼

  • 先處理大頭貼的 autolayout。固定寬高且1比1等比例,往 cell 的 top 跟 leading 固定距離。重點是忘 cell 的 bottom 要改成 larger or equal,這樣子照片到時候才不會跟著對話框變長。

對話框

  • 接下來設定對話框的 autolayout。往 cell 的 top 跟 對話框的 trailing 固定距離,往 cell 的 trailing 跟 bottom 設定 larger or equal 的距離。
  • 還要記得在 text view 的 scroll view 屬性取消勾選 Scrolling Enabled,這樣對話框才會根據字數改變大小。

對話框的箭頭

  • 先在能夠畫出三角型的程式製作三角形,或直接上網抓圖
  • 在對話框旁邊新增一個 image view 後放入三角形的圖片,讓圖片寬高固定並往對話框的距離為零,再設定圖片的高度跟對話框的距離。

輸入框

  • 先製作一個跟 view 寬度一樣的橫向 stack view,在裡面放兩個按鈕。將兩個按鈕固定寬高且對齊 stack view 兩邊的角落將會空出中間的一塊空間,這個空間會用 text view 蓋住。
  • 將一個 text view “蓋在” 剛剛的空間上,如果放進 stack view 裡,左右兩邊的 button 可能會被 text view 高度改變而改變。
  • 將 text view 左右對齊兩邊按鈕,固定往下到 super view bottom 的距離。往上 less or equal 到 super view top 的距離。這邊建議連到 super view 是因為我在測試時發現有些機型會因為 safe area 的關係導致到時候跟鍵盤會有距離。

鍵盤移動輸入框

  • 先宣告相關屬性
@IBOutlet weak var inputStackViewTop: NSLayoutConstraint! //stack view 到 super view top 的 constraint
@IBOutlet weak var inputStackViewBottom: NSLayoutConstraint! // stack view 到 super view bottom 的 constraint
var keyboardSize: CGRect? //鍵盤高度
var keyboardIsShowed = false //檢查鍵盤顯示狀態
  • 為了不要在叫出鍵盤時讓輸入框被擋到,需要取得鍵盤的高度去移動輸入框。使用 NotificationCenter 物件可以檢查鍵盤的動向。addObserver(_ observer: 觀察者, selector: 選擇執行的方法 , name:相關提示的名稱 , object: 傳送提示的物件),使用這個功能去觀察鍵盤是顯示還是關閉狀態。
override func viewDidLoad() {
super.viewDidLoad()
// 鍵盤觀察器:鍵盤顯示時
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil)
// 鍵盤觀察器:繼盤關閉時
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil)

}
  • 鍵盤顯示時,輸入框會往上移動,代表輸入框到 super view top 的 constraints 常數會減少,且輸入框 stack view 到 super view bottom 的 constraints 會變成鍵盤高度。新增一個顯示鍵盤的方法,先得到鍵盤的 size。如果能得到鍵盤高度,那就把到 super view bottom 的限制常數改為鍵盤高度。由於 text view 原本有對齊 stack view 底部,所以也會跟著往上移動。
@objc func keyboardWillShow(notification: NSNotification) {
keyboardSize = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue
guard !keyboardIsShowed else {return}
if let height = keyboardSize?.height {
inputStackViewBottom.constant = height
}

keyboardIsShowed = true
}
  • 另建一個鍵盤關閉的方法,把到 super view bottom 的距離設定成原本的常數就可以了。
@objc func keyboardWillHide(notification: NSNotification) {
inputStackViewBottom.constant = 44
keyboardIsShowed = false
}

輸入框高度改變

  • 由於使用的是 text view,所以只要取消勾選 Scrolling Enabled 就可以實現了。

顯示訊息

  • 先宣告相關屬性。由於這邊簡單設計一個學人精 robot,所以除了紀錄聊天內容外,另外紀錄一個型態 [Bool] 的屬性觀察是狗還是人留的言。
//messages
static var messages = [String]() //儲存聊天訊息
var isMen = [Bool]() //紀錄對話的順序
  • 建立一個發送訊息的方法,由於對方是個學人精,所以重複存取一樣的訊息一次。重點是發送訊息要 reload tabel view 讓新的訊息顯示出來。
@IBAction func sendMessage(_ sender: Any) {
for _ in 0...1 {
ChattingViewController.messages.append(inputTextView.text)
}
inputTextView.text = ""
isMen.append(true)
isMen.append(false)
chattingTableView.reloadData()
view.endEditing(true)
}
  • 由於不是用 Table View Controller 所以要主動幫 table view 指派 delegate & data source。
  • 讓 view controller 遵從 UITableViewDelegate 跟 UITableViewDataSource 後,回傳訊息紀錄的總數為 row 的數量。
extension ChattingViewController: UITableViewDelegate, UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return ChattingViewController.messages.count
}
}
  • 將兩個 cell 分別取名稱
  • 新增一個 UITableViewCell 的 file ,將兩個 cell 都繼承這個物件後,把需要改變資料的元件連進來。在下面另外各創造一人一狗的資料更新。
class MessageTableViewCell: UITableViewCell {    @IBOutlet weak var messageTextView: UITextView!
@IBOutlet weak var headImageView: UIImageView!

@IBOutlet weak var head2ImageView: UIImageView!
@IBOutlet weak var message2TextView: UITextView!

override func awakeFromNib() {
super.awakeFromNib()
// Initialization code
}
override func setSelected(_ selected: Bool, animated: Bool) {
super.setSelected(selected, animated: animated)
// Configure the view for the selected state
}



func updateMan() {
head2ImageView.layer.cornerRadius = head2ImageView.bounds.width/2
message2TextView.layer.cornerRadius = 4
message2TextView.textContainerInset = UIEdgeInsets(top: 10, left: 5, bottom: 10, right: 5)
head2ImageView.image = UIImage(named: "man")
}

func updateDog(){
headImageView.layer.cornerRadius = headImageView.bounds.width/2
messageTextView.layer.cornerRadius = 4
messageTextView.textContainerInset = UIEdgeInsets(top: 10, left: 5, bottom: 10, right: 5)
headImageView.image = UIImage(named: "dog")
}
}
  • 回到 view controller 呼叫參數有 cellForRowAt 的方法製造 cell,裡用自己宣告的 isMen 來判斷要用什麼名稱的 cell。
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

if isMen[indexPath.row] {
ChattingViewController.reuseIdentifier = "MessageTableViewCell2"
} else {
ChattingViewController.reuseIdentifier = "\(MessageTableViewCell.self)"
}


return cell
}
  • 使用 table view 的 dequeueReusableCell 製造 cell
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

if isMen[indexPath.row] {
ChattingViewController.reuseIdentifier = "MessageTableViewCell2"
} else {
ChattingViewController.reuseIdentifier = "\(MessageTableViewCell.self)"
}

guard let cell = tableView.dequeueReusableCell(withIdentifier: ChattingViewController.reuseIdentifier, for: indexPath) as? MessageTableViewCell else { return MessageTableViewCell() }

return cell
}
  • 判斷要更新狗還是人的資訊
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

if isMen[indexPath.row] {
ChattingViewController.reuseIdentifier = "MessageTableViewCell2"
} else {
ChattingViewController.reuseIdentifier = "\(MessageTableViewCell.self)"
}

guard let cell = tableView.dequeueReusableCell(withIdentifier: ChattingViewController.reuseIdentifier, for: indexPath) as? MessageTableViewCell else { return MessageTableViewCell() }

if isMen[indexPath.row] {
cell.updateMan()
} else {
cell.updateDog()
}


return cell
}
  • 先檢查對話是否存在,有的話使用 indexPath.row 將那個欄位的訊息傳入對話框裡
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

if isMen[indexPath.row] {
ChattingViewController.reuseIdentifier = "MessageTableViewCell2"
} else {
ChattingViewController.reuseIdentifier = "\(MessageTableViewCell.self)"
}

guard let cell = tableView.dequeueReusableCell(withIdentifier: ChattingViewController.reuseIdentifier, for: indexPath) as? MessageTableViewCell else { return MessageTableViewCell() }

if isMen[indexPath.row] {
cell.updateMan()
} else {
cell.updateDog()
}

guard !ChattingViewController.messages.isEmpty else {return cell}
if isMen[indexPath.row] {
cell.message2TextView.text = ChattingViewController.messages[indexPath.row]
} else {
cell.messageTextView.text = ChattingViewController.messages[indexPath.row]
}

return cell
}

收鍵盤

一個是傳送訊息後收回,一個是按螢幕及收回。

傳送訊息後收回

  • 在發送訊息的方法裡加入 view.endEditing(true) 即可。
@IBAction func sendMessage(_ sender: Any) {
for _ in 0...1 {
ChattingViewController.messages.append(inputTextView.text)
}
inputTextView.text = ""
isMen.append(true)
isMen.append(false)
chattingTableView.reloadData()
view.endEditing(true)
}

按 cell 收鍵盤

呼叫參數有 didSelectRowAt 的方法在裡面寫 view.endEditing(true) 即可。但這有個缺點,如果剛開始沒對話內容,可能會沒有 cell 可以按。

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
view.endEditing(true)
}

使用 Tap Gesture Recognizer

  • 在 table view 上加入 Tap Gesture Recognizer ,創建一個功能在裡面寫 view.endEditing(true) 即可。
@IBAction func dismissKeyboard(_ sender: UITapGestureRecognizer) {
view.endEditing(true)
}

--

--