Passion make things more better

Ruby on Rails / React.js / Swift / AWS / Docker

SwiftでJSQMessagesViewControllerを使ってチャットUIを実装する

昨今、多くのアプリでチャットUIが見慣れてきたかと思います。以外と見慣れているUIだけど一から自分で実装するのは以外と面倒ですよね....そこで今回はJSQMessagesViewControllerというライブラリを使ってチャットに必要な最低限の機能を実装する方法を説明します。

仕様について

JSQMessagesViewControllerには多くの機能が備わっているのですが、今回は以下の機能を実装していきます(とりあえずこれがあれば成り立つだろうという要素)。

  • 1対1でユーザー同士がやりとりをできる
  • テキストが送信できる
  • 画像が送信できる
    • 画像タップが検知できる
  • 送信時刻を良い感じに表示する(ある一定の間隔が空いたら表示するなど)
  • チャット相手のユーザーのアイコンが表示される
    • 相手のユーザーのアイコンタップを検知することができる

初期導入方法

インストール

JSQMessagesViewControllerをCocoaPodsを使ってインストールします。

// Podfile
pod 'JSQMessagesViewController'

~ pod install

はじめの設定

以下のファイルを作成してください。するとチャット画面ができ、最低限の実装ができているかと思います。 処理としては、メッセージを送ると自動で返信をするというものです。

import UIKit
import SwiftyJSON
import JSQMessagesViewController

class MessagesViewController: JSQMessagesViewController {
    
    private var messages: [JSQMessage] = []
    private var incomingBubble: JSQMessagesBubbleImage!
    private var outgoingBubble: JSQMessagesBubbleImage!
    private var incomingAvatar: JSQMessagesAvatarImage!
    // テスト用
    private let targetUser: JSON = ["senderId": "targetUser", "displayName": "passion"]
    
    override func viewDidLoad() {
        super.viewDidLoad()
        initialSettings()
    }
    
    private func initialSettings() {
        // 自分の情報入力
        self.senderId = "self"
        self.senderDisplayName = "自分の名前"
        // 吹き出しの色設定
        let bubbleFactory = JSQMessagesBubbleImageFactory()
        self.incomingBubble = bubbleFactory.incomingMessagesBubbleImageWithColor(UIColor.jsq_messageBubbleLightGrayColor())
        self.outgoingBubble = bubbleFactory.outgoingMessagesBubbleImageWithColor(UIColor.jsq_messageBubbleGreenColor())
        
        // 相手の画像設定
        self.incomingAvatar = JSQMessagesAvatarImageFactory.avatarImageWithImage(UIImage(named: "sample_user")!, diameter: 64)
    // 自分の画像を表示しない
        self.collectionView!.collectionViewLayout.outgoingAvatarViewSize = CGSizeZero
    }
    
    // 送信ボタンを押した時の挙動
    override func didPressSendButton(button: UIButton!, withMessageText text: String!, senderId: String!, senderDisplayName: String!, date: NSDate!) {
        let message = JSQMessage(senderId: senderId, displayName: senderDisplayName, text: text)
        messages.append(message)
        // 更新
        finishSendingMessageAnimated(true)
        
        sendAutoMessage()
    }
    
    // 表示するメッセージの内容
    override func collectionView(collectionView: JSQMessagesCollectionView!, messageDataForItemAtIndexPath indexPath: NSIndexPath!) -> JSQMessageData! {
        return self.messages[indexPath.item]
    }
    
    // 表示するメッセージの背景を指定
    override func collectionView(collectionView: JSQMessagesCollectionView!, messageBubbleImageDataForItemAtIndexPath indexPath: NSIndexPath!) -> JSQMessageBubbleImageDataSource! {
        if messages[indexPath.item].senderId == senderId {
            return self.outgoingBubble
        }
        return self.incomingBubble
    }
    
    // 表示するユーザーアイコンを指定。nilを指定すると画像がでない
    override func collectionView(collectionView: JSQMessagesCollectionView!, avatarImageDataForItemAtIndexPath indexPath: NSIndexPath!) -> JSQMessageAvatarImageDataSource! {
        if messages[indexPath.item].senderId != self.senderId {
            return incomingAvatar
        }
        return nil
    }
    
    // メッセージの件数を指定
    override func collectionView(collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return messages.count
    }
    
    // テストでメッセージを送信するためのメソッド
    private func sendAutoMessage() {
        let message = JSQMessage(senderId: targetUser["senderId"].string, displayName: targetUser["displayName"].string, text: "返信するぞ")
        messages.append(message)
        finishReceivingMessageAnimated(true)
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }

}

機能実装

ここからはこの基本的なチャットを拡張していきます。

メッセージの上に時刻を表示

よくLINEなどで見かけるメッセージの上に送信時刻を表示する機能を実装していきます。 以下のリンクに書いてある内容が非常に良いので、今回はそちらを使わせて頂きました。Objective-Cで書かれていたのでSwift用に書き換えています。

Change a timestamp logic in JSQMessagesViewController

// 送信時刻を出すために高さを調整する
    override func collectionView(collectionView: JSQMessagesCollectionView!, attributedTextForCellTopLabelAtIndexPath indexPath: NSIndexPath!) -> NSAttributedString! {
        let message = messages[indexPath.item]
        if indexPath.item == 0 {
            return JSQMessagesTimestampFormatter.sharedFormatter().attributedTimestampForDate(message.date)
        }
        if indexPath.item - 1 > 0 {
            let previousMessage = messages[indexPath.item - 1]
            if message.date.timeIntervalSinceDate(previousMessage.date) / 60 > 1 {
                return JSQMessagesTimestampFormatter.sharedFormatter().attributedTimestampForDate(message.date)
            }
        }
        return nil
    }
    
    // 送信時刻を出すために高さを調整する
    override func collectionView(collectionView: JSQMessagesCollectionView!, layout collectionViewLayout: JSQMessagesCollectionViewFlowLayout!, heightForCellTopLabelAtIndexPath indexPath: NSIndexPath!) -> CGFloat {
        if indexPath.item == 0 {
            return kJSQMessagesCollectionViewCellLabelHeightDefault
        }
        if indexPath.item - 1 > 0 {
            let previousMessage = messages[indexPath.item - 1]
            let message = messages[indexPath.item]
            if message.date .timeIntervalSinceDate(previousMessage.date) / 60 > 1 {
                return kJSQMessagesCollectionViewCellLabelHeightDefault
            }
        }
        return 0.0
    }

これでメッセージを送信してみてください。上に時刻が表示されていることでしょう。

ユーザーアイコンのタッチイベントを検知する

ユーザーのアイコンタップのイベントをハンドルするメソッドはデフォルトで備わっていないので、collectionViewのメソッドをオーバーライドして検知する方法をとります。

公式FAQを見るとcellのカスタマイズ方法が書いてあるので、それに習っていきます。

override func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
        let cell = super.collectionView(collectionView, cellForItemAtIndexPath: indexPath) as! JSQMessagesCollectionViewCell
        
        // ユーザーアイコンに対してジェスチャーをつける
        let avatarImageTap = UITapGestureRecognizer(target: self, action: "tappedAvatar")
        cell.avatarImageView?.userInteractionEnabled = true
        cell.avatarImageView?.addGestureRecognizer(avatarImageTap)

    // 文字色を変える
        if messages[indexPath.item].senderId != senderId {
            cell.textView?.textColor = UIColor.darkGrayColor()
        } else {
            cell.textView?.textColor = UIColor.whiteColor()
        }
        
        return cell
    }
    
    func tappedAvatar() {
        print("tapped user avatar")
    }

これでtappedAvatar()メソッドでアイコンタッチが拾えるようになっているかと思います。

画像を投稿する

画像を投稿する仕組みを実装します。キーボードの上にクリップのアイコンがあるかと思います。あのアイコンをタップすることで投稿する画像を選べるようにします。

クリップのタッチイベントはdidPressAccessoryButton(sender: UIButton!)で受け取ることができるので、処理をこの中に書いていきます。 今回はUIImagePickerControllerを使用するので、こちらをみて、selectImage()メソッドが呼べる状態にしておいてください。

override func didPressAccessoryButton(sender: UIButton!) {
        selectImage()
    }
private func selectImage() {
        let alertController = UIAlertController(title: "画像を選択", message: nil, preferredStyle: .ActionSheet)
        let cameraAction = UIAlertAction(title: "カメラを起動", style: .Default) { (UIAlertAction) -> Void in
            self.selectFromCamera()
        }
        let libraryAction = UIAlertAction(title: "カメラロールから選択", style: .Default) { (UIAlertAction) -> Void in
            self.selectFromLibrary()
        }
        let cancelAction = UIAlertAction(title: "キャンセル", style: .Cancel) { (UIAlertAction) -> Void in
            self.dismissViewControllerAnimated(true, completion: nil)
        }
        alertController.addAction(cameraAction)
        alertController.addAction(libraryAction)
        alertController.addAction(cancelAction)
    }
    
    private func selectFromCamera() {
        if UIImagePickerController.isSourceTypeAvailable(.Camera) {
            let imagePickerController = UIImagePickerController()
            imagePickerController.delegate = self
            imagePickerController.sourceType = UIImagePickerControllerSourceType.Camera
            imagePickerController.allowsEditing = true
            self.presentViewController(imagePickerController, animated: true, completion: nil)
        } else {
            print("カメラ許可をしていない時の処理")
        }
    }
    
    private func selectFromLibrary() {
        if UIImagePickerController.isSourceTypeAvailable(.PhotoLibrary) {
            let imagePickerController = UIImagePickerController()
            imagePickerController.delegate = self
            imagePickerController.sourceType = UIImagePickerControllerSourceType.PhotoLibrary
            imagePickerController.allowsEditing = true
            self.presentViewController(imagePickerController, animated: true, completion: nil)
        } else {
            print("カメラロール許可をしていない時の処理")
        }
    }
    
    func imagePickerController(picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [String : AnyObject]) {
        if let image = info[UIImagePickerControllerEditedImage] {
            sendImageMessage(image)
        }
        picker.dismissViewControllerAnimated(true, completion: nil)
    }
private func sendImageMessage(image: UIImage) {
        let photoItem = JSQPhotoMediaItem(image: image)
        let imageMessage = JSQMessage(senderId: senderId, displayName: senderDisplayName, media: photoItem)
        messages.append(imageMessage)
        finishSendingMessageAnimated(true)
    }

これで写真を投稿することができたかと思います。

メッセージで送った画像のタップイベントを受け取る

先ほど、メッセージで画像を送るところまではできているかと思います。続いてやるのは、よくチャットアプリである画像をタップして拡大を行うために、画像のタップを検知する処理を書くことです。

JSQMessagesViewControllerにはfunc collectionView(collectionView: JSQMessagesCollectionView!, didTapMessageBubbleAtIndexPath indexPath: NSIndexPath!)というメソッドがあります。このメソッドでbubble(吹き出し)のタッチを検知し、さらに画像だった場合に別のメソッドを呼ぶという処理を書いていきます。

override func collectionView(collectionView: JSQMessagesCollectionView!, didTapMessageBubbleAtIndexPath indexPath: NSIndexPath!) {
        if messages[indexPath.item].isMediaMessage {
            let media = messages[indexPath.item].media
            if media .isKindOfClass(JSQPhotoMediaItem) {
               print("tapped Image")
            }
        }
    }

これで画像を送信してタップをすることでprintで書いた文章が呼ばれるかと思います。

最後に

以上が、SwiftでJSQMessagesViewControllerを使用した際のチャットUIの実装方法になります。今回紹介したのはあくまで最低限の実装方法なので、興味ある方は、他にも詳しく調べて是非実装してみてください。

間違え等ありましたらコメントなりで連絡を頂けると嬉しいです。

参考

SwiftでUIImagePickerControllerを使用したテンプレ

import UIKit

class UIPickerViewSampleViewController: UIViewController, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        selectImage()
    }
    
    private func selectImage() {
        let alertController = UIAlertController(title: "画像を選択", message: nil, preferredStyle: .ActionSheet)
        let cameraAction = UIAlertAction(title: "カメラを起動", style: .Default) { (UIAlertAction) -> Void in
            self.selectFromCamera()
        }
        let libraryAction = UIAlertAction(title: "カメラロールから選択", style: .Default) { (UIAlertAction) -> Void in
            self.selectFromLibrary()
        }
        let cancelAction = UIAlertAction(title: "キャンセル", style: .Cancel) { (UIAlertAction) -> Void in
            self.dismissViewControllerAnimated(true, completion: nil)
        }
        alertController.addAction(cameraAction)
        alertController.addAction(libraryAction)
        alertController.addAction(cancelAction)
        presentViewController(alertController, animated: true, completion: nil
    }
    
    private func selectFromCamera() {
        if UIImagePickerController.isSourceTypeAvailable(.Camera) {
            let imagePickerController = UIImagePickerController()
            imagePickerController.delegate = self
            imagePickerController.sourceType = UIImagePickerControllerSourceType.Camera
            imagePickerController.allowsEditing = true
            self.presentViewController(imagePickerController, animated: true, completion: nil)
        } else {
            print("カメラ許可をしていない時の処理")
        }
    }
    
    private func selectFromLibrary() {
        if UIImagePickerController.isSourceTypeAvailable(.PhotoLibrary) {
            let imagePickerController = UIImagePickerController()
            imagePickerController.delegate = self
            imagePickerController.sourceType = UIImagePickerControllerSourceType.PhotoLibrary
            imagePickerController.allowsEditing = true
            self.presentViewController(imagePickerController, animated: true, completion: nil)
        } else {
            print("カメラロール許可をしていない時の処理")
        }
    }
    
    func imagePickerController(picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [String : AnyObject]) {
        if let image = info[UIImagePickerControllerEditedImage] {
            print(image)
        }
        picker.dismissViewControllerAnimated(true, completion: nil)
    }
    
    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }
}

CocoaPodsでpod installしたらundefined method 'to_ary'に出くわした

アプリを作っていて、新しいライブラリを導入するためにいつものようにPodfileに書いて、pod installしたら掲題のエラーが起きました。 他のライブラリでは問題なくできていたのに急に!?という思いに駆られ、調べていたところ、以下のissueが見つかりました。

undefined method `to_ary' #4891

読んでみるとどうやらrubyのバージョンが2.3以上の場合、起きることがあるようだ、ということがわかり、rubyのバージョンを調べてみると案の定。

╰─$ ruby -v
ruby 2.3.0p0 (2015-12-25 revision 53290) [x86_64-darwin14]

対応策としてはCocoaPods 1.0.0をインストールすることらしい。まだβ版(2016/4/2時点)なのでうーん、と思いながらアップデート作業。

// なんかあると怖そうだったので一旦消す
~ gem uninstall cocoapods
~ gem install cocoapods --pre
~ pod setup

そして再度pod installを実行すると見事成功。 ちょっとハマったので他の人の役に立てればと。

Rails runnerを使ってスクリプトを実行する

Railsを使っている時に、普通の処理とは別だけどもRailsの機能も使いつつ単独のスクリプト実行したい(バッチ処理とか、アプデに伴う諸々のデータの更新など)、と思ったことがある人は多いのではないでしょうか?そんな時に役立つのがRails runnerです。

はじめに

railsのルートディレクトリ直下にscriptsというフォルダを用意し、その中に実行したいファイルを作成します。 今回は20160322_passion.rbというファイルを作成します。内容は以下です。この中のスクリプトRailsの機能も使用可能です。

class Passion
  def self.execute
    put 'Do you have passion?'
  end
end

Passion.execute

スクリプトを実行する

作成したスクリプトは、以下のコマンドで実行します。

~ bundle exec rails runner scripts/20160322_passion.rb

環境毎に実行したい時

stagingやproduction環境下でも実行したい場合があるでしょう。その場合は以下のように-eオプションをつけて実行します。

bundle exec rails runner -e environment_here scripts/20160322_passion.rb

参考

gitでmerge済みのlocal branchを一括で削除する

よくローカルに溜め込みすぎて、毎度調べているので、自分用のメモとして。

git branch --merged | grep -v '^*' | xargs git branch -d

参考