#20 自己與自己對話之-聊天對話畫面以及功能製作

夜深人靜,找不到人陪你聊天嘛?沒關係,你可以自己跟自己聊天。

EJ Lo
彼得潘的 Swift iOS / Flutter App 開發教室
24 min readJan 31, 2023

--

在現今的APP裡面,對話功能是不可以少的。幾乎大部分社交軟體都有這樣的功能,今天就來實踐聊天APP的畫面以及功能吧!首先需求部分如下:

  1. 要可以對話 — 因此使用tabBarController來製造兩個分頁,分別是男跟女。
  2. 必須保留對話 — 這邊其實用FileManager儲存對話比較適當,但因為很久沒練習Json跟串接API功能,因此使用了airTable來儲存對話。
  3. 畫面必須順暢 — 平常用line習慣,都沒特別注意,這次特別模擬line的一些畫面,尤其是對話框以及打字框的功能

首先看看StoryBoard畫面:

上面是男生,下面是女生,而男生用填滿的人當圖片,女生則是用空心的人當圖片。

首先拉兩個ViewController,個別拉一個TableView + 兩個TableViewCell,Cell裡面放入一張圖片跟一個Label,以及拉一個View裡面放入一個TextView跟button。

基本上AutoLayout我只有拉Cell裡面的人跟文字的位置,記得Label要跟旁變得邊界設定≥來確保字變多後Label長度,以及圖片還有Label跟下面也要用≥確保Label只有一行以及變多行之後圖片仍然保持在同一個位置。

而下面TextView的部分也是把scrollEnable取消,來確定對話框會隨著越來越多字變大,以及按鈕一樣要固定位置。

程式碼 — Model:

這邊很簡單,就是名字跟對話內容,因為是Fetch AirTable的資料,對話要照時間排,因此設定一個時間,PS.時間格式後面會說到。

import Foundation

struct ChatRoomModel: Codable{
var records:[Record]
struct Record: Codable {
let fields : Fields
}
struct Fields: Codable{
var name: String
var content: String
var sendTimeString: String
}
}

struct ChatContentModel {
var name: String = ""
var content: String = ""
var sendTime: Date = Date.now
}

程式碼 — DataManager:

基本上只需要上傳跟下載對話資料即可,直接使用兩個static function,非常方便。

import Foundation


struct FetchData{
private static var apiKey : String = "Bearer YourAPIKey"
private static var apiHeaderField : String = "Authorization"
private static var apiKeyOfPost : String = "application/json"
private static var apiHeaderFieldOfPost : String = "Content-Type"
private static var chatUrl : String = "https://api.airtable.com/YourURL"


//下載對話資料
static func fetchChatContent(completion: @escaping (Result<[ChatRoomModel.Record], Error>) -> Void){
let url = URL(string: Self.chatUrl)!
var request = URLRequest(url: url)
request.setValue(Self.apiKey, forHTTPHeaderField: Self.apiHeaderField)
URLSession.shared.dataTask(with: request) { data, response, error in
if let data = data {
do{
let decorder = JSONDecoder()
let contentResponse = try decorder.decode(ChatRoomModel.self, from: data)
completion(.success(contentResponse.records))
}catch{
completion(.failure(error))
}
}
}.resume()
}

//上傳對話資料
static func uploadChatContent(chatContent: ChatContentModel ,completion: @escaping(Result<String,Error>) -> Void){
let sendTimeStr = dateToString(chatContent.sendTime)
let chatContent = ChatRoomModel.Record(fields: ChatRoomModel.Fields(name: chatContent.name, content: chatContent.content, sendTimeString: sendTimeStr))
let url = URL(string: Self.chatUrl)!
var request = URLRequest(url: url)
request.setValue(Self.apiKey, forHTTPHeaderField: Self.apiHeaderField)
request.setValue(Self.apiKeyOfPost, forHTTPHeaderField: Self.apiHeaderFieldOfPost)
request.httpMethod = "POST"
do {
let encoder = JSONEncoder()
let data = try encoder.encode(chatContent)
URLSession.shared.uploadTask(with: request, from: data) { data, response, error in
if let response = response as? HTTPURLResponse, (200...299).contains(response.statusCode) {
completion(.success("成功"))
}
}.resume()
}catch{
completion(.failure(error))
}
}

}

程式碼 — Extension跟Public Function:

extension 只有讓對話Label改變一下字的位置,如果都沒改會變得很醜,然後要記得去StoryBoard把Label的Class改成PaddingLabel。

另外就是時間格式,光是處理格式耗費半天,airTable的時間格式是String,而且秒數後面會多.000Z,一開始根本不知道怎麼從Data轉成這樣子,後來才查到format 是 “yyyy-MM-dd’T’HH:mm:ss.SSS’Z’”,上傳的時候要轉換成String,下載回來要轉回Date,因此做了兩個轉換的function。

import Foundation
import UIKit


class PaddingLabel : UILabel{
override func drawText(in rect: CGRect) {
let padding = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
super.drawText(in: rect.inset(by: padding))
}
override var intrinsicContentSize: CGSize {
let superContent = super.intrinsicContentSize
let width = superContent.width + 20
let height = superContent.height + 20
return CGSize(width: width, height: height)
}
}

public func dateToString(_ date: Date) -> String{
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"
dateFormatter.timeZone = TimeZone(secondsFromGMT: 8)
let dateString = dateFormatter.string(from: date)
return dateString
}

public func stringToDate(_ dateStr: String) -> Date{
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"
dateFormatter.timeZone = TimeZone(secondsFromGMT: 8)
let date = dateFormatter.date(from: dateStr)!
return date
}

程式碼 — TableViewCell:

基本上男生跟女生TableViewCell內容一樣,因此只放男生,其實四個Cell似乎可以共用,但避免搞混,我還是使用男生Cell跟女生Cell,然後男生跟女生Controller的男生Cell共用,反之兩個Controller的女生Cell共用。

import UIKit

class MaleTableViewCell: UITableViewCell {

@IBOutlet weak var maleImage: UIImageView!{
didSet{
maleImage.layer.borderWidth = 1
maleImage.layer.cornerRadius = 25
maleImage.layer.borderColor = UIColor.darkGray.cgColor
maleImage.layer.masksToBounds = true
}
}

@IBOutlet weak var maleContent: UILabel!

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
}

}

程式碼 — Controller:

其實兩個Controller幾乎一模一樣,除了名稱,另外為了測試不同功能,做了不一樣的小改變。

首先為了解決鍵盤彈出不要遮住TextView,我TableView以及裝載TextView跟Button的View(之後簡稱ViewOfText),我是用寫程式的方式AutoLayout(這方法比在 StoryBoard 拉方便也更直覺),先把各自的translatesAutoresizingMaskIntoConstraints設成false,然後讓TableView的上、左、右anchor等於safeArea,下方Anchor等於ViewOfText的top,而ViewOfText的左右一樣等於safeArea,關鍵在TextOfView下方Anchor要等於KeyBoardLayout的上方,以及TextOfView上方等於TableView的下方。

其他畫面顏色設定的部分略過,但值得注意,一般我們的對話都是由下而上出現,因此這邊我把TableView做一個180度反轉,同樣在Cell的部分也是。

接下來就是按按鈕會把打字送出,因此把內容做一個判斷,如果是empty就return(因為如果送出,airTable在下載的時候是nil,會死當,除非content設成optional),上傳好之後,原本設計會在下載一次資料來顯示新對話,但發現太慢,因此改成用insert在最前面的方式,然後設定動畫為從top出現(因為180度反轉)。

接下來Cell就去判斷是男生說話還是女生說話,顯示不同的對話內容,這部分簡單。

最困難的是TextView,要慢慢增大,又不至於遮住畫面,因此在最下面extension Controller的部分增加TextView的Delegate,一開始使用shouldChangeTextIn的function,來判斷換行,後來發現用TextViewDidChange來判斷變化更方便,接下來就是計算高度,大於160 point就可以開使捲動,記得translatesAutoresizingMaskIntoConstraints 也要一起開啟跟關閉。

import UIKit

class MaleChatViewController: UIViewController {

var chatRecords : [ChatRoomModel.Record] = []
let name : String = "Male"
var content : String = ""
@IBOutlet weak var maleChatTableView: UITableView!
@IBOutlet weak var viewOfTextView: UIView!

func addConstraints(){
maleChatTableView.translatesAutoresizingMaskIntoConstraints = false
viewOfTextView.translatesAutoresizingMaskIntoConstraints = false
maleChatTableView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor).isActive = true
maleChatTableView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor).isActive = true
maleChatTableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true
maleChatTableView.bottomAnchor.constraint(equalTo: viewOfTextView.topAnchor).isActive = true
viewOfTextView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor).isActive = true
viewOfTextView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor).isActive = true
viewOfTextView.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor).isActive = true
viewOfTextView.topAnchor.constraint(equalTo: maleChatTableView.bottomAnchor).isActive = true

}

@IBAction func closeKeyBoard(_ sender: Any) {
view.endEditing(true)
}

@IBOutlet weak var contentTextView: UITextView!{
didSet{
contentTextView.layer.masksToBounds = true
contentTextView.layer.cornerRadius = 10
contentTextView.layer.borderWidth = 1
contentTextView.layer.borderColor = UIColor.secondaryLabel.cgColor
contentTextView.textContainerInset = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5)
}
}

@IBOutlet weak var enterButton: UIButton!

@IBAction func enterButtonAction(_ sender: UIButton) {
content = contentTextView.text
guard !content.isEmpty else { return }
let contentModel = ChatContentModel(name: name, content: content)
FetchData.uploadChatContent(chatContent: contentModel) { result in
switch result {
case .success(_):
print("成功")
case .failure(let error):
print(error)
}
}
contentTextView.text = ""
let dateStr = dateToString(Date.now)
chatRecords.insert(.init(fields: .init(name: name, content: content, sendTimeString: dateStr)), at: 0)
maleChatTableView.insertRows(at: [[0,0]], with: .top)

}

func updateUI(with chatRecord: [ChatRoomModel.Record]){
DispatchQueue.main.async { [self] in
chatRecords = chatRecord
chatRecords.sort{ stringToDate($0.fields.sendTimeString) > stringToDate($1.fields.sendTimeString) }
maleChatTableView.reloadData()
}
}

override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
FetchData.fetchChatContent { result in
switch result{
case .success(let chatRecords):
self.updateUI(with: chatRecords)
case .failure(let error):
print(error)
}
}
}

override func viewDidLoad() {
FetchData.fetchChatContent { result in
switch result {
case .success(let records):
self.updateUI(with: records)
case .failure(let error):
print(error)
}
}
contentTextView.delegate = self
maleChatTableView.backgroundColor = .tertiaryLabel
maleChatTableView.transform = CGAffineTransform(rotationAngle: .pi)
addConstraints()
super.viewDidLoad()
}

}

extension MaleChatViewController: UITableViewDelegate, UITableViewDataSource {

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return chatRecords.count
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let identifier = chatRecords[indexPath.row].fields.name == name ? "maleCellOfMale" : "femaleCellOfMale"
if identifier == "maleCellOfMale" {
let cell = tableView.dequeueReusableCell(withIdentifier: identifier, for: indexPath) as! MaleTableViewCell
cell.backgroundColor = .clear
cell.maleContent.text = chatRecords[indexPath.row].fields.content
cell.maleContent.layer.cornerRadius = 10
cell.maleContent.layer.borderColor = UIColor.secondaryLabel.cgColor
cell.maleContent.layer.masksToBounds = true
cell.maleContent.layer.borderWidth = 1
cell.maleContent.backgroundColor = .cyan
cell.transform = CGAffineTransform(rotationAngle: .pi)
return cell
} else {
let cell = tableView.dequeueReusableCell(withIdentifier: identifier, for: indexPath) as! FemaleTableViewCell
cell.backgroundColor = .clear
cell.femaleContent.text = chatRecords[indexPath.row].fields.content
cell.femaleContent.layer.cornerRadius = 10
cell.femaleContent.layer.borderColor = UIColor.secondaryLabel.cgColor
cell.femaleContent.layer.borderWidth = 1
cell.femaleContent.layer.masksToBounds = true
cell.femaleContent.backgroundColor = .white
cell.transform = CGAffineTransform(rotationAngle: .pi)
return cell
}
}

}

extension MaleChatViewController : UITextViewDelegate {

func textViewDidChange(_ textView: UITextView) {
let height = textView.contentSize.height
textView.translatesAutoresizingMaskIntoConstraints = height >= 160
textView.isScrollEnabled = height >= 160
}

}

程式碼 — Controller不一樣的地方:

因為這次TableView沒有轉向,因此排序要跟男生的相反,位置也相反,另外為了一直在底部,因此會使用一個功能scrollToRow,並且使用在只要有任何變化都會觸發,尤其是最下面的呼叫鍵盤部分,需要等待0.01秒左右觸發。

    @IBAction func contentButtonAction(_ sender: UIButton) {

chatRecords.insert(.init(fields: .init(name: name, content: content, sendTimeString: dateStr)), at: chatRecords.count)
femaleChatTableView.insertRows(at: [[0,chatRecords.count-1]], with: .bottom)
femaleChatTableView.scrollToRow(at: [0,chatRecords.count-1], at: .bottom, animated: true)
}

func updateUI(with chatRecord: [ChatRoomModel.Record]){
DispatchQueue.main.async { [self] in
chatRecords = chatRecord
chatRecords.sort{ stringToDate($0.fields.sendTimeString) < stringToDate($1.fields.sendTimeString) }
femaleChatTableView.reloadData()
femaleChatTableView.scrollToRow(at: [0,chatRecords.count-1], at: .bottom, animated: false)
}
}

func textViewDidBeginEditing(_ textView: UITextView) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { [self] in
femaleChatTableView.scrollToRow(at: [0,chatRecords.count-1], at: .bottom, animated: true)
}
}

畫面GIF:

可以看到,因為男生反轉TableView的關係,最一開始對話從下面開始,而女生是從上面開始。

這邊可以看到,對話一旦變多,男女都一樣從下面出現。

這邊測試多行也沒問題:

這邊測試對話打字,超過一定行數就可以開使捲動,delete到一定行數也會開始縮小。

另外可以看到,反轉的TableView在呼叫鍵盤的時候畫面較順暢,如果是用scrollToRow的方法,還是會有一點卡卡的。

GitHub: https://github.com/EJLO0805/ChatRoomPractice

--

--