Search code examples
iosswiftuitableviewuilabelnslayoutconstraint

String Boundingrect calculation issue when string contains '\n'


I develop a Whatsapp style feature inside our app. messages are parsed from JSON and then created as text (+ optional image) messages inside a UITableView (each message is a custom cell). The message bubble is drawn using Bezier Path, based on the calculation of the text frame using the boundingRect method. Later the UILabel and UIImage are added as subviews of a UIStackview, and both the StackView and the message bubble view are constrained to a container view.

Sometimes when the text contains '\n' the UILabel is either getting cut (with '...') or flows down below the message bubble view, depending on the stack view's bottom anchor priority (higher/lower than the UILabel's content hugging priority), but other messages that contain newlines appear correctly. My guess is that the string's frame calculation treats the '\n' as 2 characters instead of a newline.

When I tried testing the same code in a playground (with a simpler layout, just UILabel and bubble view, no container views, no tableview and no constraints) everything seemed to work fine and the bubble would expand itself to adapt to the added newlines.

Based on this thread I tried replacing the code with the sizeThatFits Method, still the same result. Eventually, I ended up counting the occurrences of '\n' inside the string and manually adding height to the frame, but it affects both the bad messages and the good messages, which by now has extra space surrounding them.

Screenshots, relevant code and console logs are attached below. Hopefully, it will help someone to figure this out.

Edit: changing the width of messageView from UIScreen.main.bounds.width * 0.73 to UIScreen.main.bounds.width * 0.8 fixed the issue. However I still can't figure out why it affected only specific messages. I'll be grateful for any further information regarding this.

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

CustromRoundedCorenerRectangle.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!))
    }
}

log of cut down message:

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

log of a message with newline that was displayed ok (there is a width issue, but I don't think it has something to do with this issue)

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

cut down message cut down message

good message, now with extra space good message, now with extra space

bad message label constraints bad message label constraints

bad message stack constraints bad message stack constraints

good message label constraints good message label constraints

good message stack constraints good message stack constraints


Solution

  • I think you'll find it works much better if you let auto-layout handle all of the sizing. No need to rely calculating text bounding box sizes.

    Here is an example with some sample data:

    enter image description here

    and, after scrolling to see some messages without content images:

    enter image description here

    The code I used:


    Sample Struct and Data

    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]),
        ]
    }
    

    Table View Controller

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

    Cell Class

    You'll probably want to tweak the spacing, but the comments explaining the layout should make it clear where to make changes.

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

    Additional Classes

    For the "chat bubble view," "rounded corners image view," and "gradient background view"

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

    And sample images (click for full sizes):

    content1.png content2.png content3.png

    pro1.png pro2.png pro3.png