字符串包含 '\n' 时的字符串 Boundingrect 计算问题
String Boundingrect calculation issue when string contains '\n'
我在我们的应用程序中开发了一个 Whatsapp 风格的功能。消息从 JSON 中解析出来,然后在 UITableView 中创建为文本(+ 可选图像)消息(每条消息都是一个自定义单元格)。
消息气泡是使用 Bezier Path 绘制的,基于使用 boundingRect 方法计算的文本框。稍后将 UILabel 和 UIImage 添加为 UIStackview 的子视图,StackView 和消息气泡视图都被限制为容器视图。
有时,当文本包含“\n”时,UILabel 要么被剪切(使用“...”),要么向下流动到消息气泡视图下方,具体取决于堆栈视图的底部锚点优先级(higher/lower 比 UILabel 的内容拥抱优先级),但其他包含换行符的消息会正确显示。我的猜测是字符串的帧计算将 '\n' 视为 2 个字符而不是换行符。
当我尝试在操场上测试相同的代码时(使用更简单的布局,只有 UILabel 和气泡视图,没有容器视图,没有表视图,也没有约束)一切似乎都运行良好,气泡会自行扩展以适应到添加的换行符。
基于我尝试用sizeThatFits方法替换代码,结果还是一样。最终,我最终计算了字符串中 '\n' 的出现次数并手动将高度添加到框架,但它同时影响了坏消息和好消息,现在它们周围有额外的 space。
屏幕截图、相关代码和控制台日志附在下面。希望它能帮助人们解决这个问题。
编辑:将 messageView 的宽度从 UIScreen.main.bounds.width * 0.73 更改为 UIScreen.main.bounds.width * 0.8 解决了这个问题。但是我仍然不明白为什么它只影响特定的消息。如果能提供有关此的任何进一步信息,我将不胜感激。
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!))
}
}
截断消息日志:
(
"<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)>
显示正常的带换行符的消息日志(存在宽度问题,但我认为这与此问题无关)
(
"<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)>
删减留言
好消息,现在有额外的space
错误消息标签约束
错误的消息堆栈约束
好消息标签约束
良好的消息堆栈约束
我认为如果让 auto-layout 处理所有大小调整,您会发现效果会好得多。无需依赖计算文本边界框大小。
这是一个包含一些示例数据的示例:
并且,在滚动查看一些没有内容图片的消息后:
我使用的代码:
示例结构和数据
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 视图控制器
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
}
}
单元格Class
您可能想要调整间距,但解释布局的注释应该清楚说明更改的位置。
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 {
[=12=].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
}
}
额外Classes
对于“聊天气泡视图”、“圆角图像视图”和“渐变背景视图”
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({[=13=].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)
}
}
和示例图片(点击查看全尺寸):
content1.png content2.png content3.png
pro1.png pro2.png pro3.png
我在我们的应用程序中开发了一个 Whatsapp 风格的功能。消息从 JSON 中解析出来,然后在 UITableView 中创建为文本(+ 可选图像)消息(每条消息都是一个自定义单元格)。 消息气泡是使用 Bezier Path 绘制的,基于使用 boundingRect 方法计算的文本框。稍后将 UILabel 和 UIImage 添加为 UIStackview 的子视图,StackView 和消息气泡视图都被限制为容器视图。
有时,当文本包含“\n”时,UILabel 要么被剪切(使用“...”),要么向下流动到消息气泡视图下方,具体取决于堆栈视图的底部锚点优先级(higher/lower 比 UILabel 的内容拥抱优先级),但其他包含换行符的消息会正确显示。我的猜测是字符串的帧计算将 '\n' 视为 2 个字符而不是换行符。
当我尝试在操场上测试相同的代码时(使用更简单的布局,只有 UILabel 和气泡视图,没有容器视图,没有表视图,也没有约束)一切似乎都运行良好,气泡会自行扩展以适应到添加的换行符。
基于
屏幕截图、相关代码和控制台日志附在下面。希望它能帮助人们解决这个问题。
编辑:将 messageView 的宽度从 UIScreen.main.bounds.width * 0.73 更改为 UIScreen.main.bounds.width * 0.8 解决了这个问题。但是我仍然不明白为什么它只影响特定的消息。如果能提供有关此的任何进一步信息,我将不胜感激。
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!))
}
}
截断消息日志:
(
"<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)>
显示正常的带换行符的消息日志(存在宽度问题,但我认为这与此问题无关)
(
"<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)>
删减留言
好消息,现在有额外的space
错误消息标签约束
错误的消息堆栈约束
好消息标签约束
良好的消息堆栈约束
我认为如果让 auto-layout 处理所有大小调整,您会发现效果会好得多。无需依赖计算文本边界框大小。
这是一个包含一些示例数据的示例:
并且,在滚动查看一些没有内容图片的消息后:
我使用的代码:
示例结构和数据
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 视图控制器
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
}
}
单元格Class
您可能想要调整间距,但解释布局的注释应该清楚说明更改的位置。
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 {
[=12=].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
}
}
额外Classes
对于“聊天气泡视图”、“圆角图像视图”和“渐变背景视图”
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({[=13=].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)
}
}
和示例图片(点击查看全尺寸):
content1.png content2.png content3.png
pro1.png pro2.png pro3.png