見出し画像

iOSの MessageKit によるチャットUIの作成

iOSの「MessageKit」によるチャットUIの作成を試したので、まとめました。

・Xcode 14
・iOS 16

1. MessageKit

「MessageKit」は、iOSでチャットUIを簡単に作成できるパッケージです。

2. MessageType

MessageType」は、「MessageKit」のメッセージを定義するデータ型です。
次の4つのプロパティを持っています。

var sender: SenderType : 送信者
var kind: MessageKind 
: メッセージ種別
var messageId: String : メッセージID
var sentDate: Date 
: 送信日

2-1. SenderType

SenderType」は、送信者を定義するデータ型です。
次の2つのプロパティを持っています。

var senderId: String : 送信者ID
var displayName: String 
: 表示名

2-2. MessageKind

「MessageKind」は、メッセージ種別を定義するデータ型数です。
次の8種類のメッセージ種別があります。

・text (String) : 属性なしテキスト
・attributedText (NSAttributedString) : 属性ありテキスト
・emoji (String) : 絵文字テキスト
・photo (MediaItem) : 写真メッセージ
・video (MediaItem) : ビデオメッセージ
・location (LocationItem) : ロケーションメッセージ
・audio (AudioItem) : 音声メッセージ
・contact (ContactItem) : 連絡先メッセージ

3. MessagesViewController

「MessagesViewController」は、「MessageKit」のUIを表示するビューコントローラです。
「MessageKit」を利用するには、「MessagesViewController」のサブクラスで、次の4つのプロトコルを実装する必要があります。

・MessagesDataSource : データソース
・MessagesLayoutDelegate
: レイアウト
・MessagesDisplayDelegate
: ビュー
・InputBarAccessoryViewDelegate : 入力バー

class ChatViewController: MessagesViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        // messagesCollectionView
        messagesCollectionView.backgroundColor = UIColor.secondarySystemBackground
        messagesCollectionView.messagesDataSource = self
        messagesCollectionView.messagesLayoutDelegate = self
        messagesCollectionView.messagesDisplayDelegate = self
        
        // messageInputBar
        messageInputBar.delegate = self
        messageInputBar.sendButton.title = nil
        messageInputBar.sendButton.image = UIImage(systemName: "paperplane")
    }
}

3-1. MessagesDataSource

MessagesDataSource」は、「MessagesCollectionView」のデータソースを設定するためのプロトコルです。
実装必須のメソッドは、次の3つです。

public struct Sender: SenderType {
    public let senderId: String
    public let displayName: String
}

// 例のためにグローバル変数を使っています
let sender = Sender(senderId: "any_unique_id", displayName: "Steven")
let messages: [MessageType] = []

extension ChatViewController: MessagesDataSource {
    // 現在の送信者
    var currentSender: SenderType {
        return Sender(senderId: "any_unique_id", displayName: "Steven")
    }

    // メッセージ数
    func numberOfSections(in messagesCollectionView: MessagesCollectionView) -> Int {
        return messages.count
    }

    // IndexPathに応じたメッセージ
    func messageForItem(at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> MessageType {
        return messages[indexPath.section]
    }
}

messageForItem()で、従来のindexPath.rowではなく、indexPath.sectionでメッセージ (MessageType) を取得しています。これは、「MessageKit」ではMessageTypeをMessagesCollectionViewの独自セクションに配置するためです。

「MessagesDataSource」のメソッド一覧は、次のとおりです。

var currentSender: SenderType { get }
現在の送信者 (必須)

func messageForItem(at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> MessageType
IndexPathに応じたメッセージ (必須)

func numberOfSections(in messagesCollectionView: MessagesCollectionView) -> Int
MessagesCollectionViewに表示されるセクション数 (必須)

func numberOfItems(inSection section: Int, in messagesCollectionView: MessagesCollectionView) -> Int
MessagesCollectionViewに表示されるセル数

func messageTopLabelAttributedText(for message: MessageType, at indexPath: IndexPath) -> NSAttributedString?
messageTopLabelの属性テキスト

func messageBottomLabelAttributedText(メッセージ用: MessageType, at indexPath: IndexPath) -> NSAttributedString?
messageBottomLabelの属性テキスト

func cellTopLabelAttributedText(メッセージ用: MessageType, at indexPath: IndexPath) -> NSAttributedString?
cellTopLabelの属性テキスト

func cellBottomLabelAttributedText(メッセージ用: MessageType, at indexPath: IndexPath) -> NSAttributedString?
cellBottomLabelの属性テキスト

func messageTimestampLabelAttributedText(for message: MessageType, at indexPath: IndexPath) -> NSAttributedString?
messageTimestampLabelの属性テキスト

func textCell(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> UICollectionViewCell?
「text」「attributedText」「emoji」のメッセージセル

func photoCell(for message: MessageType、indexPath: IndexPath、messagesCollectionView: MessagesCollectionView) -> UICollectionViewCell?
「photo」「video」のメッセージセル

func locationCell(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> UICollectionViewCell?
「location」のメッセージセル

func audioCell(メッセージ用: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> UICollectionViewCell?
「audio」のメッセージセル

func contactCell(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> UICollectionViewCell?
「contact」のメッセージセル

func customCell(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> UICollectionViewCell
「custom」のメッセージセル

func typingIndicator(at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> UICollectionViewCell
タイピングインジケータセル

func isFromCurrentSender(message: MessageType) -> Bool
メッセージが現在の送信者のものであるかどうかを判定

デフォルトセル (MessageContentCell) のUI構成は、次のとおりです。

上から順に、次のパーツが配置されてます。

・cellTopLabel
・messageTopLabel
・messageContainerView
・messageBottomLabel
・cellBottomLabel

そして両側に、次のパーツが配置されてます。

・avatarView
・accessoriesView

3-2. MessagesLayoutDelegate

MessagesLayoutDelegate」は、「MessagesCollectionView」のレイアウトを設定するためのプロトコルです。
実装必須のメソッドはありません。

「MessagesLayoutDelegate」のメソッド一覧は、次のとおりです。

func headerViewSize(for section: Int, in messagesCollectionView: MessagesCollectionView) -> CGSize
ヘッダービューのサイズ

func footerViewSize(for section: Int, in messagesCollectionView: MessagesCollectionView) -> CGSize
フッタービューのサイズ

func typingIndicatorViewSize(for layout: MessagesCollectionViewFlowLayout) -> CGSize
タイピングインジケータビューのサイズ

func typingIndicatorViewTopInset(in messagesCollectionView: MessagesCollectionView) -> CGFloat
タイピングインジケータビューのトップインセット

func cellTopLabelHeight(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> CGFloat
messageTopLabelの高さ

func cellBottomLabelHeight(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> CGFloat
messageBottomLabelの高さ

func messageTopLabelHeight(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> CGFloat
messageTopLabelのの高さ

func messageTopLabelAlignment(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> LabelAlignment?
messageTopLabelの配置

func messageBottomLabelHeight(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> CGFloat
messageBottomLabelのの高さ

func messageBottomLabelAlignment(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> LabelAlignment?
messageBottomLabelの配置

func avatarSize(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> CGSize?
avatarViewのサイズ

func textCellSizeCalculator(for _: MessageType, at _: IndexPath, in _: MessagesCollectionView) -> CellSizeCalculator?
「text」のメッセージセルのサイズカリキュレータ

func attributedTextCellSizeCalculator(for _: MessageType, at _: IndexPath, in _: MessagesCollectionView) -> CellSizeCalculator?
「attributedText」のメッセージセルのサイズカリキュレータ

func emojiCellSizeCalculator(for _: MessageType, at _: IndexPath, in _: MessagesCollectionView) -> CellSizeCalculator?
「emoji」のメッセージセルのサイズカリキュレータ

func photoCellSizeCalculator(for _: MessageType, at _: IndexPath, in _: MessagesCollectionView) -> CellSizeCalculator?
「photo」のメッセージセルのサイズカリキュレータ

func videoCellSizeCalculator(for _: MessageType, at _: IndexPath, in _: MessagesCollectionView) -> CellSizeCalculator?
「video」のメッセージセルのサイズカリキュレータ

func locationCellSizeCalculator(for _: MessageType, at _: IndexPath, in _: MessagesCollectionView) -> CellSizeCalculator?
「location」のメッセージセルのサイズカリキュレータ

func audioCellSizeCalculator(for _: MessageType, at _: IndexPath, in _: MessagesCollectionView) -> CellSizeCalculator?
「audio」のメッセージセルのサイズカリキュレータ

func contactCellSizeCalculator(for _: MessageType, at _: IndexPath, in _: MessagesCollectionView) -> CellSizeCalculator?
「contact」のメッセージセルのサイズカリキュレータ

func customCellSizeCalculator(for _: MessageType, at _: IndexPath, in _: MessagesCollectionView) -> CellSizeCalculator
「custom」のメッセージセルのサイズカリキュレータ

3-3. MessagesDisplayDelegate

MessagesDisplayDelegate」は、「MessagesCollectionView」のビューを設定するためのプロトコルです。
実装必須のメソッドはありません。

「MessagesDisplayDelegate」のメソッド一覧は、次のとおりです。

func messageStyle(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> MessageStyle
メッセージの背景スタイル (MessageStyle)

func backgroundColor(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> UIColor
MessagesCollectionViewの背景色

func messageHeaderView(for indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> MessageReusableView
IndexPathに応じたセクションヘッダ

func messageFooterView(for indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> MessageReusableView
IndexPathに応じたセクションフッタ

func configureAvatarView(_ avatarView: AvatarView, for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView)
AvatarView

func configureAccessoryView(_ accessoryView: UIView, for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView)
AccessoryView

func textColor(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> UIColor
セルのテキスト色

func enabledDetectors(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> [DetectorType]
DetectorType

func detectorAttributes(for detector: DetectorType, and message: MessageType, at indexPath: IndexPath) -> [NSAttributedString.Key: Any]
DetectorTypeの属性

func snapshotOptionsForLocation(message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> LocationMessageSnapshotOptions
「location」のSnapshotOption

func annotationViewForLocation(message: MessageType, at indexPath: IndexPath, in messageCollectionView: MessagesCollectionView) -> MKAnnotationView?
「location」のAnotationView

func animationBlockForLocation(message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> ((UIImageView) -> Void)?
「location」のAnimationBlock

func configureMediaMessageImageView(_ imageView: UIImageView, for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView)
「photo」「video」ImageView

func configureAudioCell(_ cell: AudioMessageCell, message: MessageType)
「audio」のAudioMessageCell

func audioTintColor(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> UIColor
プログレスバー色

func audioProgressTextFormat(_ duration: Float, for audioCell: AudioMessageCell, in messageCollectionView: MessagesCollectionView) -> String
オーディオサウンドの持続時間の設定

func configureLinkPreviewImageView(_ imageView: UIImageView, for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView)
LinkPreviewMessageCellImageView

3-4. InputBarAccessoryViewDelegate

InputBarAccessoryViewDelegate」は、入力バーを設定するためのプロトコルです。
実装必須のメソッドはありません。

「InputBarAccessoryViewDelegate」のメソッド一覧は、次のとおりです。

func inputBar(_ inputBar: InputBarAccessoryView, didPressSendButtonWith text: String)
InputBarAccessoryViewの送信ボタン押下時に呼ばれる

func inputBar(_ inputBar: InputBarAccessoryView, didChangeIntrinsicContentTo size: CGSize)
InputBarAccessoryViewInstrinsicContentSize変更時に呼ばれる

func inputBar(_ inputBar: InputBarAccessoryView, textViewTextDidChangeTo text: String)
InputBarAccessoryViewInputTextViewのテキスト変更時に呼ばれる

func inputBar(_ inputBar: InputBarAccessoryView, didSwipeTextViewWith gesture: UISwipeGestureRecognizer)
InputBarAccessoryViewInputTextViewでスワイプジェスチャ認識時に呼ばれる

入力バー (InputBarAccessoryView) のUI構成は、次のとおりです。

4. Xcodeへのパッケージの追加

Xcode14では、「Package Dependencies」からパッケージを追加します。

(1) 「PROJECT」の「Package Dependencies」の「+」を押す。

(2) 右上のテキストボックスに以下のURLを入力し、「MessageKit」を選択し、「Add Package」ボタンを押す。

https://github.com/MessageKit/MessageKit

5. Xcodeへのアセットの追加

今回は、アイコンとして使う2つのアセット画像 (cat と bear)を追加します。

6. コードの編集

ViewControllerを以下のように編集します。

import UIKit
import MessageKit
import InputBarAccessoryView

// 送信者
struct ChatSender: SenderType {
    var senderId: String  // 送信者ID
    var displayName: String  // 表示名
    var iconName: String  // アイコン名

    // 自分のSenderType
    static var me: ChatSender {
        return ChatSender(senderId: "0", displayName: "me", iconName: "cat")
    }

    // 他人のSenderType
    static var other: ChatSender {
        return ChatSender(senderId: "1", displayName: "other", iconName: "bear")
    }
}

// メッセージ
struct ChatMessage: MessageType {
    var sender: SenderType  // 送信者
    var messageId: String  // メッセージID
    var kind: MessageKind  // メッセージ種別
    var sentDate: Date  // 送信日時

    // メッセージの生成
    static func new(sender: SenderType, message: String) -> ChatMessage {
        return ChatMessage(
            sender: sender,
            messageId: UUID().uuidString,
            kind: .attributedText(NSAttributedString(
                string: message,
                attributes: [
                    .font: UIFont.systemFont(ofSize: 14.0),
                    .foregroundColor: sender.senderId == "0" ? UIColor.white : UIColor.label
                ]
            )),
            sentDate: Date())
    }
}

// ViewController
final class ViewController: MessagesViewController {

    // メッセージリスト
    private var messageList: [ChatMessage] = [] {
        // メッセージ設定時に呼ばれる
        didSet {
            messagesCollectionView.reloadData()
            messagesCollectionView.scrollToLastItem(at: .bottom, animated: true)
        }
    }

    // ビューロード時に呼ばれる
    override func viewDidLoad() {
        super.viewDidLoad()
        DispatchQueue.main.async {
            // メッセージリストの初期化
            self.messageList = [
                ChatMessage.new(sender: ChatSender.me, message: "こんにちは。"),
                ChatMessage.new(sender: ChatSender.other, message: "はい、こんにちは。"),
                ChatMessage.new(sender: ChatSender.me, message: "はい、今日は良い天気ですね!"),
                ChatMessage.new(sender: ChatSender.other, message: "今日は良い天気ですね!")
            ]
        }
        
        // messagesCollectionView
        messagesCollectionView.backgroundColor = UIColor.secondarySystemBackground
        messagesCollectionView.messagesDataSource = self
        messagesCollectionView.messagesLayoutDelegate = self
        messagesCollectionView.messagesDisplayDelegate = self
        
        // messageInputBar
        messageInputBar.delegate = self
        messageInputBar.sendButton.title = nil
        messageInputBar.sendButton.image = UIImage(systemName: "paperplane")
    }
}

// MessagesDataSource
extension ViewController: MessagesDataSource {
    // 現在の送信者
    var currentSender: SenderType {
        return ChatSender.me
    }

    // メッセージ数
    func numberOfSections(in messagesCollectionView: MessagesCollectionView) -> Int {
        return messageList.count
    }

    // IndexPathに応じたメッセージ
    func messageForItem(at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> MessageType {
        return messageList[indexPath.section]
    }

    // messageTopLabelの属性テキスト
    func messageTopLabelAttributedText(for message: MessageType, at indexPath: IndexPath) -> NSAttributedString? {
        return NSAttributedString(
            string: messageList[indexPath.section].sender.displayName,
            attributes: [.font: UIFont.systemFont(ofSize: 12.0), .foregroundColor: UIColor.systemBlue])
    }

    // messageBottomLabelの属性テキスト
    func messageBottomLabelAttributedText(for message: MessageType, at indexPath: IndexPath) -> NSAttributedString? {
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = DateFormatter.dateFormat(
            fromTemplate: "HH:mm", options: 0, locale: Locale(identifier: "ja_JP"))
        return NSAttributedString(
            string: dateFormatter.string(from: messageList[indexPath.section].sentDate),
            attributes: [.font: UIFont.systemFont(ofSize: 12.0), .foregroundColor: UIColor.secondaryLabel])
    }
}

// MessagesDisplayDelegate
extension ViewController: MessagesDisplayDelegate {
    // 背景色
    func backgroundColor(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> UIColor {
        return isFromCurrentSender(message: message) ? UIColor.systemBlue : UIColor.systemBackground
    }

    // メッセージスタイル
    func messageStyle(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> MessageStyle {
        let corner: MessageStyle.TailCorner = isFromCurrentSender(message: message) ? .bottomRight : .bottomLeft
        return .bubbleTail(corner, .curved)
    }

    // avaterViewの設定
    func configureAvatarView(_ avatarView: AvatarView, for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) {
        let sender = messageList[indexPath.section].sender as! ChatSender
        avatarView.image =  UIImage(named: sender.iconName)
    }
}

// MessagesLayoutDelegate
extension ViewController: MessagesLayoutDelegate {
    // messageTopLabelの高さ
    func messageTopLabelHeight(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> CGFloat {
        return 24
    }

    // messageBottomLabelの高さ
    func messageBottomLabelHeight(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> CGFloat {
        return 24
    }
    
    // headerViewのサイズ
    func headerViewSize(for section: Int, in messagesCollectionView: MessagesCollectionView) -> CGSize {
        return CGSize.zero
    }
}

// InputBarAccessoryViewDelegate
extension ViewController: InputBarAccessoryViewDelegate {
    // InputBarAccessoryViewの送信ボタン押下時に呼ばれる
    func inputBar(_ inputBar: InputBarAccessoryView, didPressSendButtonWith text: String) {
        messageList.append(ChatMessage.new(sender: ChatSender.me, message: text))
        messageInputBar.inputTextView.text = String()
    }
}

7. 実行

実行すると、以下のようなチャットUIが表示されます。

参考

次回



この記事が気に入ったらサポートをしてみませんか?