UI
iOS
Swift

LINEのようなチャットUIをつくる

この記事はSwift愛好会 Advent Calendar 2017の15日目のものです!

チャット画面はチャットアプリだけではなくSNSのDM画面や、カスタマーサポートの画面など、様々なところで利用されることがあると思います。
今回はそのチャット画面の実装のサンプルをご紹介します。
間違っているところやベストプラクティスでない場合がありますので、
その際は是非コメントにお願い致します!

見た目

image.png

ソースコード

以下の通りに作成しても、ナビゲーションバーはつきませんのでご了承ください⚠

ChatRoomViewController.swift
import UIKit

class ChatRoomViewController: UIViewController {

    @IBOutlet weak var tableView: UITableView!

    var bottomView: ChatRoomInputView! //画面下部に表示するテキストフィールドと送信ボタン

    var chats: [ChatEntity] = []

    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
    }

    override var canBecomeFirstResponder: Bool {
        return true
    }

    override var inputAccessoryView: UIView? {
        return bottomView //通常はテキストフィールドのプロパティに設定しますが、画面を表示している間は常に表示したいため、ViewControllerのプロパティに設定します
    }
}

extension ChatRoomViewController {
    func setupUI() {
        self.view.backgroundColor = UIColor(red: 113/255, green: 148/255, blue: 194/255, alpha: 1)
        tableView.backgroundColor = UIColor(red: 113/255, green: 148/255, blue: 194/255, alpha: 1)

        tableView.separatorColor = UIColor.clear // セルを区切る線を見えなくする
        tableView.estimatedRowHeight = 10000 // セルが高さ以上になった場合バインバインという動きをするが、それを防ぐために大きな値を設定
        tableView.rowHeight = UITableViewAutomaticDimension // Contentに合わせたセルの高さに設定
        tableView.allowsSelection = false // 選択を不可にする
        tableView.keyboardDismissMode = .interactive // テーブルビューをキーボードをまたぐように下にスワイプした時にキーボードを閉じる

        tableView.register(UINib(nibName: "YourChatViewCell", bundle: nil), forCellReuseIdentifier: "YourChat")
        tableView.register(UINib(nibName: "MyChatViewCell", bundle: nil), forCellReuseIdentifier: "MyChat")

        self.bottomView = ChatRoomInputView(frame: CGRect(x: 0, y: 0, width: self.view.frame.width, height: 70)) // 下部に表示するテキストフィールドを設定
        let chat1 = ChatEntity(text: "text1", time: "10:01", userType: .I)
        let chat2 = ChatEntity(text: "text2", time: "10:02", userType: .You)
        let chat3 = ChatEntity(text: "text3", time: "10:03", userType: .I)
        chats = [chat1, chat2, chat3]

    }
}

extension ChatRoomViewController: UITableViewDataSource {
    func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }

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

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let chat = self.chats[indexPath.row]
        if chat.isMyChat() {
            let cell = tableView.dequeueReusableCell(withIdentifier: "MyChat") as! MyChatViewCell
            cell.clipsToBounds = true //bound外のものを表示しない
            // Todo: isRead
            cell.updateCell(text: chat.text, time: chat.time, isRead: true)
            return cell
        } else {
            let cell = tableView.dequeueReusableCell(withIdentifier: "YourChat") as! YourChatViewCell
            cell.clipsToBounds = true
            cell.updateCell(text: chat.text, time: chat.time)
            return cell
        }
    }
}

extension ChatRoomViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        print(indexPath)
    }

    func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
        return 10
    }
}

ChatRoomInputView.swift
import UIKit

class ChatRoomInputView: UIView {

    @IBOutlet weak var chatTextField: UITextField!
    @IBOutlet weak var sendButton: UIButton!

    override init(frame: CGRect) {
        super.init(frame: frame)
        self.setFromXib()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        self.setFromXib()
    }

    func setFromXib() {
        let nib = UINib.init(nibName: "ChatRoomInputView", bundle: nil)
        let view = nib.instantiate(withOwner: self, options: nil).first as! UIView
        self.addSubview(view)// Storyboardから読み込んだレイアウトでビューを重ねて表示
    }
}

ChatEntity.swift
import Foundation
import UIKit

public class ChatEntity {
    var text = ""
    var time = ""
    var userType: UserType

    public init(text: String, time: String, userType: UserType) {
        self.text = text
        self.time = time
        self.userType = userType
    }

    public func isMyChat() -> Bool {
        return userType == .I
    }
}

public enum UserType {
    case I
    case You
}
MyChatViewCell.swift
import UIKit

class MyChatViewCell: UITableViewCell {

    @IBOutlet weak var textView: UITextView!
    @IBOutlet weak var timeLabel: UILabel!
    @IBOutlet weak var readLabel: UILabel!

    @IBOutlet weak var textViewWidthConstraint: NSLayoutConstraint!

    override func awakeFromNib() {
        super.awakeFromNib()
        self.backgroundColor = UIColor.clear
        self.textView.layer.cornerRadius = 15 // 角を丸める
        addSubview(MyBalloonView(frame: CGRect(x: Int(frame.size.width+30), y: 0, width: 30, height: 30))) //吹き出しのようにするためにビューを重ねる
    }

    override func setSelected(_ selected: Bool, animated: Bool) {
        super.setSelected(selected, animated: animated)
    }
}

extension MyChatViewCell {
    func updateCell(text: String, time: String, isRead: Bool) {
        self.textView?.text = text
        self.timeLabel?.text = time
        self.readLabel?.isHidden = !isRead

        let frame = CGSize(width: self.frame.width - 8, height: CGFloat.greatestFiniteMagnitude)
        var rect = self.textView.sizeThatFits(frame)
        if(rect.width<30){
            rect.width=30
        }
        textViewWidthConstraint.constant = rect.width//テキストが短くても最小のビューの幅を30とする
    }
}
YourChatViewCell.swift
import UIKit

class YourChatViewCell: UITableViewCell {

    @IBOutlet weak var textView: UITextView!
    @IBOutlet weak var timeLabel: UILabel!
    @IBOutlet weak var textViewWidthConstraint: NSLayoutConstraint!

    override func awakeFromNib() {
        super.awakeFromNib()
        self.backgroundColor = UIColor.clear
        self.textView.layer.cornerRadius = 15// 角を丸める
        self.addSubview(YourBalloonView(frame: CGRect(x: textView.frame.minX-10, y: textView.frame.minY-10, width: 50, height: 50)))//吹き出しのようにするためにビューを重ねる
    }

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

}

extension YourChatViewCell {
    func updateCell(text: String, time: String) {
        self.textView?.text = text
        self.timeLabel?.text = time

        let frame = CGSize(width: self.frame.width, height: CGFloat.greatestFiniteMagnitude)
        var rect = self.textView.sizeThatFits(frame)
        if(rect.width<30){
            rect.width=30
        }
        textViewWidthConstraint.constant = rect.width//テキストが短くても最小のビューの幅を30とする
    }
}


MyBalloonView.swift
import UIKit

class MyBalloonView: UIView {

    override init(frame: CGRect) {
        super.init(frame: frame)
        self.backgroundColor = UIColor.clear
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }

    override func draw(_ rect: CGRect) {
        let line = UIBezierPath()//吹き出しの口部分を描画
        UIColor(red: 133/255, green: 226/255, blue: 73/255, alpha: 1.0).setFill()
        UIColor.clear.setStroke()
        line.move(to: CGPoint(x: 0, y: 10))
        line.addQuadCurve(to: CGPoint(x: 20, y: 0), controlPoint: CGPoint(x: 10, y: 20))
        line.addQuadCurve(to: CGPoint(x: 5, y: 20), controlPoint: CGPoint(x: 13, y: 30))
        line.close()
        line.fill()
        line.stroke()
    }
}

YourBalloonView.swift
import UIKit

class YourBalloonView: UIView {

    override init(frame: CGRect) {
        super.init(frame: frame)
        self.backgroundColor = UIColor.clear
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }

    override func draw(_ rect: CGRect) {
        let line = UIBezierPath()//吹き出しの口部分を描画
        UIColor.white.setFill()
        UIColor.clear.setStroke()
        line.move(to: CGPoint(x: 20, y: 15))
        line.addQuadCurve(to: CGPoint(x: 5, y: 5), controlPoint: CGPoint(x: 5, y: 10))
        line.addQuadCurve(to: CGPoint(x: 10, y: 25), controlPoint: CGPoint(x: 0, y: 10))
        line.close()
        line.fill()
        line.stroke()
    }
}

Xib

MyChatViewCell.xib

ポイント
セルのCustom ClassにMyChatViewCellを設定します
image.png

YourChatViewCell.xib

セルのCustom ClassにYourChatViewCellを設定します
image.png

ChatRoomInputView.xib

image.png

おわりに

長くなってしまいましたが、このようLINEのようなUIを作ることができます。
また、今回のソースコードはリポジトリとしてGitHubにあげています
Swift愛好会に参加した事があるのは1回だけですがノリと勢いでアドベントカレンダーに参加してしまいました笑
できるだけ恥ずかしくないようにテーマをしっかりしたものに設定したつもりでしたが、
趣旨とそれてしまっていたらすみません🙌🏻
Xibのレイアウトについてはスクリーンショットで伝わるようにしたつもりですが、
もし不明な点があればぜひ聞いてください!

ちなみに、LINEの再現のプロジェクトを個人的に進めています(大したものではありませんが)
バグは結構ありますが、現状ではリアルタイムでのチャットとビデオ通話ができるようになっています。
もし興味がある方が居たらこちらもご覧ください!
(今回のソースに出ていなかったNavigationBarもこちらでは実装しています!)

最後までお読み頂きありがとうございました!!

明日16日目は@gotou015さんです!
よろしくお願いします😆