Swiftでソケット通信するチャットアプリを作ってみた。
iOSにはSIOSocketを、バックエンドにはSocket.ioを使用。
Github
環境
iOS
Swift
SIOSocket https://github.com/MegaBits/SIOSocket
バックエンド
Node.js
Express https://github.com/strongloop/express/stargazers
socket.io https://github.com/Automattic/socket.io
mongodb http://www.mongodb.org/
mongoose https://github.com/LearnBoost/mongoose
あれこれ
iOSでソケット通信するためのライブラリにはSocketRocketなど色々あるがSIOSocketがSwiftと相性良さそう。
ソケット通信の部分以外にも、
キーボードを出すとTexiViewが隠れないようTableViewが上部にずれたり
TextViewが改行で可変になったり
Autolayoutで画面外に隠したcontainerViewをアニメーションで出し入れするなど
何かしら役に立つ部分があると思う。
バックエンドはもうちょっときれいに書きたかったけど今回は面倒なので割愛。
Expressも使う必要なかったけど今後の拡張を考えて使用している。
ソースコード(iOS)
import UIKit
class ViewController: UIViewController, UITextViewDelegate {
@IBOutlet weak var tableView: UITableView!
@IBOutlet weak var inputAreaView: UIView!
@IBOutlet weak var textView: UITextView!
@IBOutlet weak var textViewConstraintHeight: NSLayoutConstraint!
@IBOutlet weak var sendButton: UIButton!
@IBOutlet weak var settingsButton: UIButton!
@IBOutlet weak var settingsView: UIView!
@IBOutlet weak var settingsViewConstraintMarginTop: NSLayoutConstraint!
var settingsViewController:SettingsViewController! = nil
var settingsViewIsDisplayed = false
var messages: Array<MessageModel>?
var socket:SIOSocket! = nil
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
messages = []
// ここがソケット通信するところ
SIOSocket.socketWithHost("ws://localhost:3000", response: { (_socket: SIOSocket!) in
self.socket = _socket
self.socket.onConnect = {() in
println("connected")
self.socket.emit("message init", args: [])
}
self.socket.onReconnect = { (attempts: Int) in
}
self.socket.onDisconnect = {() in
println("disconnected")
}
// メッセージを受信
self.socket.on("message send", callback:{(data:[AnyObject]!) in
let dic = data[0] as NSDictionary
let model = MessageModel(_name: dic["name"] as String, _message: dic["message"] as String)
self.messages?.append(model)
self.tableView.reloadData()
})
// メッセージの初期化
self.socket.on("message init", callback:{(data:[AnyObject]!) in
let arr = data[0] as NSArray
for var i = 0; i < arr.count; i++ {
let dic = arr[i] as NSDictionary
let model = MessageModel(_name: dic["name"] as String, _message: dic["message"] as String)
self.messages?.append(model)
}
self.tableView.reloadData()
// tableviewのスクロールを一番下まで動かす
UIView.animateWithDuration(0.2, delay: 3.0, options: nil, animations: {}, completion: {(finished) -> Void in
let indexPath = NSIndexPath(forRow:(self.tableView.numberOfRowsInSection(0) as Int - 1), inSection: self.tableView.numberOfSections()-1 as Int)
self.tableView.scrollToRowAtIndexPath(indexPath, atScrollPosition: UITableViewScrollPosition.Top, animated: true)
})
})
})
// SettingsViewControllerを取得
self.settingsViewController = self.childViewControllers[0] as SettingsViewController
//SettingsViewControllerを閉じた時
self.settingsViewController.closeMe = {
self.moveSettingsView()
self.settingsViewIsDisplayed = false
}
// tableViewの高さを可変にする
self.tableView.estimatedRowHeight = 49
self.tableView.rowHeight = UITableViewAutomaticDimension
// textviewのスクロールのバグ修正
textView.scrollEnabled = false;
textView.scrollEnabled = true;
// 装飾周り
textView.layer.borderColor = UIColor(white: 0.5, alpha: 0.2).CGColor
textView.layer.borderWidth = 0.5
var inputViewLayer = CALayer()
inputViewLayer.frame = CGRect(x: 0, y: 0, width: inputAreaView.frame.width, height: 1)
inputViewLayer.backgroundColor = UIColor(white: 0.5, alpha: 0.3).CGColor
inputAreaView.layer.addSublayer(inputViewLayer)
sendButton.layer.cornerRadius = 2.0
// イベント系
sendButton.addTarget(self, action: "sended:", forControlEvents: UIControlEvents.TouchUpInside)
settingsButton.addTarget(self, action: "settingsPressed:", forControlEvents: UIControlEvents.TouchUpInside)
NSNotificationCenter.defaultCenter().addObserver(self, selector: Selector("keyboardWillAppear:"), name: UIKeyboardWillShowNotification, object: nil)
NSNotificationCenter.defaultCenter().addObserver(self, selector: Selector("keyboardWillHide:"), name: UIKeyboardWillHideNotification, object: nil)
}
override func viewDidAppear(animated: Bool) {
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// Dispose of any resources that can be recreated.
}
// MARK: - TableViewDelegate
func tableView(tableView: UITableView!, numberOfRowsInSection section: Int) -> Int {
return messages!.count
}
func tableView(tableView: UITableView?, cellForRowAtIndexPath indexPath:NSIndexPath!) -> UITableViewCell! {
var cell: MessageCell = self.tableView.dequeueReusableCellWithIdentifier("MessageCell", forIndexPath: indexPath) as MessageCell
let message = messages![indexPath.row]
cell.setContent(message)
return cell
}
func tableView(tableView: UITableView?, didSelectRowAtIndexPath indexPath:NSIndexPath!) {
}
// MARK: - textFieldDelegate
func textViewDidBeginEditing(textView: UITextView) {
}
func textViewDidEndEditing(textView: UITextView) {
textView.resignFirstResponder()
}
func textViewShouldEndEditing(textView: UITextView) -> Bool {
textView.resignFirstResponder()
return true
}
func textViewDidChange(textView: UITextView) {
// autolayoutのconstraintを変更してtextviewの高さを可変にする
let maxHeight:CGFloat = 60.0
let size = textView.sizeThatFits(textView.frame.size)
if (textView.frame.height < maxHeight) {
self.textViewConstraintHeight.constant = size.height
}
}
func textView(textView: UITextView, shouldChangeTextInRange range: NSRange, replacementText text: String) -> Bool {
return true
}
// MARK: - keyboardNotification
func keyboardWillAppear(notification: NSNotification) {
// settingsViewが表示されている時(TexiField)
if (settingsViewIsDisplayed) {
return
}
// キーボードの高さだけ全体にオフセットを与える
var rect:NSValue
var duration: NSTimeInterval
if let userInfo = notification.userInfo as? Dictionary<String,AnyObject> {
rect = userInfo["UIKeyboardFrameEndUserInfoKey"] as NSValue
duration = userInfo["UIKeyboardAnimationDurationUserInfoKey"] as NSTimeInterval
let transform = CGAffineTransformMakeTranslation(0, -rect.CGRectValue().size.height)
UIView.animateWithDuration(duration, delay: 0.0, options: UIViewAnimationOptions.CurveEaseOut,
animations: {() -> Void in
self.view.transform = transform
}, completion: {(finished) in
})
}
}
func keyboardWillHide(notification: NSNotification) {
// settingsViewが表示されている時(TexiField)
if (settingsViewIsDisplayed) {
return
}
// 与えたオフセットを取り除く
var duration: NSTimeInterval
if let userInfo = notification.userInfo as? Dictionary<String,AnyObject> {
duration = userInfo["UIKeyboardAnimationDurationUserInfoKey"] as NSTimeInterval
UIView.animateWithDuration(duration, delay: 0.0, options: UIViewAnimationOptions.CurveEaseOut,
animations: {() -> Void in
self.view.transform = CGAffineTransformIdentity
}, completion: {(finished) in
})
}
}
// MARK: - Button Event
func sended(sender: UIButton!) {
if !MyUtils().stringHasContent(textView.text) {
return
}
let username = (MyUtils().stringHasContent(MyUtils().username)) ? MyUtils().username! : "Mr. Unknown"
// ソケットにemitする
let model = NSDictionary(dictionary: ["name": username, "message": textView.text, "date": convertDateToStr(NSDate())]);
socket.emit("message send", args:[model] as SIOParameterArray)
// texiviewの高さを元に戻す
textView.text = nil
let size = textView.sizeThatFits(textView.frame.size)
self.textViewConstraintHeight.constant = size.height
textView.resignFirstResponder()
}
private func convertDateToStr(date:NSDate) -> String {
let dateFormatter = NSDateFormatter()
dateFormatter.locale = NSLocale(localeIdentifier: "en_US")
dateFormatter.dateFormat = "yyyy/MM/dd HH:mm:ss"
return dateFormatter.stringFromDate(date)
}
func settingsPressed(sender: UIButton!) {
settingsViewIsDisplayed = true
moveSettingsView()
self.settingsViewController.textField.becomeFirstResponder()
}
func moveSettingsView() {
var offset:CGFloat = 0.0
if (self.settingsViewConstraintMarginTop.constant == 0) {
offset = self.settingsView.frame.height
} else {
offset = 0.0
}
self.view.removeConstraint(self.settingsViewConstraintMarginTop)
self.settingsViewConstraintMarginTop = NSLayoutConstraint(
item: self.view!, attribute: NSLayoutAttribute.Top, relatedBy: NSLayoutRelation.Equal,
toItem: self.settingsView, attribute: NSLayoutAttribute.Top,
multiplier: 1, constant: offset)
self.view.addConstraint(self.settingsViewConstraintMarginTop)
UIView.animateWithDuration(0.24,
delay: 0.0,
options: UIViewAnimationOptions.CurveEaseIn,
animations: {() -> Void in
self.view.layoutIfNeeded()
},
completion: nil)
}
}
ソースコード(バックエンド: Node.js)
#!/usr/bin/env node
var express = require('express');
var debug = require('debug')('socket-example');
var app = express();
var mongoose = require('mongoose');
app.set('port', process.env.PORT || 3000);
//mongoose
var Schema = mongoose.Schema;
var MessagesSchema = new Schema({
name: String,
message: String,
date: Date
});
mongoose.model('messages', MessagesSchema);
mongoose.connect('mongodb://localhost/onechat');
var Messages = mongoose.model('messages');
//socket.io
var http = require('http').Server(app);
var io = require('socket.io')(http);
io.on('connection', function(socket) {
socket.on('disconnect', function() {
console.log('user disconnected');
});
socket.on('message init', function(data) {
console.log('message init');
Messages.find({}).limit(100).exec(function(err, data){
socket.emit('message init', data);
});
});
socket.on('message send', function(data) {
console.log('message send');
data.date = Date.parse(data.date);
var message = new Messages(data);
message.save(function(err, message) {
if (err) return console.error(err);
io.emit('message send', message);
});
});
});
http.listen(app.get('port'), function() {
console.log('listening on *:' + http.address().port);
});