4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

自作iOSライブラリ使って簡単なチャットアプリ作ってみた

Last updated at Posted at 2018-10-19

今回は
前回の記事(iOSアプリのUIをAndroidライクに作成できるライブラリを作ってみた)
紹介させていただいたライブラリを使って実際にサンプルアプリを作ってみました。
完成品はこちら
ezgif-4-3211ffaabad6.gif
##アプリ概要
クライアント: iOS
サーバー: Rails
複数のiOS端末でメッセージチャットが出来る、以上!
##サーバー側準備
とりあえずrails newでアプリ作成。

$ rails new chat_test

ActionCableを作成して、

$ rails g channel room chat

ActionCableの設定を修正。

application.rb
module ChatTest
  class Application < Rails::Application
    config.load_defaults 5.2
    config.action_cable.allowed_request_origins = [/ws:*/]
  end
end

今回は簡単な実装なので全員同じ部屋にぶち込みます。
メッセージが来たら通信が確立されている全端末に届いたメッセージを送信するように設定。

channels/room_channel
class RoomChannel < ApplicationCable::Channel
  def subscribed
    stream_from "chat_room_channel"
  end

  def chat data
    ActionCable.server.broadcast "chat_room_channel", chat_message: {message: data["message"], uuid: data["uuid"]}
  end
end

あとはサーバーを起動してサーバー側の実装は完了。(ポートはSwiftJsonUIのホットロード機能用に3000と8080を使っているので3100を使用。)

$ rails s -p 3100 -b 0.0.0.0

##クライアント側準備
まずはアプリケーションを作成して
スクリーンショット 2018-10-19 17.14.43.png
アプリケーションの初期設定でHotLoaderを有効に。詳しくはライブラリのwikiに書いてますのでご確認くださいませ。

Appdelegate.swift
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        SJUIViewCreator.copyResourcesToDocuments()
        return true
    }
    
    func applicationDidBecomeActive(_ application: UIApplication) {
        #if DEBUG
        HotLoader.instance.isHotLoadEnabled = true
        #endif
    }

hot_loaderも微修正して準備完了。

hot_loader/server.js
var watcher = chokidar.watch('../chat-test/*/*.json',{
...
}
hot_loader/layout_loader.js
app.get("/layout_loader", function(req, res, next){
    var filePath = path.join(__dirname, "../chat-test");

チャット用のViewControllerを作成してそれをwindowのrootViewControllerに設定。
StoryBoardは使わないので削除。
チャットのメッセージ用のモデルChatMessageモデル、チャットのWebSocket管理用のクラスChatManager、チャット表示ようのUICollectionViewCellのChatCollectionViewCellを作成して
必要なファイルを揃えた後のプロジェクト構成はこんな感じ。
スクリーンショット 2018-10-19 17.32.37.png
一応今後の拡張も考えて標準ビューコントローラーのDefaultViewControllerも作成。
チャットは自分と自分以外で左右に吹き出しを分けたいのでChatCollectionViewCellの子クラスにChatMeCollectionViewCellとChatYouCollectionViewCellも作成しておきます。
WebSocket通信の管理クラスであるChatManagerクラスはこんな感じ。
今回のメインはSwiftJsonUIのライブラリの使い方なので詳細は省略。

ChatManaget.swift
protocol ChatManagerDelegate: class {
    func didConnected()
    func failedToConnect(error: Error)
    func onReceiveMessage(chatMessage: ChatMessage)
    func didSendMessage()
    func failedToSendMessage(error: Error)
}

class ChatManager: WebSocketDelegate {
    private static var Instance = ChatManager()
    public static var instance: ChatManager {
        get {
            return Instance
        }
    }
    
    public var isChatEnabled: Bool = false
    {
        willSet {
            if newValue != isChatEnabled {
                newValue ? connectToSocket() : disconnectFromSocket()
            }
        }
    }
    
    weak var delegate: ChatManagerDelegate?
    
    let uuid = UUID().uuidString
    
    private lazy var handleQueue: DispatchQueue = DispatchQueue(label: "socket_queue")
    
    private var _socket: WebSocket?
    
    private func createSocket() -> WebSocket {
        let socket = WebSocket(url: URL(string: "ws://\((Bundle.main.object(forInfoDictionaryKey: "CurrentIp") as? String) ?? ""):3100/cable/")!)
        socket.delegate = self
        socket.callbackQueue = handleQueue
        return socket
    }
    
    private func connectToSocket() {
        guard (self._socket == nil || !self._socket!.isConnected) else {
            return
        }
        handleQueue.async(execute: {
            print("connect to server")
            self._socket = self.createSocket()
            self._socket?.connect()
        })
    }
    
    private func disconnectFromSocket() {
        guard let socket = self._socket, socket.isConnected else {
            return
        }
        handleQueue.async(execute: {
            socket.disconnect()
            self._socket = nil
        })
    }
    
    func websocketDidConnect(socket: WebSocketClient) {
        handleQueue.async(execute: {
            let time = Date()
            let data = ["action": "subscribed", "data": "\(time)"]
            do {
                let jsonAction = try JSONSerialization.data(withJSONObject: data, options: JSONSerialization.WritingOptions.prettyPrinted)
                let jsonChannel = try JSONSerialization.data(withJSONObject: ["channel": "RoomChannel"], options: JSONSerialization.WritingOptions.prettyPrinted)
                let commandDict = [
                    "command" : "subscribe",
                    "identifier": String(data: jsonChannel, encoding: String.Encoding.utf8)!,
                    "data": String(data: jsonAction, encoding: String.Encoding.utf8)!
                ]
                let jsonData = try JSONSerialization.data(withJSONObject: commandDict, options: JSONSerialization.WritingOptions.prettyPrinted)
                socket.write(string: String(data: jsonData, encoding: String.Encoding.utf8)!)
                DispatchQueue.main.async(execute: {
                    let notification = NSNotification.Name("socketDidConnected")
                    NotificationCenter.default.post(name: notification, object: nil)
                    self.delegate?.didConnected()
                })
            } catch  let error {
                socket.disconnect()
                DispatchQueue.main.async(execute: {
                    self.delegate?.failedToConnect(error: error)
                })
            }
        })
    }
    
    func websocketDidDisconnect(socket: WebSocketClient, error: Error?) {
        DispatchQueue.main.async(execute: {
            let notification = NSNotification.Name("socketDidDisConnected")
            NotificationCenter.default.post(name: notification, object: nil)
        })
    }
    
    func websocketDidReceiveMessage(socket: WebSocketClient, text: String) {
        if let data = text.data(using: .utf8) {
            do {
                let json = try JSON(data: data)
                if !json["message"]["chat_message"].isEmpty {
                    DispatchQueue.main.async(execute: {
                        self.delegate?.onReceiveMessage(chatMessage: ChatMessage(json: json["message"]["chat_message"]))
                    })
                }
            } catch let error {
                print("Parse Error: \(error)")
            }
        }
    }
    
    func websocketDidReceiveData(socket: WebSocketClient, data: Data) {
        
    }
    
    func sendMessage(message: String) {
        handleQueue.async(execute: {
            guard let socket = self._socket, socket.isConnected else {
                DispatchQueue.main.async(execute: {
                    let error = NSError(domain: "Socket not connected", code: 500, userInfo: nil)
                    self.delegate?.failedToSendMessage(error: error)
                })
                return
            }
            let data = ["action": "chat", "message": message, "uuid": self.uuid]
            do {
                let jsonAction = try JSONSerialization.data(withJSONObject: data, options: JSONSerialization.WritingOptions.prettyPrinted)
                let jsonChannel = try JSONSerialization.data(withJSONObject: ["channel": "RoomChannel"], options: JSONSerialization.WritingOptions.prettyPrinted)
                let commandDict = [
                    "command" : "message",
                    "identifier": String(data: jsonChannel, encoding: String.Encoding.utf8)!,
                    "data": String(data: jsonAction, encoding: String.Encoding.utf8)!
                ]
                let jsonData = try JSONSerialization.data(withJSONObject: commandDict, options: JSONSerialization.WritingOptions.prettyPrinted)
                socket.write(string: String(data: jsonData, encoding: String.Encoding.utf8)!)
                DispatchQueue.main.async(execute: {
                    self.delegate?.didSendMessage()
                })
            } catch  let error {
                print("Error: \(error)")
                DispatchQueue.main.async(execute: {
                    self.delegate?.failedToSendMessage(error: error)
                })
            }
        })
    }
}

メッセージの送信は

  func sendMessage(message: String)

受信周りはWebSocketDelegateで管理。今回は細かい機能はないのでざっくり実装。
ActionCableにリクエスト投げる時のパラメータの構成が独特なのでそこだけ注意。
あとはコントローラーで表示周りの処理を書いていきまーす。
##ビューコントローラー実装
とりあえず必要になるプロトコルを全部入れちゃいます。

ChatViewController.swift
class ChatViewController: DefaultViewController, UICollectionViewDelegate, UICollectionViewDataSource, ChatManagerDelegate {  

アクセスしたいビューたちをBindingに設定

ChatViewController.swift
class ChatViewController: DefaultViewController, UICollectionViewDelegate, UICollectionViewDataSource, ChatManagerDelegate {  
    private class ViewBinding: Binding {
        weak var rootView: SJUIView!
        weak var collectionView: SJUICollectionView!
        weak var messageArea: SJUIView!
        weak var messageTextView: SJUITextView!
        weak var sendBtn: SJUIButton!
    }
    private lazy var _binding = ViewBinding(viewHolder: self)

ビューのアクティブ、インアクティブのタイミングでチャットの通信の接続/切断

ChatViewController.swift
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        ChatManager.instance.delegate = self
        ChatManager.instance.isChatEnabled = true
    }
    
    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        ChatManager.instance.isChatEnabled = false
    }

ビューをバインディング&コレクションビューにセルを登録の処理をするメソッドを作成。
iPhoneX系用にsafeAreaのサイズに応じて若干ビューを変更できるように。

ChatViewController.swift
   override func initializeView() {
        super.initializeView()
        _binding.collectionView?.constraintInfo?.topMargin = safeAreaInsets.top
        _binding.messageArea?.constraintInfo?.bottomMargin = safeAreaInsets.bottom
        _binding.rootView.resetConstraintInfo()
        _binding.collectionView.delegate = self
        _binding.collectionView.dataSource = self
        _binding.collectionView.register(ChatMeCollectionViewCell.self, forCellWithReuseIdentifier: ChatMeCollectionViewCell.cellIdentifier)
        _binding.collectionView.register(ChatYouCollectionViewCell.self, forCellWithReuseIdentifier: ChatYouCollectionViewCell.cellIdentifier)
    }

こいつをviewDidLoadで呼んであげます。

ChatViewController.swift
    override func viewDidLoad() {
        super.viewDidLoad()
        initializeView()
    }

ついでにキーボード表示された時のイベントとかアプリがバックグラウンドに行った時のイベントも登録してあげます。

ChatViewController.swift
        let nc = NotificationCenter.default
        nc.addObserver(self, selector: #selector(keyboardWillChangeFrame(_:)), name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
        nc.addObserver(self, selector: #selector(applicationWillEnterForeground), name: NSNotification.Name(rawValue: "applicationWillEnterForeground"), object: nil)
        nc.addObserver(self, selector: #selector(applicationDidBecomeActive), name: NSNotification.Name(rawValue: "applicationDidBecomeActive"), object: nil)

メッセージの送受信系のメソッドも作成。

ChatViewController.swift
    func sendMessage() {
        _binding.sendBtn.isEnabled = false
        ChatManager.instance.sendMessage(message: _binding.messageTextView.text)
    }
    func onReceiveMessage(chatMessage: ChatMessage) {
        _chatMessages.append(chatMessage)
        _binding.collectionView.reloadData()
        let lastIndexPath = IndexPath(row: _chatMessages.count - 1, section: 0)
        _binding.collectionView.scrollToItem(at: lastIndexPath, at: .top, animated: true)
    }
    func didSendMessage() {
        _binding.sendBtn.isEnabled = true
        _binding.messageTextView.text = ""
        hideKeyboard()
    }

キーボードが出て来た時の表示管理。
画面のルートビューがキーボードの上部に接地するように。

ChatViewController.swift
    override func keyboardWillChangeFrame(_ notification: Foundation.Notification){
        var keyboardFrameEnd: CGRect = ((notification as NSNotification).userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! NSValue).cgRectValue
        keyboardFrameEnd = view.convert(keyboardFrameEnd, to: _binding.rootView)
        let animationDuration: TimeInterval = ((notification as NSNotification).userInfo![UIResponder.keyboardAnimationDurationUserInfoKey] as! NSNumber).doubleValue
        _binding.rootView.constraintInfo?.bottomMargin = keyboardFrameEnd.origin.y >= view.frame.size.height ? 0 : keyboardFrameEnd.size.height - safeAreaInsets.bottom
        view.animateWithConstraintInfo(duration: animationDuration)
    }

あとはコレクションビュー関連のメソッドを実装してしまえば完了。
メッセージのセルのheightをメッセージ量に応じて計算してくれるメソッドはChatCollectionViewCellに実装しておきます。
計算用のcellをプロパティで保持しておいてそれを使って計算。本当は計算結果はキャッシュしておいた方が良いのですが、その辺はやらずに毎回計算。

ChatViewController.swift
    func numberOfSections(in collectionView: UICollectionView) -> Int {
        return 1
    }
    
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return _chatMessages.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let chatMessage = _chatMessages[indexPath.row]
        let cellIdentifier = chatMessage.isMe ? ChatMeCollectionViewCell.cellIdentifier : ChatYouCollectionViewCell.cellIdentifier
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellIdentifier, for: indexPath) as! ChatCollectionViewCell
        cell.applyData(chat: chatMessage)
        return cell
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAtIndexPath indexPath: IndexPath) -> CGSize {
        let height = _demoCell.estimatedHeight(chat: _chatMessages[indexPath.row])
        return CGSize(width: UIScreen.main.bounds.size.width, height: height)
    }

これでビューコントローラー周りは大体完成。

##ビュー作成
まずはメインのビューを作成。

Layouts/chat.json
{
  "type": "View",
  "id": "root_view",
  "width": "matchParent",
  "height": "matchParent",
  "background": "#dcdcdc",
  "orientation": "vertical",
  "direction": "bottomToTop",
  "canTap": false,
  "onclick": "hideKeyboard",
  "child": [
    {
      "type": "View",
      "id": "message_area",
      "width": "matchParent",
      "height": "wrapContent",
      "orientation": "horizontal",
      "direction": "rightToLeft",
      "paddings": [
        10.0,
        15.0
      ],
      "child": [
        {
          "type": "Button",
          "id": "send_btn",
          "width": 80.0,
          "height": 50.0,
          "centerVertical": true,
          "background": "#01DF01",
          "cornerRadius": 4.0,
          "fontColor": "#ffffff",
          "text": "送信",
          "onclick": "sendMessage"
        },
        {
          "type": "TextView",
          "id": "message_text_view",
          "width": "matchParent",
          "rightMargin": 15.0,
          "minHeight": 50.0,
          "maxHeight": 250.0,
          "flexible": true,
          "background": "#ffffff",
          "cornerRadius": 4.0,
          "containerInset": [
            15.0
          ]
        }
      ]
    },
    {
      "type": "Collection",
      "id": "collection_view",
      "width": "matchParent",
      "height": "matchParent",
      "background": "#f0f0f0"
    }
  ]
}

画面表示はこんな感じ。
スクリーンショット 2018-10-19 18.09.33.png

次に自分側のメッセージを表示するビューを作成。

chat_me_cell.json
{
  "type": "View",
  "width": "matchParent",
  "height": "matchParent",
  "paddings": [
    0,
    10.0,
    20.0
  ],
  "child": [
    {
      "type": "Label",
      "id": "message_label",
      "height": "matchParent",
      "alignRight": true,
      "maxWidth": 250.0,
      "edgeInset": [
        15.0,
        20.0
      ],
      "background": "#ffffff",
      "cornerRadius": 4.0,
      "clipToBounds": true,
      "binding": "message"
    }
  ]
}

次は相手側。

chat_you_cell.json
{
  "type": "View",
  "width": "matchParent",
  "height": "matchParent",
  "paddings": [
    0,
    10.0
  ],
  "child": [
    {
      "type": "Label",
      "id": "message_label",
      "maxWidth": 250.0,
      "edgeInset": [
        15.0,
        20.0
      ],
      "background": "#ffffff",
      "cornerRadius": 4.0,
      "clipToBounds": true,
      "binding": "message"
    }
  ]
}

これで一通り完成!
実際に動かしてみる。
ezgif-4-3211ffaabad6.gif

とりあえず動いていることが確認できたのでオッケー!
今回のサンプルコードはこちらから確認できます。

4
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?