当前位置: > > > Swift - 自定义单元格实现微信聊天界面

Swift - 自定义单元格实现微信聊天界面

(本文代码已升级至Swift3)

1,下面是一个放微信聊天界面的消息展示列表,实现的功能有:
(1)消息可以是文本消息也可以是图片消息
(2)消息背景为气泡状图片,同时消息气泡可根据内容自适应大小
(3)每条消息旁边有头像,在左边表示发送方,在右边表示接收方

2,实现思路
(1)需要定义一个数据结构保存消息内容 MessageItem
(2)继承UITableViewCell实现自定义单元格,这里面放入头像和消息体
(3)继承UITableView实现自定义表格,通过读取数据源,进行页面的渲染
(4)消息体根据内容类型不同,用不同的展示方法
(5)每个单元格的高度需要根据内容计算出来
(6)数据由ViewController来提供初始化数据

3,效果图
  

4,代码结构


5,主要代码
(1)主页面 ViewController.swift
import UIKit

class ViewController: UIViewController, ChatDataSource {
    
    var Chats:Array<MessageItem>!
    var tableView:TableView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        setupChatTable()
    }
    
    /*创建表格及数据*/
    func setupChatTable() {
        self.tableView = TableView(frame:self.view.bounds, style: .plain)
        
        //创建一个重用的单元格
        self.tableView!.register(TableViewCell.self, forCellReuseIdentifier: "MsgCell")
        
        let me = "xiaoming.png"
        let you = "xiaohua.png"
        
        let first =  MessageItem(body:"你看这风景怎么样,我周末去苏州拍的!", logo:me,
                                 date:Date(timeIntervalSinceNow:-600), mtype:.mine)
        
        let second =  MessageItem(image:UIImage(named:"sz.png")!,logo:me,
                                  date:Date(timeIntervalSinceNow:-290), mtype:.mine)
        
        let third =  MessageItem(body:"太赞了,我也想去那看看呢!",logo:you,
                                 date:Date(timeIntervalSinceNow:-60), mtype:.someone)
        
        let fouth =  MessageItem(body:"嗯,下次我带你去吧!",logo:me,
                                 date:Date(timeIntervalSinceNow:-20), mtype:.mine)
        
        let fifth =  MessageItem(body:"三年了,我终究没能看到这个风景",logo:you,
                                 date:Date(timeIntervalSinceNow:0), mtype:.someone)
        
        Chats = [first,second, third, fouth, fifth]
        
        self.tableView.chatDataSource = self
        self.tableView.reloadData()
        self.view.addSubview(self.tableView)
    }
    
    /*返回对话记录中的全部行数*/
    func rowsForChatTable(_ tableView:TableView) -> Int {
        return self.Chats.count
    }
    
    /*返回某一行的内容*/
    func chatTableView(_ tableView:TableView, dataForRow row:Int) -> MessageItem {
        return Chats[row]
    }
    
    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }
}


(2)消息体数据结构 MessageItem.swift
import UIKit

//消息类型,我的还是别人的
enum ChatType {
    case mine
    case someone
}

class MessageItem {
    //头像
    var logo:String
    //消息时间
    var date:Date
    //消息类型
    var mtype:ChatType
    //内容视图,标签或者图片
    var view:UIView
    //边距
    var insets:UIEdgeInsets
    
    //设置我的文本消息边距
    class func getTextInsetsMine() -> UIEdgeInsets {
        return UIEdgeInsets(top:5, left:10, bottom:11, right:17)
    }
    
    //设置他人的文本消息边距
    class func getTextInsetsSomeone() -> UIEdgeInsets {
        return UIEdgeInsets(top:5, left:15, bottom:11, right:10)
    }
    
    //设置我的图片消息边距
    class func getImageInsetsMine() -> UIEdgeInsets {
        return UIEdgeInsets(top:11, left:13, bottom:16, right:22)
    }
    
    //设置他人的图片消息边距
    class func getImageInsetsSomeone() -> UIEdgeInsets {
        return UIEdgeInsets(top:11, left:13, bottom:16, right:22)
    }
    
    //构造文本消息体
    convenience init(body:NSString, logo:String, date:Date, mtype:ChatType) {
        let font =  UIFont.boldSystemFont(ofSize: 12)
        
        let width =  225, height = 10000.0
        
        let atts =  [NSFontAttributeName: font]
        
        let size =  body.boundingRect(with:
            CGSize(width: CGFloat(width), height: CGFloat(height)),
            options: .usesLineFragmentOrigin, attributes:atts, context:nil)
        
        let label =  UILabel(frame:CGRect(x: 0, y: 0, width: size.size.width,
                                          height: size.size.height))
        
        label.numberOfLines = 0
        label.lineBreakMode = NSLineBreakMode.byWordWrapping
        label.text = (body.length != 0 ? body as String : "")
        label.font = font
        label.backgroundColor = UIColor.clear
        
        let insets:UIEdgeInsets =  (mtype == ChatType.mine ?
            MessageItem.getTextInsetsMine() : MessageItem.getTextInsetsSomeone())
        
        self.init(logo:logo, date:date, mtype:mtype, view:label, insets:insets)
    }
    
    //可以传入更多的自定义视图
    init(logo:String, date:Date, mtype:ChatType, view:UIView, insets:UIEdgeInsets) {
        self.view = view
        self.logo = logo
        self.date = date
        self.mtype = mtype
        self.insets = insets
    }
    
    //构造图片消息体
    convenience init(image:UIImage, logo:String,  date:Date, mtype:ChatType) {
        var size = image.size
        //等比缩放
        if (size.width > 220) {
            size.height /= (size.width / 220);
            size.width = 220;
        }
        let imageView = UIImageView(frame:CGRect(x: 0, y: 0, width: size.width,
                                                 height: size.height))
        imageView.image = image
        imageView.layer.cornerRadius = 5.0
        imageView.layer.masksToBounds = true
        
        let insets:UIEdgeInsets =  (mtype == ChatType.mine ?
            MessageItem.getImageInsetsMine() : MessageItem.getImageInsetsSomeone())
        
        self.init(logo:logo,  date:date, mtype:mtype, view:imageView, insets:insets)
    }    
}



(3)表格数据协议 ChatDataSource.swift
import Foundation

/*
  数据提供协议
*/
protocol ChatDataSource
{
    /*返回对话记录中的全部行数*/
    func rowsForChatTable( _ tableView:TableView) -> Int
    /*返回某一行的内容*/
    func chatTableView(_ tableView:TableView, dataForRow:Int)-> MessageItem
}

(4)自定义表格 TableView.swift
import UIKit

class TableView:UITableView,UITableViewDelegate, UITableViewDataSource
{
    //用于保存所有消息
    var bubbleSection:Array<MessageItem>!
    //数据源,用于与 ViewController 交换数据
    var chatDataSource:ChatDataSource!
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }
    
    override init(frame: CGRect, style: UITableViewStyle) {
        super.init(frame:frame,  style:style)
        
        self.bubbleSection = Array<MessageItem>()
        self.backgroundColor = UIColor.clear
        self.separatorStyle = UITableViewCellSeparatorStyle.none
        self.delegate = self
        self.dataSource = self
    }
    
    override func reloadData() {
        self.showsVerticalScrollIndicator = false
        self.showsHorizontalScrollIndicator = false
        
        var count =  0
        if ((self.chatDataSource != nil))
        {
            count = self.chatDataSource.rowsForChatTable(self)
            
            if(count > 0)
            {
                
                for i in 0 ..< count
                {
                    let object =  self.chatDataSource.chatTableView(self, dataForRow:i)
                    bubbleSection.append(object)
                }
                
                //按日期排序方法
                bubbleSection.sort(by: {
                    $0.date.timeIntervalSince1970 < $1.date.timeIntervalSince1970
                })
            }
        }
        super.reloadData()
    }
    
    //第一个方法返回分区数,在本例中,就是1
    func numberOfSections(in tableView:UITableView)->Int {
        return 1
    }
    
    //返回指定分区的行数
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        if (section >= self.bubbleSection.count)
        {
            return 0
        }
        
        return self.bubbleSection.count
    }
    
    //用于确定单元格的高度,如果此方法实现得不对,单元格与单元格之间会错位
    func tableView(_ tableView:UITableView,heightForRowAt indexPath:IndexPath)
        -> CGFloat {
        let data =  self.bubbleSection[indexPath.row]
        return max(data.insets.top + data.view.frame.size.height + data.insets.bottom, 52)
    }
    
    //返回自定义的 TableViewCell
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath)
        -> UITableViewCell {        
        let cellId = "MsgCell"
        let data =  self.bubbleSection[indexPath.row]
        let cell =  TableViewCell(data:data, reuseIdentifier:cellId)
        return cell
    }
}


(5)自定义单元格 TableViewCell.swift
import UIKit

class TableViewCell:UITableViewCell {
    //消息内容视图
    var customView:UIView!
    //消息背景
    var bubbleImage:UIImageView!
    //头像
    var avatarImage:UIImageView!
    //消息数据结构
    var msgItem:MessageItem!
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }
    
    //- (void) setupInternalData
    init(data:MessageItem, reuseIdentifier cellId:String) {
        self.msgItem = data
        super.init(style: UITableViewCellStyle.default, reuseIdentifier:cellId)
        rebuildUserInterface()
    }
    
    func rebuildUserInterface() {
        
        self.selectionStyle = UITableViewCellSelectionStyle.none
        if (self.bubbleImage == nil)
        {
            self.bubbleImage = UIImageView()
            self.addSubview(self.bubbleImage)
        }
        
        let type =  self.msgItem.mtype
        let width =  self.msgItem.view.frame.size.width
        let height =  self.msgItem.view.frame.size.height
        
        var x =  (type == ChatType.someone) ? 0 : self.frame.size.width - width -
            self.msgItem.insets.left - self.msgItem.insets.right
        
        var y:CGFloat =  0
        //显示用户头像
        if (self.msgItem.logo != "")
        {
            let logo =  self.msgItem.logo
            
            self.avatarImage
                = UIImageView(image:UIImage(named:(logo != "" ? logo : "noAvatar.png")))
            
            self.avatarImage.layer.cornerRadius = 9.0
            self.avatarImage.layer.masksToBounds = true
            self.avatarImage.layer.borderColor = UIColor(white:0.0 ,alpha:0.2).cgColor
            self.avatarImage.layer.borderWidth = 1.0
            
            //别人头像,在左边,我的头像在右边
            let avatarX =  (type == ChatType.someone) ? 2 : self.frame.size.width - 52
            
            //头像居于消息底部
            let avatarY =  height
            //set the frame correctly
            self.avatarImage.frame = CGRect(x: avatarX, y: avatarY, width: 50, height: 50)
            self.addSubview(self.avatarImage)
            
            let delta =  self.frame.size.height - (self.msgItem.insets.top
                + self.msgItem.insets.bottom + self.msgItem.view.frame.size.height)
            if (delta > 0) {
                y = delta
            }
            if (type == ChatType.someone) {
                x += 54
            }
            if (type == ChatType.mine) {
                x -= 54
            }
        }
        
        self.customView = self.msgItem.view
        self.customView.frame = CGRect(x: x + self.msgItem.insets.left,
            y: y + self.msgItem.insets.top, width: width, height: height)
        
        self.addSubview(self.customView)
        
        //如果是别人的消息,在左边,如果是我输入的消息,在右边
        if (type == ChatType.someone)
        {
            self.bubbleImage.image = UIImage(named:("yoububble.png"))!
                .stretchableImage(withLeftCapWidth: 21,topCapHeight:14)
            
        }
        else {
            self.bubbleImage.image = UIImage(named:"mebubble.png")!
                .stretchableImage(withLeftCapWidth: 15, topCapHeight:14)
        }
        self.bubbleImage.frame = CGRect(x: x, y: y,
            width: width + self.msgItem.insets.left + self.msgItem.insets.right,
            height: height + self.msgItem.insets.top + self.msgItem.insets.bottom)
    }
    
    //让单元格宽度始终为屏幕宽
    override var frame: CGRect {
        get {
            return super.frame
        }
        set (newFrame) {
            var frame = newFrame
            frame.size.width = UIScreen.main.bounds.width
            super.frame = frame
        }
    }
}


6,源码下载:hangge_559.zip

功能改进一:消息进行分组、添加消息发送栏

1,效果图
(1)消息按天分组展示
(2)增加消息发送框,可以发送和展示消息

2,源码下载: hangge_559.zip

功能改进二:聊天气泡向下扩展

从前面两个样例的效果图可以看到,如果某条消息内容多的话,该消息用户头像都是处于最下方,聊天气泡看起像是向上扩张。

1,效果图
这里将用户头像位置做个调整,不管消息高度如何,头像总在最上方。这样看起来消息气泡就是向下扩张。

2,源码下载:hangge_559.zip
评论27
  • 27楼
    2017-11-09 19:29
    Eric

    太棒了,感谢!

    站长回复

    不客气。

  • 26楼
    2017-10-11 21:42
    lavender

    我加到项目里后发现每次只能显示一个分组,很是无奈,不知道要改哪里啊

    站长回复

    这个我也帮不了你了,只能靠你自己调试下了。

  • 25楼
    2017-09-30 16:25
    slei

    切换到横屏的时候
    TableView的宽度没有更新,导致mine的用户留言被白色区域遮挡,看不到,代码如下:

    func setupChatTable()
    {
    self.tableView = TableView(frame:CGRect(x: 5, y: 20, width: self.view.frame.size.width, height: self.view.frame.size.height - 76), style: .plain)

    #width: self.view.frame.size.width 这个宽度没更新,横屏的时候,用的还是竖屏的宽度

    站长回复

    是这样的,这个之前我是没有考虑横屏状态下的情况。需要的话你可以自己实现下。

  • 24楼
    2017-09-30 10:23
    slei

    航哥你好,我在模拟器中,把屏幕横过来,聊天的窗口的宽度还是保持竖屏的宽度

    站长回复

    是这样的,我没有做横竖屏切换自适应切换。需要的话你可以自己实现下。

  • 23楼
    2017-09-30 10:09
    slei

    航哥你好,我是一名刚学ios的菜鸟,您写的swift 代码真的是太好了,对我来说帮助很大。现在有个问题想要请教一下,我研究了半天没弄明白,我想更改聊天内容的字体和字体颜色,请问怎么修改呢?谢谢。

    站长回复

    可以参考我之前写的这篇文章中最下面的部分:Swift - 文本标签(UILabel)的用法

  • 22楼
    2017-09-22 09:57
    时光鸡

    下载了demo学习一下,看到聊天的时间和内容突然觉得是一个伤感的故事呢~还是很感谢站长

    站长回复

    哈哈,聊天内容是我瞎写的。欢迎常来看看,我会继续创作更多的文章分享给大家。

  • 21楼
    2017-09-12 18:24
    化城

    请教一下站长,我聊天时,用户的头像需要异步加载,不知道怎么做,我的聊天界面按照你的做的。头像异步不知道怎么加载?能告知一下吗?谢谢

    站长回复

    (1)可以在自定义单元格(TableViewCell.swift)中实现异步加载,具体可以参考我的另一篇文章:Swift - 异步加载各网站的favicon图标,并在单元格中显示

    (2)当然也可使用ImageHelper这个扩展库,可以直接加载网络图片。具体用法参考我的这篇文章:Swift - 图片处理库ImageHelper详解(扩展UIImage,UIImageView)

  • 20楼
    2017-08-31 09:12
    光影

    楼主,有没有swift2.2的demo,这是我的邮箱:flyyison7890@qq.com,谢谢!

    站长回复

    没有了,这个我之前都升级成3的了。

  • 19楼
    2017-04-26 15:24
    坚果

    谢谢回复!好像是因为我的工程名是中文导致的,改成英文名可以正常运行了!谢谢!

    站长回复

    不客气,找到问题就好。

  • 18楼
    2017-04-24 09:28
    坚果

    楼主您好,我是新手,临摹您这个代码,出了一个问题,已经找了好几天了还是没找出来哪错了。
    错误是:在模拟器运行时到Chats = [first,second, third, fouth, fifth]这一句时出现thread1:EXC_BAD_ACCESS错误。然后let first = MessageItem(body:"你看这风景怎么样,我周末去苏州拍的!", logo:me,date:Date(timeIntervalSinceNow:-600), mtype:.mine) 这句代码运行时我通过打印发现它输出的是<<< invalid type >>>

    我新建一个工程然后复制您的代码过来也会出现这个问题,我是新手,是不是我忽略了什么很低级的小细节,或者忘了一些别的设置呢,希望楼主能帮忙想一下,谢谢!

    站长回复

    单看这几个提示信息我也没法定位出问题,这个只能靠你自己调试下了。

  • 17楼
    2017-04-09 11:06
    Jasons

    楼主 请问在class TableViewCell:UITableViewCell {这个类 继承了UITableViewCell
    怎么在这个类点击头像 跳转页面或者 弹一个窗体 谢谢告知一下

    站长回复

    具体看我16楼的回复。

  • 16楼
    2017-04-07 09:42
    Jasons

    头像哪个我已经看到了谢谢
    楼主 就是在 TableViewCell 这个类下 继承了UITableViewCell之后 怎么在这个类里实现点击头像 弹一个窗体出来呀 ,我试过UIAlertController之后总是报不在一个层 。 要怎么做请楼主讲一下 谢谢

    站长回复

    在cell里的点击事件响应中,先获取到当前所在的VC(视图控制器),然后再使用这个VC弹出UIAlertController,或者弹出其他的窗口。
    获取视图控制器方法参考我之前的这篇文章:Swift - 通过UIView对象找到其所在的UIViewController

  • 15楼
    2017-04-04 15:28
    Jasons

    楼主你好! 怎么把气泡向下一直扩张 不是向上 请告知一下

    站长回复

    把头像位置调整下就好了,我文章末尾补充了相关内容,你可以看下。 

  • 14楼
    2017-01-16 16:21
    我来学习

    楼主能不能联系你下 我有关于聊天这个问题

    站长回复

    你可以在网站上给我留言,我都会看到的。

  • 13楼
    2016-07-22 09:40
    程序猪

    楼主您好!感谢您的代码。做为菜鸟程序员想请教您一下,我想把从后台加载的图片添加到右侧用户对话框的imageView里当做头像,不知道要怎样修改,望指导,谢谢!!

    站长回复

    你好。通常来说头像是这么显示。后台返回头像图片的url地址,你把这个地址传给MessageItem对象的logo属性。

    然后修改 TableViewCell.swift 里的47,48行代码,改成从网络加载图片。
    let url = NSURL(string: logo)
    let data = NSData(contentsOfURL: url!)
    let logoImage = UIImage(data: data!)
    self.avatarImage = UIImageView(image:logoImage)

  • 12楼
    2016-06-21 20:22
    酷毙了

    你好!楼主。很喜欢能用上你提供的代码。
    我想请教一下:我现在用[消息推送],如何动态新增到聊天窗口中?
    感谢……^_^

    站长回复

    通常做法是一旦接收到新数据,就重新更新数据集合内容,让后再让tableView执行reloadData()重新刷新下即可。

  • 11楼
    2016-06-02 19:22
    神风

    大神能把聊天记录存在硬盘里面吗,现在这个只是存在内存里面无法实用,新手看了半天代码也不知道该怎么修改法,求帮助谢谢

    站长回复

    你可以将记录存储在数据库中,也可以写到plist文件中:Swift - .plist文件数据的读取和存储

  • 10楼
    2016-05-22 03:57
    神风

    你好,真机无法运行报错,模拟器却不报错
    let data = section[indexPath.row - 1]
    let data = section[indexPath.row] as! MessageItem
    let data = section[indexPath.row - 1]
    这3行报错提示ambiguous use of 'subscript'
    另外界面宽度似乎是固定的,怎么添加约束让它适应各种设备

    站长回复

    真机确实有问题,多谢提醒,现已修复。界面宽度也改成自适应了,主要是TableViewCell.swift做了修改。

  • 9楼
    2016-05-16 13:55
    小伍

    刚好需要,写的很赞,感谢!

    站长回复

    不客气,很高兴能帮助到你。

  • 8楼
    2016-05-10 15:57
    梦痕

    self.tableView.chatDataSource = self 这个是什么意思 为什么要 = self 看不懂 多谢指教

    站长回复

    协议代理模式在iOS开发中很常见,你可以看下我原来写的这篇文章:Swift - 创建代理协议实现页面间参数传递和方法调用

    例子比较简单,看完了应该也能了解它的作用。

  • 7楼
    2016-05-10 15:32
    梦痕

    sendButton.addTarget(self, action:#selector(self.sendMessage) ,forControlEvents:UIControlEvents.TouchUpInside) 提示我改成这样,不知道对不对,但是功能还是好的

    站长回复

    对的,Swift语法更新了,现在这种写法也更安全合理些。有空我也要慢慢把一些文章里的代码更新下。

  • 6楼
    2016-03-28 14:41
    laoqilongxiao

    请教问题 真的很着急 就是swift里面根据label上文字返回 返回label高度的方法?能不能具体点~表示这个网站当教科书用了几个月.

    站长回复

    不太清楚需求。label文字是多行的吗?是要返回label控件的高度,还是里面文字内容的高度?

  • 5楼
    2016-01-14 21:46
    跃月

    好多网站看到过这个,因该是复制楼主的吧。

    站长回复

    确实有些网站是直接从我这复制过去的。这个当时还是用Swift1写的,今天特地将其更新成Swift2的。欢迎常来看看。

  • 4楼
    2016-01-13 15:09
    访客

    hangge功能改进版本运行有两个错误,我的是xcode7.1.1

    站长回复

    多谢提醒,程序写的比较早,所以到了Swift2就有很多地方报错。
    文章和附件现都已修正,你可以再下载下来看看。

  • 3楼
    2016-01-13 10:33
    我不是张三

    撸主表生气,我是看你做的整个网站是大多数都是精品资源.所以对这个上面的很多期望值都很高...

    站长回复

    ...... 好吧,不过样式暂时也不打算调整了,无非就是更换下配色和图片。你可以继续关注我后面写的文章。

  • 2楼
    2016-01-12 09:23
    xyz

    刚学iOS,demo非常好,谢谢!

    站长回复

    不客气,很高兴对你有帮助。

  • 1楼
    2016-01-09 17:33
    我不是张三

    写的也忒难看了一点。。这样式...

    站长回复

    这个是演示实现方法,嫌提供的样例样式难看自己可以调