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

(本文代码已升级至Swift5)

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

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


image.png

4、代码结构


image.png

5、主要代码
ViewController:
import UIKit

class ViewController: UIViewController, ChatDataSource,UITextFieldDelegate {
    
    var Chats:NSMutableArray!
    var tableView:TableView!
    var me:UserInfo!
    var you:UserInfo!
    var txtMsg:UITextField!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        setupChatTable()
        setupSendPanel()
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }
}

//MARK:设置输入框
extension ViewController
{
    func setupSendPanel()
    {
        let screenWidth = UIScreen.main.bounds.width
        let sendView = UIView(frame:CGRect(x: 0,y: self.view.frame.size.height - 56,width: screenWidth,height: 56))
        
        sendView.backgroundColor=UIColor.lightGray
        sendView.alpha=0.9
        
        txtMsg = UITextField(frame:CGRect(x: 7,y: 10,width: screenWidth - 95,height: 36))
        txtMsg.backgroundColor = UIColor.white
        txtMsg.textColor=UIColor.black
        txtMsg.font=UIFont.boldSystemFont(ofSize: 12)
        txtMsg.layer.cornerRadius = 10.0
        txtMsg.returnKeyType = UIReturnKeyType.send
        
        txtMsg.delegate=self
        sendView.addSubview(txtMsg)
        self.view.addSubview(sendView)
        
        let sendButton = UIButton(frame:CGRect(x: screenWidth - 80,y: 10,width: 72,height: 36))
        sendButton.backgroundColor=UIColor(red: 0x37/255, green: 0xba/255, blue: 0x46/255, alpha: 1)
        sendButton.addTarget(self, action:#selector(ViewController.sendMessage) ,
                             for:UIControl.Event.touchUpInside)
        sendButton.layer.cornerRadius=6.0
        sendButton.setTitle("发送", for:UIControl.State())
        sendView.addSubview(sendButton)
    }
    
    func textFieldShouldReturn(_ textField:UITextField) -> Bool
    {
        sendMessage()
        return true
    }
    
    @objc func sendMessage()
    {
        //composing=false
        let sender = txtMsg
        let thisChat =  MessageItem(body:(sender!.text! as NSString) as String, userInfo:me, date:Date(), mtype:ChatType.mine)
        let thatChat =  MessageItem(body:("你说的是:\(sender!.text!)" as NSString) as String, userInfo:you, date:Date(), mtype:ChatType.someone)
        
        Chats.add(thisChat)
        Chats.add(thatChat)
        self.tableView.chatDataSource = self
        self.tableView.reloadData()
        
        //self.showTableView()
        sender?.resignFirstResponder()
        sender?.text = ""
    }
}

//MARK:设置聊天假数据以及数据源
extension ViewController
{
    
     func setupChatTable()
     {
         self.tableView = TableView(frame:CGRect(x: 0, y: 20, width: self.view.frame.size.width, height: self.view.frame.size.height - 76), style: .plain)
         
         //创建一个重用的单元格
         self.tableView!.register(TableViewCell.self, forCellReuseIdentifier: "ChatCell")
         me = UserInfo(name:"Xiaoming" ,avatar:("xiaoming.png"))
         you  = UserInfo(name:"Xiaohua", avatar:("xiaohua.png"))
         
         let zero =  MessageItem(body:"最近去哪玩了?", userInfo:you,  date:Date(timeIntervalSinceNow:-90096400), mtype:.someone)
         
         let zero1 =  MessageItem(body:"去了趟苏州,明天发照片给你哈?", userInfo:me,  date:Date(timeIntervalSinceNow:-90086400), mtype:.mine)
         
         let first =  MessageItem(body:"你看这风景怎么样,我周末去苏州拍的!", userInfo:me,  date:Date(timeIntervalSinceNow:-90000600), mtype:.mine)
         
         let second =  MessageItem(image:UIImage(named:"sz.png")!,userInfo:me, date:Date(timeIntervalSinceNow:-90000290), mtype:.mine)
         
         let third =  MessageItem(body:"太赞了,我也想去那看看呢!",userInfo:you, date:Date(timeIntervalSinceNow:-90000060), mtype:.someone)
         
         let fouth =  MessageItem(body:"嗯,下次我们一起去吧!",userInfo:me, date:Date(timeIntervalSinceNow:-90000020), mtype:.mine)
         
         let fifth =  MessageItem(body:"三年了,我终究没能看到这个风景",userInfo:you, date:Date(timeIntervalSinceNow:0), mtype:.someone)
         

         Chats = NSMutableArray()
         Chats.addObjects(from: [first,second, third, fouth, fifth, zero, zero1])
         
         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] as! MessageItem
     }
}

TableView:

import UIKit

enum ChatBubbleTypingType {
    case nobody
    case me
    case somebody
}

class TableView: UITableView, UITableViewDelegate, UITableViewDataSource {
    
    // 聊天数据
    var bubbleSection: NSMutableArray!
    // 数据源,与viewController交互数据
    var chatDataSource: ChatDataSource!
    
    var snapInterval: TimeInterval!
    var bubbleTypingType: ChatBubbleTypingType!
    
    

    override init(frame: CGRect, style: UITableView.Style) {
        self.snapInterval = TimeInterval(60 * 60 * 24)
        self.bubbleTypingType = ChatBubbleTypingType.nobody
        self.bubbleSection = NSMutableArray()
        
        super.init(frame: frame, style: style)
        
        self.backgroundColor = UIColor.clear
        self.separatorStyle = UITableViewCell.SeparatorStyle.none
        self.delegate = self
        self.dataSource = self
        
        
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func reloadData()
    {
        self.showsVerticalScrollIndicator = false
        self.showsHorizontalScrollIndicator = false
        self.bubbleSection = NSMutableArray()
        var count =  0
        if ((self.chatDataSource != nil))
        {
            count = self.chatDataSource.rowsForChatTable(self)
            
            if(count > 0)
            {
                let bubbleData =  NSMutableArray(capacity:count)
                
                for i in 0 ..< count
                {
                    let object =  self.chatDataSource.chatTableView(self, dataForRow:i)
                    bubbleData.add(object)
                }
                bubbleData.sort(comparator: sortDate)
                
                var last =  ""
                
                var currentSection = NSMutableArray()
                // 创建一个日期格式器
                let dformatter = DateFormatter()
                // 为日期格式器设置格式字符串
                dformatter.dateFormat = "dd"
                
                for i in 0 ..< count
                {
                    let data =  bubbleData[i] as! MessageItem
                    // 使用日期格式器格式化日期,日期不同,就新分组
                    let datestr = dformatter.string(from: data.date as Date)
                    if (datestr != last)
                    {
                        currentSection = NSMutableArray()
                        self.bubbleSection.add(currentSection)
                    }
                    (self.bubbleSection[self.bubbleSection.count-1] as AnyObject).add(data)
                    
                    last = datestr
                }
            }
        }
        super.reloadData()
        
        //滑向最后一部分
        let secno = self.bubbleSection.count - 1
        let indexPath =  IndexPath(row:(self.bubbleSection[secno] as AnyObject).count,section:secno)
        
        self.scrollToRow(at: indexPath,                at:UITableView.ScrollPosition.bottom,animated:true)
    }
    
    //按日期排序方法
    func sortDate(_ m1: Any, m2: Any) -> ComparisonResult {
        if((m1 as! MessageItem).date.timeIntervalSince1970 < (m2 as! MessageItem).date.timeIntervalSince1970)
        {
            return ComparisonResult.orderedAscending
        }
        else
        {
            return ComparisonResult.orderedDescending
        }
    }
    
    
}

//MARK:数据源 & 代理方法
extension TableView {
    
    //用于确定单元格的高度,如果此方法实现得不对,单元格与单元格之间会错位
    func tableView(_ tableView:UITableView,heightForRowAt indexPath:IndexPath)
        -> CGFloat {
        // Header
        if (indexPath.row == 0)
        {
            return TableHeaderViewCell.getHeight()
        }
        let section  =  self.bubbleSection[indexPath.section] as! NSMutableArray
        let data = section[indexPath.row - 1]
        
        let item =  data as! MessageItem
        let height  =  max(item.insets.top + item.view.frame.size.height  + item.insets.bottom, 52) + 17
        print("height:\(height)")
        return height
    }
    
    
    func numberOfSections(in tableView: UITableView) -> Int {
        var count = self.bubbleSection.count
        if self.bubbleTypingType != .nobody {
            count += 1
        }
        return count
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        if (section >= self.bubbleSection.count)
        {
            return 1
        }
        
        return (self.bubbleSection[section] as AnyObject).count + 1
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        if indexPath.row == 0 {
            let cellIdentifier = "headerCell"
            let cell = TableHeaderViewCell(reuseIdentifier: cellIdentifier)
            let section = self.bubbleSection[indexPath.section] as! NSMutableArray
            let data = section[indexPath.row ] as! MessageItem
            cell.setDate(data.date)
            return cell
        }
        let cellIdentifier = "ChatCell"
        let section = self.bubbleSection[indexPath.section] as! NSMutableArray
        let data = section[indexPath.row - 1] as! MessageItem
        let cell = TableViewCell(data: data, reuseIdentifier: cellIdentifier)
        return cell
    }
}

TableViewCell:

import UIKit

class TableViewCell: UITableViewCell {
    
    //消息内容视图
    var customView:UIView!
    //消息背景
    var bubbleImage:UIImageView!
    //头像
    var avatarImage:UIImageView!
    //消息数据结构
    var msgItem:MessageItem!
    

    init(data: MessageItem, reuseIdentifier: String)
    {
        self.msgItem = data
        super.init(style: UITableViewCell.CellStyle.default, reuseIdentifier: reuseIdentifier)
        rebuildUserInterface()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
    }
    
    func rebuildUserInterface()
    {
        self.selectionStyle = .none
        if self.bubbleImage == nil {
            self.bubbleImage = UIImageView()
            self.addSubview(self.bubbleImage)
        }
        
        let type = msgItem.chatType
        let width = msgItem.view.frame.size.width
        let height = msgItem.view.frame.size.height
        
        var x = (type == .someone) ? 0 : self.frame.size.width - msgItem.insets.left - msgItem.insets.right - width
        var y: CGFloat = 0
        
        if (msgItem.userInfo.username != "")
        {
            let user = msgItem.userInfo
            
            let imageName = user.avatar == "" ? "noAvatar.png" : user.avatar
            self.avatarImage = UIImageView(image: UIImage(named: imageName))
            
            self.avatarImage.layer.cornerRadius = 10
            self.avatarImage.layer.masksToBounds = true
            self.avatarImage.layer.borderColor = UIColor(white: 0.0, alpha: 0.2).cgColor
            self.avatarImage.layer.borderWidth = 1.0
            
            //计算x:别人头像,在左边,我的头像在右边
            let avatarX: CGFloat = (type == .someone) ? 2 : self.frame.size.width - 52;
            //头像居于消息顶部
            let avatarY: CGFloat = 0.0;
            self.avatarImage.frame = CGRect(x: avatarX, y: avatarY, width: 50, height: 50)
            self.addSubview(self.avatarImage)
            
             //如果只有一行消息(消息框高度不大于头像)则将消息框居中于头像位置
            let delta = (50 - (self.msgItem.insets.top + self.msgItem.insets.bottom + self.msgItem.view.frame.size.height)) / 2
            if delta > 0 {
                y = delta
            }
            
            if (type == .someone)
            {
                x += 54
            } else {
                x -= 54
            }
            
            self.customView = msgItem.view
            self.customView.frame = CGRect(x: x + msgItem.insets.left, y: y + msgItem.insets.top, width: width, height: height)
            self.addSubview(self.customView)
            
            //如果是别人的消息,在左边,如果是我输入的消息,在右边
            if type == .mine {
                self.bubbleImage.image = UIImage(named:"mebubble.png")!
                .stretchableImage(withLeftCapWidth: 15, topCapHeight:25)
            } else {
                self.bubbleImage.image = UIImage(named:("yoububble.png"))!
                .stretchableImage(withLeftCapWidth: 21,topCapHeight:25)
            }
            
            self.bubbleImage.frame = CGRect(x: x, y: y, width: width + msgItem.insets.left + msgItem.insets.right, height: height + msgItem.insets.top + msgItem.insets.bottom)
            
        }
    }
    
    // 让单元格宽度始终为屏幕宽
    override var frame: CGRect {
        get {
            return super.frame
        }
        set (newFrame) {
            var frame = newFrame
            frame.size.width = UIScreen.main.bounds.size.width
            super.frame = frame
        }
    }
    
}

源码地址:https://gitee.com/dong_li/ChatDemo
原文出自:https://www.hangge.com/blog/cache/detail_559.html

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,165评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,720评论 1 298
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,849评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,245评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,596评论 3 288
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,747评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,977评论 2 315
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,708评论 0 204
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,448评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,657评论 2 249
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,141评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,493评论 3 258
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,153评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,108评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,890评论 0 198
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,799评论 2 277
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,685评论 2 272