プログラミング解説ノート

Swift, Python, ネットワークなど、新しい技術の学びや開発中の気づきをアウトプットしていきます。

【Swift】JSQMessagesViewController後継のMessageKitでのチャット画面の作成方法

はじめに

Swiftでチャットアプリを作ろうと思い、どんなライブラリが主流なのかな?と思って調べていると、これまではJSQMessagesViewControllerが一般的だったらしいですが、すでに開発が終了しており、現在は非推奨となっていることが分かりました。
そして、JSQMessagesViewControllerの後継?のMessageKitなるものを見つけました。

使い方を解説してくれているサイトが少なかったのですが、以下のサイトが非常に分かりやすかったです。

www.raywenderlich.com

ちなみに全部英語です(笑)
英語がある程度できれば問題なく読めますが、英語ってだけで挫折しそう!って方もいると思うので、そんな方たちのためにこの記事ではMessageKitの使い方をかみ砕いて日本語で説明していこうと思います!
Firebase Tutorial: Real-time ChatMessageKitのサンプルプロジェクトを合わせて見るとより理解できると思います。

環境

macOS Mojave 10.14.3, XCode 10.2.1, iOS 12.2, Swift 4.2.1, MessageKit 2.0.0

MessageKitのインストール

以下の手順でCocoaPodsを使用してライブラリをインストールします。

$ cd プロジェクト名
$ pod init
$ vim Podfile
$ pod install

Podfileには以下の内容を記述します。

# Pods for FirebaseChat
pod 'MessageKit'
pod 'MessageInputBar' // ver2.0.0から必要

MessageKitの使い方

MessageKitはUICollectionViewControllerの強化版みたいな感じなので、UICollectionViewControllerの使い方が分かっていれば割と簡単です。
MessagesViewControllerクラスを継承するだけでチャット画面を作れます。
後は必要なプロトコルを実装していくって感じです。
メッセージはMessageTypeに準拠したMessageという構造体を自分で用意する必要があるんですが、これはMessageKitのサンプルプロジェクト内のMockMessageを利用すれば問題ありません。

class ChatViewController: MessagesViewController {

    private var messages: [Message] = []

    override func viewDidLoad() {
        super.viewDidLoad()

        messagesCollectionView.messagesDataSource = self
        messagesCollectionView.messageCellDelegate = self
        messagesCollectionView.messagesLayoutDelegate = self
        messagesCollectionView.messagesDisplayDelegate = self
        messagesCollectionView.messageCellDelegate = self
        messageInputBar.delegate = self
    }
}

メッセージの送信やアバターの設定といったカスタマイズは以下の6つのプロトコルを使って行います。

  • MessagesDataSource
  • MessageInputBarDelegate
  • MessagesDisplayDelegate
  • MessagesLayoutDelegate
  • MessageCellDelegate
  • MessageLabelDelegate

それぞれについて簡単に説明していきます。 設定項目は多いですが、テキストメッセージの送受信さえとりあえずできればOKという場合は、 MessagesDataSourceMessageInputBarDelegateのみ実装すれば大丈夫です。

MessagesDataSource

メッセージの送信者や画面に表示するメッセージの数などの設定ができます。
全部で9つのメソッドがありますが、必ず実装しないといけないのはcurrentSendermessageForItem, numberOfSectionsの3つです。
~AttributedTextではメッセージやセルの上下に表示する文字列を設定できます。

extension ChatViewController: MessagesDataSource {

    // idとdisplayNameをプロパティに持つ構造体Senderを返す
    func currentSender() -> Sender {
       return Sender(id: "1", displayName: "Tom")
    }

    func numberOfSections(in messagesCollectionView: MessagesCollectionView) -> Int {
        return messages.count
    }

    func messageForItem(at indexPath: IndexPath, 
        in messagesCollectionView: MessagesCollectionView) -> MessageType {

        return messages[indexPath.section]
    }

    func cellTopLabelAttributedText(for message: MessageType, 
        at indexPath: IndexPath) -> NSAttributedString? {

        let name = message.sender.displayName
        return NSAttributedString(
            string: name,
            attributes: [
                .font: UIFont.preferredFont(forTextStyle: .caption1),
                .foregroundColor: UIColor(white: 0.3, alpha: 1)
            ]
        )
    }
}

MessageInputBarDelegate

メッセージ入力バーに関する処理を実装するものです。
ほぼ確実に実装することになるのは、送信ボタンを押した時の処理を表すmessageInputBarメソッドだと思います。
以下の例は、送信ボタンを押すと、入力された文字列からMessageオブジェクトを作成して、メッセージリストに追加し、メッセージ入力バーを空にして、画面の下までスクロールするというものです。
insertMessage(message)もしくは、それに値するメソッドは自分で作る必要があります。
そのメソッドの中でメッセージデータをローカルに保存するなり、データベースに保存するなりします。 firebaseを利用する場合の処理と、チャット画面全体をリロードして、送信したメッセージを表示する処理もここで行います。

extension ChatViewController: MessageInputBarDelegate {
    
    func messageInputBar(_ inputBar: MessageInputBar, didPressSendButtonWith text: String) {      
        let message = Message(text: text, sender: currentSender(), messageId: UUID().uuidString, date: Date())
        insertMessage(message)
        inputBar.inputTextView.text = String()
        messagesCollectionView.scrollToBottom(animated: true)
    }
    
}

MessagesDisplayDelegate

メッセージの表示形式やデザインを決めるものです。
テキストメッセージのみに反映される設定や全てのメッセージに反映される設定等いろいろあるので、MessageKitのサンプルプロジェクトを参考にするといいと思います。
以下の例では、相手のメッセージが自分のメッセージかに応じてメッセージの背景色を変えて、メッセージスタイルを吹き出しにしています。
isFromCurrentSender()がそのメッセージが自分が送信したのもかどうかを判定するメソッドです。
.primary.incomingMessageは自作の色なので、ここは各自好きな色にしてください。

extension ChatViewController: MessagesDisplayDelegate {
  
    func backgroundColor(for message: MessageType, at indexPath: IndexPath, 
        in messagesCollectionView: MessagesCollectionView) -> UIColor {

        return isFromCurrentSender(message: message) ? .primary : .incomingMessage
    }

    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)
    }
}

MessagesLayoutDelegate

このプロトコルはメッセージ周辺のラベルのレイアウトを決めるものです。
メッセージ周辺のラベルというのは、メッセージ付近に表示される時間や既読、日付といったものです。
それらの位置や大きさはここで設定できます。
メッセージの下に余白を作るといったこともここで行います。

extension ChatViewController: MessagesLayoutDelegate {
    
    func cellTopLabelHeight(for message: MessageType, at indexPath: IndexPath, 
        in messagesCollectionView: MessagesCollectionView) -> CGFloat {
        return 18
    }
    
    func messageTopLabelHeight(for message: MessageType, at indexPath: IndexPath, 
        in messagesCollectionView: MessagesCollectionView) -> CGFloat {
        return 20
    }
    
    func messageBottomLabelHeight(for message: MessageType, at indexPath: IndexPath, 
        in messagesCollectionView: MessagesCollectionView) -> CGFloat {
        return 16
    }
}

MessageCellDelegate

チャット画面で様々なものをタップした時の処理を実装できます。
以下は、アバター(自分のプロフ画像が表示される所)とメッセージがタップされた時の処理の例です。
アバターとメッセージをタップするとコンソールにそれぞれ文字列が出力されます。

extension ChatViewController: MessageCellDelegate {
    
    func didTapAvatar(in cell: MessageCollectionViewCell) {
        print("Avatar tapped")
    }
    
    func didTapMessage(in cell: MessageCollectionViewCell) {
        print("Message tapped")
    }
    
}

MessageLabelDelegate

メッセージ中の電話番号や住所、URLなどをタップした時の処理を実装できます。
URLをタップしたらブラウザを開く、住所をタップしたらマップを起動するといった処理はここで実装します。
以下は例なので、コンソールに文字列を出力するだけです。

extension ChatViewController: MessageLabelDelegate {
    
    func didSelectAddress(_ addressComponents: [String: String]) {
        print("Address Selected: \(addressComponents)")
    }
    
    func didSelectPhoneNumber(_ phoneNumber: String) {
        print("Phone Number Selected: \(phoneNumber)")
    }
    
    func didSelectURL(_ url: URL) {
        print("URL Selected: \(url)")
    }
    
}

おわりに

さらに詳しく知りたい方はMessageKit Referenceをご覧ください。
今回の内容だけだとチャットアプリにはならないので、次はFirebaseとも組み合わせた実装例についても記事を書きたいと思います!

【Swift】画面遷移と値受け渡しの方法まとめ

画面遷移の方法

Swiftには画面遷移の方法がいくつかあると思います。
自分の頭を整理するためにもここでまとめておきます。

Storyboardのみで実装する

これは最も簡単な方法で、ボタンを遷移先のViewCntrollerをつなげて、Segueの種類を選ぶだけで完了です。

performSegueを使う

  1. ViewController同士をSegueでつなぐ
  2. このSegueにはStoryboard Segue→Identifierを設定する(nextとか) f:id:b_murabito:20190424015659p:plain:w250
  3. 遷移したいタイミングでperformSegueメソッドを呼び出す
self.performSegue(withIdentifier: <SegueのIdentifier>, sender: nil)

ViewControllerにStoryboard IDを設定する

  1. 遷移先のViewController(NextViewControllerとする)のIndetify→Storyboard IDを設定する(nextとか)
    f:id:b_murabito:20190424015522p:plain:w250
  2. instantiateViewControllerメソッドで遷移先のViewControllerを特定する
  3. presentメソッドを呼び出す
let next = self.storyboard?.instantiateViewController(withIdentifier: <Storyboard ID>)
self.present(next, animated: true, completion: nil)

Navigation Controllerを使う

  1. Editor→Embed InからNavigation Controllerを追加する
  2. 遷移先のViewController(NextViewControllerとする)のIndetify→Storyboard IDを設定する(nextとか)
  3. 以下のコードで画面遷移する
// 進むとき
let next = self.storyboard?.instantiateViewController(withIdentifier: <Storyboard ID>)
self.navigationController?.pushViewController(next, animated: true)

// 戻るとき
_ = navigationController?.popViewController(animated: false)

値受け渡しとの組み合わせ

画面遷移と合わせてよく使う値の受け渡しの方法にもついてもまとめておきます。

performSegueとprepareを使う

performSegueメソッドの内部で呼び出されるprepareメソッドをオーバーライドすることで値を受け渡すことができます。

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    let next = segue.destination as! NextViewController
    next.<プロパティ> = <受け渡す値>
}

presentと値受け渡し

遷移先のViewControllerのインスタンスを取得し、そのインスタンスのプロパティを設定することで値を受け渡すことができます。

let next = self.storyboard?.instantiateViewController(withIdentifier: <Storyboard ID>) as! NextViewController
next.<プロパティ> = <受け渡す値>
self.present(next, animated: true, completion: nil)

クロージャを使っても上と同等のことができます。

let next = self.storyboard?.instantiateViewController(withIdentifier: <Storyboard ID>) as! NextViewController
self.present(next!,animated: true, completion: {
    next.<プロパティ> = <受け渡す値>
})

頭がいい感じに整理できました〜!

参考資料

【Swift】具体例でわかりやすいプロトコル解説

どうも。
今回は、Swiftで重要なプロトコルについて具体例を出しつつ解説していこうと思います。

プロトコルとは?

プロトコル(protocol)という言葉の辞書的な意味は「条約」とか「規約」です。
Swiftにおいては抽象化した約束事を表します。
言葉本来の意味に加えて抽象化という概念が入るのがポイントです。

現実問題で考えてみる

例として、選挙を考えます。
選挙では各立候補者は公約つまり、「日本を良くするための約束事」を 言います。
また、昨今問題となっている少子化問題を必ず解決しなければならないとします。

ここで、少子化問題の解決方法は各立候補が決めることです。
そのため、必須であっても具体的な内容まではあらかじめ決めておけませんよね。

つまり、必ず守って欲しい内容は抽象的な約束事として用意しておいて、細かい内容は約束事を守る側に決めさせるというのがSwiftにおけるプロトコルです。

プロトコルの使い方

上の例を踏まえてプロトコルの使い方をソースコードで見ていきましょう。

// プロトコルの宣言
protocol PublicCommitment {
    func stopDecliningBirthRates()
}

// プロトコルに準拠した麻生クラス
class Asou: PublicCommitment {
    func stopDecliningBirthRates() {
        print("子育ての補助金を増やします")
    }
}

// プロトコルに準拠した安倍クラス
class Abe: PublicCommitment {
    func stopDecliningBirthRates() {
        print("育メン制度を整えます")
    }
}

プロトコルは基本的にクラスと同じように定義・継承して使います。
クラスと違うのは主に次の2点です。

  • プロトコルのメソッドは空

  • プロトコルは多重継承が可能

プロトコルのメソッドが空なのは、上で説明したようにそのプロトコルに準拠した各クラスで具体的な処理内容を記述するためです。
逆に、プロトコルに準拠したのにメソッドを実装していないとエラーが発生します。

iOSアプリ開発におけるプロトコルの具体例

アプリの開発中に独自のプロトコルを作成することはあまりありません。
どこで使われているのかというと、Appleが用意してくれているライブラリです。

例えば、TableViewを使いたいときは UITableViewDelegate, UITableViewDataSourceという2つのプロトコルに準拠して、以下の2つのtableViewメソッドを実装する必要があります。
「TableView使うんだったらこの2つは絶対必要だから実装してね」ってことでプロトコルにしているのだと思います。

import UIKit
class ViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {
    @IBOutlet weak var tableView: UITableView!

    override func viewDidLoad() {
        super.viewDidLoad()
        tableView.delegate = self
        tableView.dataSource = self
    }

    tableView.reloadData()

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

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
        return cell
    }
}

まとめ

  • プロトコルはiOSアプリ開発における必須の技術

  • プロトコルとは抽象化した約束事

  • プロトコルを準拠したクラス内でメソッドの実装をする

Swiftのプロトコルは、C++やJavaでいうインタフェースなんですが、個人的にはインタフェースよりも、ある機能を使うために実装しておかないといけない約束事って感じの方がわかりやすく思います。

また、プロトコルを勉強するにあたっては以下の書籍が非常にためになりました!

【Swift】UserDefaultsに自作クラスのデータを保存する方法(iOS12対応)

UserDefaultsを使っていてハマったことがあったのでメモ。

環境

Swift4.2.1、Xcode10.1

やりたいこと

UserDefaultsに自作クラスの配列を保存したい。

UserDefaultsとは?

データベースを使うことなく、iPhone上にデータを保存できるようにしてくれる機能です。 ただ、アプリをアンイストールするとデータは消えるので注意。 また、UserDefaultsで保存できる型は決まっており、以下の11個の型のみが利用できます。

Any, URL, [Any], [String: Any], String, [String], Data, Bool, Int, Float, Double

UserDefaultsの基本的な使い方

キーを用いて値を保存したり、参照したりします。 例えば、String型の配列を保存する場合は以下のようになります。

// 保存
UserDefaults.standard.set(["test1", "test2", "test3"], forKey: "tests")

// 参照
if let tests = UserDefaults.standard.stringArray(forKey: "tests") {
    print(tests)
}

UserDefaultsで自作クラスのデータを保存する方法

上で説明したように、UserDefaultsで保存できる型は決まっており、自作クラスは保存できません。 また、公式ドキュメントでも以下のような説明があります。

A default object must be a property list—that is, an instance of (or for collections, a combination of instances of) NSData, NSString, NSNumber, NSDate, NSArray, or NSDictionary. If you want to store any other type of object, you should typically archive it to create an instance of NSData.

つまり、自作クラスのデータを保存したいなら一度Data型に変換する必要があります。 今回は、Articleクラスのインスタンスの配列を保存する場合を例にとってサンプルコードを書きました。

保存したい自作クラス

// NSObjectを継承し、NSCodingを批准する必要があります
class Article: NSObject, NSCoding {
    var title = ""
    var date = ""

    init(_ title: String, _ date: String) {
        self.title = title
        self.date = date
    }

    // NSKeyedArchiverに呼び出されるシリアライズ処理(NSCodingで定義されている)
    func encode(with aCoder: NSCoder) {
        aCoder.encode(self.title, forKey: "title")
        aCoder.encode(self.date, forKey: "date")
    }

    // NSKeyedArchiverに呼び出されるデシリアライズ処理(NSCodingで定義されている)
    required init?(coder aDecoder: NSCoder) {
        title = aDecoder.decodeObject(forKey: "title") as! String
        date = aDecoder.decodeObject(forKey: "date") as! String
    }
}

iOS11までの保存と参照の処理

// 保存したい配列
var articles = [Article]()
let article = Article("Swift", "1月1日")
articles.append(article)

// 保存
let archivedData = NSKeyedArchiver.archivedData(withRootObject: articles)
UserDefaults.standard.set(archivedData, forKey: "articles")

// 参照
var articles = [Article]()
if let storedData = UserDefaults.standard.object(forKey: "articles") as? Data {
    if let unarchivedObject = NSKeyedUnarchiver.unarchiveObject(with: storedData) as? [Article] {
        articles = unarchivedObject
    }
}

iOS12での保存と参照の処理

// 保存したい配列
var articles = [Article]()
let article = Article("Swift", "1月1日")
articles.append(article)

// 保存
let archivedData = try! NSKeyedArchiver.archivedData(withRootObject: articles, requiringSecureCoding: false)
UserDefaults.standard.set(archivedData, forKey: "articles")

// 参照
var articles = [Article]()
if let storedData = UserDefaults.standard.object(forKey: "articles") as? Data {
    if let unarchivedObject = try! NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(storedData) as? [Article] {
        articles = unarchivedObject
    }
}

NSKeyedUnarchiver.unarchivedObjectを使う方法もあるようなのですが、エラーが出てうまくいきませんでした。 誰か分かる方いればコメントください。