Sự cố tính toán String Boundrect khi chuỗi chứa '\ n'

Sep 23 2020

Tôi phát triển một tính năng kiểu Whatsapp bên trong ứng dụng của chúng tôi. tin nhắn được phân tích cú pháp từ JSON và sau đó được tạo dưới dạng tin nhắn văn bản (+ hình ảnh tùy chọn) bên trong UITableView (mỗi tin nhắn là một ô tùy chỉnh). Bong bóng thông báo được vẽ bằng cách sử dụng Bezier Path, dựa trên việc tính toán khung văn bản bằng cách sử dụng phương thức bindingRect. Sau đó, UILabel và UIImage được thêm vào dưới dạng các lượt xem phụ của UIStackview và cả Chế độ xem StackView và chế độ xem bong bóng thông báo đều bị giới hạn ở chế độ xem vùng chứa.

Đôi khi khi văn bản chứa '\ n', UILabel bị cắt (với '...') hoặc chảy xuống bên dưới chế độ xem bong bóng thư, tùy thuộc vào mức độ ưu tiên neo dưới cùng của chế độ xem ngăn xếp (cao hơn / thấp hơn mức độ ưu tiên ôm sát nội dung của UILabel ), nhưng các thư khác có chứa dòng mới xuất hiện chính xác. Tôi đoán là phép tính khung của chuỗi coi '\ n' là 2 ký tự thay vì một dòng mới.

Khi tôi thử kiểm tra cùng một mã trong một sân chơi (với bố cục đơn giản hơn, chỉ có nhãn và chế độ xem bong bóng, không có chế độ xem vùng chứa, không có chế độ xem bảng và không có ràng buộc) mọi thứ dường như hoạt động tốt và bong bóng sẽ tự mở rộng để thích ứng với các dòng mới được thêm vào.

Dựa trên chủ đề này, tôi đã thử thay thế mã bằng Phương thức sizeThatFits, kết quả vẫn tương tự. Cuối cùng, tôi đã kết thúc việc đếm số lần xuất hiện của '\ n' bên trong chuỗi và thêm chiều cao vào khung theo cách thủ công, nhưng nó ảnh hưởng đến cả thông báo xấu và thông báo tốt, hiện có thêm không gian xung quanh chúng.

Ảnh chụp màn hình, mã liên quan và nhật ký bảng điều khiển được đính kèm bên dưới. Hy vọng rằng, nó sẽ giúp ai đó tìm ra điều này.

Chỉnh sửa: thay đổi độ rộng của messageView từ UIScreen.main.bounds.width * 0.73 thành UIScreen.main.bounds.width * 0.8 đã khắc phục sự cố. Tuy nhiên tôi vẫn không thể tìm ra lý do tại sao nó chỉ ảnh hưởng đến các tin nhắn cụ thể. Tôi sẽ biết ơn về bất kỳ thông tin nào khác liên quan đến điều này.

ChatMessageModel.swift

fileprivate func setText(_ label: ClickableUILabel, _ text: String, _ shouldLimitSize: Bool, _  shouldOpenLinks: Bool) {
    
    ...

    // set text frame
    let textFrameHeight: CGFloat = shouldLimitSize ? 40.0 : .greatestFiniteMagnitude
    
    let constraintRect = CGSize(width: innerContentWidth, height: textFrameHeight)
    
    let boundingBox = text.boundingRect(with: constraintRect,
                                        options: .usesLineFragmentOrigin,
                                        attributes: [.font: label.font!],
                                        context: nil)
    
    // width must have minimum value for short text to appear centered
    let widthCeil = ceil(boundingBox.width)
    let constraintWidthWithInset = constraintRect.width - 30
    
    var height: CGFloat
    
    if text.isEmpty {
        height = 0
    } else {
        // min value of 40
        height = max(ceil(boundingBox.height), 40) + 5
    }
  
    // ***** This part fixes bad messages but messes up good messages ****

    // add extra height for newLine inside text
    if let newLineCount = label.text?.countInstances(of: "\n"), newLineCount > 0 {
        LOG("found \n")
        height += CGFloat((newLineCount * 8))
    }

    label.frame.size = CGSize(width:max(widthCeil, constraintWidthWithInset),
                              height: height)
    label.setContentHuggingPriority(UILayoutPriority(200), for: .horizontal)
}
fileprivate func setTextBubble(_ label: UILabel, _ image: String?, _ video: String?, _ shouldLimitSize: Bool) -> CustomRoundedCornerRectangle  {
        
        // configure bubble size
        
        var contentHeight = CGFloat()
        
        if imageDistribution! == .alongsideText {
            contentHeight = max(label.frame.height, contentImageView.frame.height)
        } else {
            contentHeight = label.frame.height + contentImageView.frame.height + 20
        }
        
        // messages with no text on main feed should have smaller width
        let width: CGFloat = shouldLimitSize && (label.text ?? "").isEmpty ? 150.0 : UIScreen.main.bounds.width * 0.73
        let bubbleFrame = CGRect(x: 0, y: 0, width: width, height: contentHeight + 20)
        
        let messageView = CustomRoundedCornerRectangle(frame: bubbleFrame)
        messageView.heightAnchor.constraint(equalToConstant: bubbleFrame.size.height).isActive = true
        messageView.widthAnchor.constraint(equalToConstant: bubbleFrame.size.width).isActive = true
        messageView.translatesAutoresizingMaskIntoConstraints = false
        
        self.messageViewFrame = bubbleFrame
        
        return messageView
    }
fileprivate func layoutSubviews(_ containerView: UIView, _ messageView: CustomRoundedCornerRectangle, _ timeLabel: UILabel, _ profileImageView: UIImageView, _ profileName: UILabel, _ label: UILabel, _ contentImageView: CustomImageView, _ imagePlacement: imagePlacement) {
        
        // container view
        containerView.addSubview(messageView)
        containerView.translatesAutoresizingMaskIntoConstraints = false
        
        containerView.autoSetDimension(.width, toSize: UIScreen.main.bounds.width * 0.8)
        containerView.autoPinEdge(.bottom, to: .bottom, of: messageView)
        messageView.autoPinEdge(.top, to: .top, of: containerView, withOffset: 23)
        
        // time label
        containerView.addSubview(timeLabel)
        timeLabel.autoPinEdge(.bottom, to: .top, of: messageView)
        timeLabel.autoPinEdge(.leading, to: .leading, of: containerView, withOffset: -2)
        
        // profile image
        containerView.addSubview(profileImageView)
        profileImageView.autoPinEdge(.trailing, to: .trailing, of: containerView, withOffset: 15)
        profileImageView.autoPinEdge(.top, to: .top, of: containerView, withOffset: 30)
        messageView.autoPinEdge(.trailing, to: .leading, of: profileImageView, withOffset: 15)
        
        // profile name
        containerView.addSubview(profileName)
        profileName.autoAlignAxis(.horizontal, toSameAxisOf: timeLabel)
        profileName.autoPinEdge(.trailing, to: .trailing, of: messageView, withOffset: -2)
        
        if isSameAuthor {
            profileName.isHidden = true
            profileImageView.isHidden = true
        }
        
        // content stack view
        let contenStackView = UIStackView(forAutoLayout: ())
        messageView.addSubview(contenStackView)
        
        if imageDistribution! == .alongsideText {
            contenStackView.axis = NSLayoutConstraint.Axis.horizontal
            contenStackView.alignment = UIStackView.Alignment.center
        } else {
            contenStackView.axis = NSLayoutConstraint.Axis.vertical
            contenStackView.alignment = UIStackView.Alignment.trailing
        }
        
        contenStackView.spacing = 5.0
        contenStackView.autoPinEdge(.leading, to: .leading, of: messageView, withOffset: 15)
        contenStackView.autoPinEdge(.trailing, to: .trailing, of: messageView, withOffset: -40)
        contenStackView.autoPinEdge(.top, to: .top, of: messageView, withOffset: 10)

        let bottomConstraint = contenStackView.bottomAnchor.constraint(equalTo: messageView.bottomAnchor, constant: -10)
        bottomConstraint.priority = UILayoutPriority(800)
        bottomConstraint.isActive = true
        
        
        //Add Chat image and Message
        contenStackView.addArrangedSubview(contentImageView)
        if imagePlacement == .alongsideText || !label.text!.isEmpty { // do not insert empty labels if above text
            contenStackView.addArrangedSubview(label)
        }
    }

CustromRoundsCorenerRectangle.swift

class CustomRoundedCornerRectangle: UIView {
    lazy var shapeLayer = CAShapeLayer()
    var frameToUse: CGRect?
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setup(frame: frame)
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setup(frame: CGRect(x: 0, y: 0, width: 300, height: 100))
    }
    
    func setup(frame: CGRect) {
        
        // keep frame for later use
        frameToUse = frame
        
        // create CAShapeLayer
                
        // apply other properties related to the path
        shapeLayer.fillColor = UIColor.white.cgColor
        shapeLayer.lineWidth = 1.0
        shapeLayer.strokeColor = UIColor(red: 212/255, green: 212/255, blue: 212/255, alpha: 1.0).cgColor
        shapeLayer.position = CGPoint(x: 0, y: 0)
        
        // add the new layer to our custom view
        self.layer.addSublayer(shapeLayer)
    }
    
    func updateBezierPath(frame: CGRect) {
        let path = UIBezierPath()
        let largeCornerRadius: CGFloat = 18
        let smallCornerRadius: CGFloat = 10
        let upperCornerSpacerRadius: CGFloat = 2
        let imageToArcSpace: CGFloat = 5
        var rect = frame
        
        // bezier frame is smaller than messageView frame
        rect.size.width -= 20
        
        // move to starting point
        path.move(to: CGPoint(x: rect.minX + smallCornerRadius, y: rect.maxY))
       
        // draw bottom left corner
        path.addArc(withCenter: CGPoint(x: rect.minX + smallCornerRadius, y: rect.maxY - smallCornerRadius), radius: smallCornerRadius,
                    startAngle: .pi / 2, // straight down
                    endAngle: .pi, // straight left
                    clockwise: true)
        
        // draw left line
        path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + smallCornerRadius))
        
        // draw top left corner
        path.addArc(withCenter: CGPoint(x: rect.minX + smallCornerRadius, y: rect.minY + smallCornerRadius), radius: smallCornerRadius,
            startAngle: .pi, // straight left
            endAngle: .pi / 2 * 3, // straight up
            clockwise: true)
        
        // draw top line
        path.addLine(to: CGPoint(x: rect.maxX - largeCornerRadius, y: rect.minY))
        
        // draw concave top right corner
        // first arc
        path.addArc(withCenter: CGPoint(x: rect.maxX + largeCornerRadius, y: rect.minY + upperCornerSpacerRadius), radius: upperCornerSpacerRadius, startAngle: .pi / 2 * 3, // straight up
            endAngle: .pi / 2, // straight left
            clockwise: true)
        
        // second arc
        path.addArc(withCenter: CGPoint(x: rect.maxX + largeCornerRadius + imageToArcSpace, y: rect.minY + largeCornerRadius + upperCornerSpacerRadius * 2 + imageToArcSpace), radius: largeCornerRadius + imageToArcSpace, startAngle: CGFloat(240.0).toRadians(), // up with offset
            endAngle: .pi, // straight left
            clockwise: false)
        
        // draw right line
        path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - smallCornerRadius))
        
        // draw bottom right corner
        path.addArc(withCenter: CGPoint(x: rect.maxX - smallCornerRadius, y: rect.maxY - smallCornerRadius), radius: smallCornerRadius,
                    startAngle: 0, // straight right
            endAngle: .pi / 2, // straight down
                    clockwise: true)
        
        // draw bottom line to close the shape
        path.close()
        
        shapeLayer.path = path.cgPath
    }
}

extension CGFloat {
    func toRadians() -> CGFloat {
        return self * CGFloat(Double.pi) / 180.0
    }
}

CustomChatTableViewCell.swift

class ChatMessageCell: UITableViewCell {
    
    let horizontalInset: CGFloat = 30.0
    let bottomInset: CGFloat = 10.0
    var topInset: CGFloat = 5.0
    
    var didSetupConstraints = false
 
    var messageObject: ChatMessageModel?
    weak var delegate: Notify?
    
    
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }
    
    // what we will call from our tableview method
    func configure(with item: ChatItem?, previousItem: ChatItem?, delegate: Notify?) {
 
        if let safeItem = item {

            messageObject = ChatMessageModel().createMessage(chatItem: safeItem, previousItem: previousItem, shouldLimitSize: false,shouldAddMediaTap: true, imagePlacement: .aboveText, shouldOpenLinks: true)
            messageObject?.delegate = delegate

            let messageContainerView = messageObject?.containerView
            contentView.addSubview(messageContainerView!)
            contentView.backgroundColor = .clear
            backgroundColor = .clear
            selectionStyle = .none
            
            // pin together messages from same author
            if safeItem.user?.name == previousItem?.user?.name {
                topInset = -10.0
            } else {
                topInset = 5.0
            }
            
            messageContainerView?.autoPinEdge(toSuperviewEdge: .top, withInset: topInset)
            messageContainerView?.autoAlignAxis(.vertical, toSameAxisOf: contentView, withOffset: 0)
            messageContainerView?.autoPinEdge(toSuperviewEdge: .bottom, withInset: bottomInset)
        }
    }
    
    override func prepareForReuse() {
        messageObject?.containerView.removeFromSuperview()
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        
        // redraw message background        
        messageObject?.messageView?.updateBezierPath(frame: (messageObject!.messageView!.frameToUse!))
    }
}

nhật ký của tin nhắn bị cắt giảm:

(
"<NSLayoutConstraint:0x600000294960 Sport5.CustomRoundedCornerRectangle:0x7f9af3c9e990.height == 89   (active)>",
"<NSLayoutConstraint:0x6000002dc8c0 V:[Sport5.CustomRoundedCornerRectangle:0x7f9af3c9e990]-(0)-|   (active, names: '|':UIView:0x7f9af3ce99a0 )>",
"<NSLayoutConstraint:0x6000002ddef0 V:|-(23)-[Sport5.CustomRoundedCornerRectangle:0x7f9af3c9e990]   (active, names: '|':UIView:0x7f9af3ce99a0 )>",
"<NSLayoutConstraint:0x600000237890 V:|-(-10)-[UIView:0x7f9af3ce99a0]   (active, names: '|':UITableViewCellContentView:0x7f9af3cdd730 )>",
"<NSLayoutConstraint:0x600000237610 UIView:0x7f9af3ce99a0.bottom == UITableViewCellContentView:0x7f9af3cdd730.bottom - 10   (active)>",
"<NSLayoutConstraint:0x600000203ca0 'UIView-Encapsulated-Layout-Height' UITableViewCellContentView:0x7f9af3cdd730.height == 108   (active)>"
)

Will attempt to recover by breaking constraint 
<NSLayoutConstraint:0x600000294960 Sport5.CustomRoundedCornerRectangle:0x7f9af3c9e990.height == 89   (active)>

nhật ký của một tin nhắn với dòng mới được hiển thị ok (có vấn đề về chiều rộng, nhưng tôi không nghĩ nó liên quan đến vấn đề này)

(
"<NSLayoutConstraint:0x600003de94a0 Sport5.CustomImageView:0x7fc7fd4c0540.width == 273.24   (active)>",
"<NSLayoutConstraint:0x600003deaf80 Sport5.CustomRoundedCornerRectangle:0x7fc7fd4e2730.width == 302.22   (active)>",
"<NSLayoutConstraint:0x600003d3fde0 H:|-(15)-[UIStackView:0x7fc7ff2d8430]   (active, names: '|':Sport5.CustomRoundedCornerRectangle:0x7fc7fd4e2730 )>",
"<NSLayoutConstraint:0x600003d3fe30 UIStackView:0x7fc7ff2d8430.trailing == Sport5.CustomRoundedCornerRectangle:0x7fc7fd4e2730.trailing - 40   (active)>",
"<NSLayoutConstraint:0x600003de9d10 'UISV-canvas-connection' UIStackView:0x7fc7ff2d8430.leading == _UILayoutSpacer:0x60000219f660'UISV-alignment-spanner'.leading   (active)>",
"<NSLayoutConstraint:0x600003deba20 'UISV-canvas-connection' H:[Sport5.CustomImageView:0x7fc7fd4c0540]-(0)-|   (active, names: '|':UIStackView:0x7fc7ff2d8430 )>",
"<NSLayoutConstraint:0x600003dea8f0 'UISV-spanning-boundary' _UILayoutSpacer:0x60000219f660'UISV-alignment-spanner'.leading <= Sport5.CustomImageView:0x7fc7fd4c0540.leading   (active)>"
)

Will attempt to recover by breaking constraint 
<NSLayoutConstraint:0x600003de94a0 Sport5.CustomImageView:0x7fc7fd4c0540.width == 273.24   (active)>

cắt bớt tin nhắn

thông điệp tốt, bây giờ có thêm không gian

ràng buộc nhãn thông báo xấu

hạn chế ngăn xếp thư xấu

ràng buộc nhãn thông điệp tốt

các ràng buộc ngăn xếp thư tốt

Trả lời

1 DonMag Sep 23 2020 at 23:52

Tôi nghĩ rằng bạn sẽ thấy nó hoạt động tốt hơn nhiều nếu bạn để bố cục tự động xử lý tất cả các kích thước. Không cần phải dựa vào tính toán kích thước hộp giới hạn văn bản.

Dưới đây là một ví dụ với một số dữ liệu mẫu:

và sau khi cuộn để xem một số thư không có hình ảnh nội dung:

Mã tôi đã sử dụng:


Cấu trúc mẫu và dữ liệu

struct MyMessageStruct {
    var time: String = " "
    var name: String = " "
    var profileImageName: String = ""
    var contentImageName: String = ""
    var message: String = " "
}

class SampleData: NSObject {
    let sampleStrings: [String] = [
        "First message with short text.",
        "Second message with longer text that should cause word wrapping in this cell.",
        "Third message with some embedded newlines.\nThis line comes after a newline (\"\\n\"), so we can see if that works the way we want.",
        "Message without content image.",
        "Longer Message without content image.\n\nWith a pair of embedded newline (\"\\n\") characters giving us a \"blank line\" in the message text.",
        "The sixth message, also without a content image."
    ]
    
    lazy var sampleData: [MyMessageStruct] = [
        MyMessageStruct(time: "08:36", name: "Bob",   profileImageName: "pro1", contentImageName: "content1", message: sampleStrings[0]),
        MyMessageStruct(time: "08:47", name: "Bob",   profileImageName: "pro1", contentImageName: "content2", message: sampleStrings[1]),
        MyMessageStruct(time: "08:59", name: "Joe",   profileImageName: "pro2", contentImageName: "content3", message: sampleStrings[2]),
        MyMessageStruct(time: "09:06", name: "Steve", profileImageName: "pro3", contentImageName:         "", message: sampleStrings[3]),
        MyMessageStruct(time: "09:21", name: "Bob",   profileImageName: "pro1", contentImageName:         "", message: sampleStrings[4]),
        MyMessageStruct(time: "09:45", name: "Joe",   profileImageName: "pro2", contentImageName:         "", message: sampleStrings[5]),
    ]
}

Bộ điều khiển Chế độ xem Bảng

class ChatTableViewController: UITableViewController {
    
    var myData: [MyMessageStruct] = SampleData().sampleData
    
    override func viewDidLoad() {
        super.viewDidLoad()

        // register the cell
        tableView.register(ChatMessageCell.self, forCellReuseIdentifier: "chatCell")
        
        tableView.separatorStyle = .none
        tableView.backgroundView = GrayGradientView()
    }
    
    override func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }
    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return myData.count
    }
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "chatCell", for: indexPath) as! ChatMessageCell
        
        // don't show the profile image if this message is from the same person
        //  as the previous message
        var isSameAuthor = false
        if indexPath.row > 0 {
            if myData[indexPath.row].name == myData[indexPath.row - 1].name {
                isSameAuthor = true
            }
        }
        
        cell.fillData(myData[indexPath.row], isSameAuthor: isSameAuthor)
        
        return cell
    }
    
}

Lớp tế bào

Có thể bạn sẽ muốn điều chỉnh khoảng cách, nhưng các nhận xét giải thích bố cục sẽ làm rõ nơi cần thực hiện thay đổi.

class ChatMessageCell: UITableViewCell {
    
    let timeLabel = UILabel()
    let nameLabel = UILabel()
    let profileImageView = RoundImageView()
    let bubbleView = CustomRoundedCornerRectangle()
    let stackView = UIStackView()
    let contentImageView = UIImageView()
    let messageLabel = UILabel()
    
    var contentImageHeightConstraint: NSLayoutConstraint!
    
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    func commonInit() -> Void {
        
        [timeLabel, nameLabel, profileImageView, bubbleView, stackView, contentImageView, messageLabel].forEach {
            $0.translatesAutoresizingMaskIntoConstraints = false
        }
        
        // MARK: add cell elements
        
        contentView.addSubview(timeLabel)
        contentView.addSubview(nameLabel)
        contentView.addSubview(profileImageView)
        contentView.addSubview(bubbleView)

        bubbleView.addSubview(stackView)
        
        stackView.addArrangedSubview(contentImageView)
        stackView.addArrangedSubview(messageLabel)

        // MARK: cell element constraints
        
        // make constraints relative to the default cell margins
        let g = contentView.layoutMarginsGuide
        
        NSLayoutConstraint.activate([
            
            // timeLabel Top: 0 / Leading: 20
            timeLabel.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
            timeLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            
            // nameLabel Top: 0 / Trailing: 30
            nameLabel.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
            nameLabel.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -30.0),
            
            // profile image
            //  Top: bubbleView.top + 6
            profileImageView.topAnchor.constraint(equalTo: bubbleView.topAnchor, constant: 6.0),
            //  Trailing: 0 (to contentView margin)
            profileImageView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
            //  Width: 50 / Height: 1:1 (to keep it square / round)
            profileImageView.widthAnchor.constraint(equalToConstant: 50.0),
            profileImageView.heightAnchor.constraint(equalTo: profileImageView.widthAnchor),
            
            // bubbleView
            //  Top: timeLabel.bottom + 4
            bubbleView.topAnchor.constraint(equalTo: timeLabel.bottomAnchor, constant: 4.0),
            //  Leading: timeLabel.leading + 16
            bubbleView.leadingAnchor.constraint(equalTo: timeLabel.leadingAnchor, constant: 16.0),
            //  Trailing: profile image.leading - 4
            bubbleView.trailingAnchor.constraint(equalTo: profileImageView.leadingAnchor, constant: -4.0),
            //  Bottom: contentView.bottom
            bubbleView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
            
            // stackView (to bubbleView)
            //  Top / Bottom: 12
            stackView.topAnchor.constraint(equalTo: bubbleView.topAnchor, constant: 12.0),
            stackView.bottomAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: -12.0),
            //  Leading / Trailing: 16
            stackView.leadingAnchor.constraint(equalTo: bubbleView.leadingAnchor, constant: 16.0),
            stackView.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor, constant: -16.0),

        ])
        
        // contentImageView height ratio - will be changed based on the loaded image
        // we need to set its Priority to less-than Required or we get auto-layout warnings when the cell is reused
        contentImageHeightConstraint = contentImageView.heightAnchor.constraint(equalTo: contentImageView.widthAnchor, multiplier: 2.0 / 3.0)
        contentImageHeightConstraint.priority = .defaultHigh
        contentImageHeightConstraint.isActive = true

        // messageLabel minimum Height: 40
        // we need to set its Priority to less-than Required or we get auto-layout warnings when the cell is reused
        let c = messageLabel.heightAnchor.constraint(greaterThanOrEqualToConstant: 40.0)
        c.priority = .defaultHigh
        c.isActive = true
        
        // MARK: element properties
        
        stackView.axis = .vertical
        stackView.spacing = 6
        
        // set label fonts and alignment here
        timeLabel.font = UIFont.systemFont(ofSize: 14, weight: .regular)
        nameLabel.font = UIFont.systemFont(ofSize: 14, weight: .bold)
        timeLabel.textColor = .gray
        nameLabel.textColor = UIColor(red: 0.175, green: 0.36, blue: 0.72, alpha: 1.0)
        
        // for now, I'm just setting the message label to right-aligned
        //  likely using RTL
        messageLabel.textAlignment = .right
        
        messageLabel.numberOfLines = 0
        
        contentImageView.backgroundColor = .blue
        contentImageView.contentMode = .scaleAspectFit
        contentImageView.layer.cornerRadius = 8
        contentImageView.layer.masksToBounds = true
        
        profileImageView.contentMode = .scaleToFill
        
        // MARK: cell background
        backgroundColor = .clear
        contentView.backgroundColor = .clear
    }
    
    func fillData(_ msg: MyMessageStruct, isSameAuthor: Bool) -> Void {
        timeLabel.text = msg.time
        nameLabel.text = msg.name
        
        nameLabel.isHidden = isSameAuthor
        profileImageView.isHidden = isSameAuthor
        
        if !isSameAuthor {
            if !msg.profileImageName.isEmpty {
                if let img = UIImage(named: msg.profileImageName) {
                    profileImageView.image = img
                }
            }
        }
        if !msg.contentImageName.isEmpty {
            contentImageView.isHidden = false
            if let img = UIImage(named: msg.contentImageName) {
                contentImageView.image = img
                let ratio = img.size.height / img.size.width
                contentImageHeightConstraint.isActive = false
                contentImageHeightConstraint = contentImageView.heightAnchor.constraint(equalTo: contentImageView.widthAnchor, multiplier: ratio)
                contentImageHeightConstraint.priority = .defaultHigh
                contentImageHeightConstraint.isActive = true
            }
        } else {
            contentImageView.isHidden = true
        }
        messageLabel.text = msg.message
    }
}

Các lớp bổ sung

Đối với "chế độ xem bong bóng trò chuyện", "chế độ xem hình ảnh góc tròn" và "chế độ xem nền gradient"

class CustomRoundedCornerRectangle: UIView {
    lazy var shapeLayer = CAShapeLayer()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setup()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setup()
    }
    
    func setup() {
        // apply properties related to the path
        shapeLayer.fillColor = UIColor.white.cgColor
        shapeLayer.lineWidth = 1.0
        shapeLayer.strokeColor = UIColor(red: 212/255, green: 212/255, blue: 212/255, alpha: 1.0).cgColor
        shapeLayer.position = CGPoint(x: 0, y: 0)
        
        // add the new layer to our custom view
        //self.layer.addSublayer(shapeLayer)
        self.layer.insertSublayer(shapeLayer, at: 0)
    }
    
    override func layoutSubviews() {
        
        let path = UIBezierPath()
        let largeCornerRadius: CGFloat = 18
        let smallCornerRadius: CGFloat = 10
        let upperCornerSpacerRadius: CGFloat = 2
        let imageToArcSpace: CGFloat = 5
        let rect = bounds
        
        // move to starting point
        path.move(to: CGPoint(x: rect.minX + smallCornerRadius, y: rect.maxY))
        
        // draw bottom left corner
        path.addArc(withCenter: CGPoint(x: rect.minX + smallCornerRadius, y: rect.maxY - smallCornerRadius), radius: smallCornerRadius,
                    startAngle: .pi / 2, // straight down
                    endAngle: .pi, // straight left
                    clockwise: true)
        
        // draw left line
        path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + smallCornerRadius))
        
        // draw top left corner
        path.addArc(withCenter: CGPoint(x: rect.minX + smallCornerRadius, y: rect.minY + smallCornerRadius), radius: smallCornerRadius,
                    startAngle: .pi, // straight left
                    endAngle: .pi / 2 * 3, // straight up
                    clockwise: true)
        
        // draw top line
        path.addLine(to: CGPoint(x: rect.maxX - largeCornerRadius, y: rect.minY))
        
        // draw concave top right corner
        // first arc
        path.addArc(withCenter: CGPoint(x: rect.maxX + largeCornerRadius, y: rect.minY + upperCornerSpacerRadius), radius: upperCornerSpacerRadius, startAngle: .pi / 2 * 3, // straight up
                    endAngle: .pi / 2, // straight left
                    clockwise: true)
        
        // second arc
        path.addArc(withCenter: CGPoint(x: rect.maxX + largeCornerRadius + imageToArcSpace, y: rect.minY + largeCornerRadius + upperCornerSpacerRadius * 2 + imageToArcSpace), radius: largeCornerRadius + imageToArcSpace, startAngle: CGFloat(240.0).toRadians(), // up with offset
                    endAngle: .pi, // straight left
                    clockwise: false)
        
        // draw right line
        path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - smallCornerRadius))
        
        // draw bottom right corner
        path.addArc(withCenter: CGPoint(x: rect.maxX - smallCornerRadius, y: rect.maxY - smallCornerRadius), radius: smallCornerRadius,
                    startAngle: 0, // straight right
                    endAngle: .pi / 2, // straight down
                    clockwise: true)
        
        // draw bottom line to close the shape
        path.close()
        
        shapeLayer.path = path.cgPath
    }
}

extension CGFloat {
    func toRadians() -> CGFloat {
        return self * CGFloat(Double.pi) / 180.0
    }
}

class RoundImageView: UIImageView {
    override func layoutSubviews() {
        layer.masksToBounds = true
        layer.cornerRadius = bounds.size.height * 0.5
    }
}

class GrayGradientView: UIView {
    private var gradLayer: CAGradientLayer!
    
    override class var layerClass: AnyClass {
        return CAGradientLayer.self
    }
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    func commonInit() -> Void {
        
        let myColors: [UIColor] = [
            UIColor(white: 0.95, alpha: 1.0),
            UIColor(white: 0.90, alpha: 1.0),
        ]
        
        gradLayer = self.layer as? CAGradientLayer
        
        // assign the colors (we're using map to convert UIColors to CGColors
        gradLayer.colors = myColors.map({$0.cgColor})
        
        // start at the top
        gradLayer.startPoint = CGPoint(x: 0.25, y: 0.0)
        
        // end at the bottom
        gradLayer.endPoint = CGPoint(x: 0.75, y: 1.0)
        
    }
}

Và hình ảnh mẫu (bấm vào để xem kích thước đầy đủ):

content1.png content2.png content3.png

pro1.png pro2.png pro3.png