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

注:代码已升级至Swift4

设计需求

微信聊天界面的消息展示列表,实现的功能有:

(1)消息可以是文本消息也可以是图片消息
(2)消息背景为气泡状图片,同时消息气泡可根据内容自适应大小
(3)每条消息旁边有头像,在左边表示发送方,在右边表示接收方
(4)消息按天分组展示
(5)增加消息发送框,可以发送和展示消息

实现思路

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

主要代码

主页面 ViewController.swift

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()
    }

    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

        //Set the delegate so you can respond to user input
        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:UIControlEvents.touchUpInside)
        sendButton.layer.cornerRadius = 6.0
        sendButton.setTitle("发送", for:UIControlState())
        sendView.addSubview(sendButton)
    }

    func textFieldShouldReturn(_ textField:UITextField) -> Bool
    {
        sendMessage()
        return true
    }

    func sendMessage()
    {
        //composing=false
        let sender = txtMsg
        let thisChat = MessageItem(body:sender!.text! as NSString, user:me, date:Date(), mtype:ChatType.mine)
        let thatChat = MessageItem(body:"你说的是:\(sender!.text!)" as NSString, user: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 = ""
    }

    /*创建表格及数据*/
    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" ,logo:("xiaoming.png"))
        you = UserInfo(name:"Xiaohua", logo:("xiaohua.png"))

        let zero = MessageItem(body:"最近去哪玩了?", user:you, date:Date(timeIntervalSinceNow:-90096400), mtype:.someone)

        let zero1 = MessageItem(body:"去了趟杭州,明天发照片给你哈?", user:me, date:Date(timeIntervalSinceNow:-90086400), mtype:.mine)

        let first = MessageItem(body:"你看这风景怎么样,我周末去杭州拍的!", user:me, date:Date(timeIntervalSinceNow:-90000600), mtype:.mine)

        let second = MessageItem(image:UIImage(named:"example.png")!, user:me, date:Date(timeIntervalSinceNow:-90000290), mtype:.mine)

        let third = MessageItem(body:"太赞了,我也想去那看看呢!", user:you, date:Date(timeIntervalSinceNow:-90000060), mtype:.someone)

        let fouth = MessageItem(body:"嗯,下次我们一起去吧!", user:me, date:Date(timeIntervalSinceNow:-90000020), mtype:.mine)

        let fifth = MessageItem(body:"三年了,我终究没能看到这个风景", user:you, date:Date(timeIntervalSinceNow:0), mtype:.someone)

        Chats = NSMutableArray()
        Chats.addObjects(from: [first,second, third, fouth, fifth, zero, zero1])

        //set the chatDataSource
        self.tableView.chatDataSource = self

        //call the reloadData, this is actually calling your override method
        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
    }

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

用户信息类 UserInfo.swift

import Foundation

/*
 * 用户信息类
 */
class UserInfo:NSObject
{
    var username:String = ""
    var avatar:String = ""

    init(name:String, logo:String)
    {
        self.username = name
        self.avatar = logo
    }
}

消息体数据结构 MessageItem.swift

import UIKit

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

class MessageItem {
    //用户信息
    var user:UserInfo
    //消息时间
    var date:Date
    //消息类型
    var mtype:ChatType
    //内容视图,标签或者图片
    var view:UIView
    //边距
    var insets:UIEdgeInsets

    //设置我的文本消息边距
    class func getTextInsetsMine() -> UIEdgeInsets {
        return UIEdgeInsets(top:9, left:10, bottom:9, right:17)
    }

    //设置他人的文本消息边距
    class func getTextInsetsSomeone() -> UIEdgeInsets {
        return UIEdgeInsets(top:9, left:15, bottom:9, right:10)
    }

    //设置我的图片消息边距
    class func getImageInsetsMine() -> UIEdgeInsets {
        return UIEdgeInsets(top:9, left:10, bottom:9, right:17)
    }

    //设置他人的图片消息边距
    class func getImageInsetsSomeone() -> UIEdgeInsets {
        return UIEdgeInsets(top:9, left:15, bottom:9, right:10)
    }

    //构造文本消息体
    convenience init(body:NSString, user:UserInfo, 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(user:user, date:date, mtype:mtype, view:label, insets:insets)
    }

    //可以传入更多的自定义视图
    init(user:UserInfo, date:Date, mtype:ChatType, view:UIView, insets:UIEdgeInsets) {
        self.view = view
        self.user = user
        self.date = date
        self.mtype = mtype
        self.insets = insets
    }

    //构造图片消息体
    convenience init(image:UIImage, user:UserInfo, 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(user:user, date:date, mtype:mtype, view:imageView, insets:insets)
    }
}

表格数据协议 ChatDataSource.swift

import Foundation

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

自定义单元格 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.user.username != "")
        {
            let thisUser = self.msgItem.user
            //self.avatarImage.removeFromSuperview()

            let imageName = thisUser.avatar != "" ? thisUser.avatar : "noAvatar.png"
            self.avatarImage = UIImageView(image:UIImage(named:imageName))

            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:CGFloat = 0
            //set the frame correctly
            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 == 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:("left_bubble.png"))!
                .stretchableImage(withLeftCapWidth: 21, topCapHeight:25)
        } else {
            self.bubbleImage.image = UIImage(named:"right_bubble.png")!
                .stretchableImage(withLeftCapWidth: 15, topCapHeight:25)
        }
        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
        }
    }
}

自定义单元格头部 TableHeaderViewCell.swift

import UIKit

class TableHeaderViewCell: UITableViewCell
{
    var height:CGFloat = 30.0
    var label:UILabel!

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }

    init(reuseIdentifier cellId:String)
    {
        super.init(style: UITableViewCellStyle.default, reuseIdentifier:cellId)
    }

    class func getHeight() -> CGFloat
    {
        return 30.0
    }

    func setDate(_ value:Date)
    {
        self.height = 30.0
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "yyyy年MM月dd日"
        let text = dateFormatter.string(from: value)

        if (self.label != nil)
        {
            self.label.text = text
            return
        }
        self.selectionStyle = UITableViewCellSelectionStyle.none
        self.label = UILabel(frame:CGRect(x: CGFloat(0), y: CGFloat(0), width: self.frame.size.width, height: height))

        self.label.text = text
        self.label.font = UIFont.boldSystemFont(ofSize: 12)

        self.label.textAlignment = NSTextAlignment.center
        self.label.shadowOffset = CGSize(width: 0, height: 1)
        self.label.shadowColor = UIColor.white

        self.label.textColor = UIColor.darkGray

        self.label.backgroundColor = UIColor.clear

        self.addSubview(self.label)
    }
}

自定义表格 TableView.swift

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 typingBubble:ChatBubbleTypingType!

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }

    override init(frame: CGRect, style: UITableViewStyle) {
        //the snap interval in seconds implements a headerview to seperate chats
        self.snapInterval = TimeInterval(60 * 60 * 24) //one day
        self.typingBubble = ChatBubbleTypingType.nobody
        self.bubbleSection = NSMutableArray()

        super.init(frame:frame,  style:style)

        self.backgroundColor = UIColor.clear
        self.separatorStyle = UITableViewCellSeparatorStyle.none
        self.delegate = self
        self.dataSource = self
    }

    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:UITableViewScrollPosition.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
        }
    }

    //第一个方法返回分区数
    func numberOfSections(in tableView:UITableView)->Int {
        var result = self.bubbleSection.count
        if (self.typingBubble != ChatBubbleTypingType.nobody)
        {
            result += 1;
        }
        return result;
    }

    //返回指定分区的行数
    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, 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
    }

    //返回自定义的 TableViewCell
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath)
        -> UITableViewCell {        
        // Header based on snapInterval
        if (indexPath.row == 0)
        {
            let cellId = "HeaderCell"

            let hcell = TableHeaderViewCell(reuseIdentifier:cellId)
            let section = self.bubbleSection[indexPath.section] as! NSMutableArray
            let data = section[indexPath.row] as! MessageItem

            hcell.setDate(data.date)
            return hcell
        }
        // Standard
        let cellId = "ChatCell"

        let section = self.bubbleSection[indexPath.section] as! NSMutableArray
        let data = section[indexPath.row - 1]

        let cell = TableViewCell(data:data as! MessageItem, reuseIdentifier:cellId)

        return cell
    }
}

引用素材

left_bubble

right_bubble

版权声明:
作者:Joe.Ye
链接:https://www.appblog.cn/index.php/2023/02/25/swift-ui-customize-cells-to-realize-wechat-chat-view/
来源:APP全栈技术分享
文章版权归作者所有,未经允许请勿转载。

THE END
分享
二维码
打赏
海报
Swift UI – 自定义单元格实现微信聊天界面
注:代码已升级至Swift4 设计需求 微信聊天界面的消息展示列表,实现的功能有: (1)消息可以是文本消息也可以是图片消息 (2)消息背景为气泡状图片,同……
<<上一篇
下一篇>>
文章目录
关闭
目 录