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
good message, now with extra space
bad message label constraints
bad message stack constraints
good message label constraints
good message stack constraints
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:
and, after scrolling to see some messages without content images:
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
Related
Closed. This question needs to be more focused. It is not currently accepting answers.
Want to improve this question? Update the question so it focuses on one problem only by editing this post.
Closed 1 year ago.
Improve this question
I have a view that may contains 2 or 3 UIViews.
I want to draw (and possibly animate a line from the bottom MidX of the higher view to the bottom one.
If I have 3 views I want the line to split and animate to both of them.
If I have a single view I want the line to go directly to the middle top of the bottom view all this using UIBezierPath and CAShapeLayer
All this considering screen height (4.7" -> 6.2") I have attached images to illustrate what I want to achieve.
Thanks for the help.
You're on the right track...
The problem with drawing a "split" line is that there is one start point and TWO end points. So, the resulting animation may not be what you really want.
Another approach would be to use TWO layers - one with the "left-side" split line and one with the "right-side" split line, then animate them together.
Here's an example of wrapping things into a "Connect" view subclass.
We'll use 3 layers: 1 for the single vertical connecting line and one each for the right-side and left-side lines.
We can also set the path points to the center of the view, and the left and right edges. That way we can constrain the Leading edge to the center of the left-box, and the trailing edge to the center of the right-box.
This view, by itself, will look like this (with a yellow background so we can see its frame):
or:
With the lines will be animated from the top.
class ConnectView: UIView {
// determines whether we want a single box-to-box line, or
// left and right split / stepped lines to two boxes
public var single: Bool = true
private let singleLineLayer = CAShapeLayer()
private let leftLineLayer = CAShapeLayer()
private let rightLineLayer = CAShapeLayer()
private var durationFactor: CGFloat = 0
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
// add and configure sublayers
[singleLineLayer, leftLineLayer, rightLineLayer].forEach { lay in
layer.addSublayer(lay)
lay.lineWidth = 4
lay.strokeColor = UIColor.blue.cgColor
lay.fillColor = UIColor.clear.cgColor
}
}
override func layoutSubviews() {
super.layoutSubviews()
// for readablility, define the points for our lines
let topCenter = CGPoint(x: bounds.midX, y: 0)
let midCenter = CGPoint(x: bounds.midX, y: bounds.midY)
let botCenter = CGPoint(x: bounds.midX, y: bounds.maxY)
let midLeft = CGPoint(x: bounds.minX, y: bounds.midY)
let midRight = CGPoint(x: bounds.maxX, y: bounds.midY)
let botLeft = CGPoint(x: bounds.minX, y: bounds.maxY)
let botRight = CGPoint(x: bounds.maxX, y: bounds.maxY)
let singleBez = UIBezierPath()
let leftBez = UIBezierPath()
let rightBez = UIBezierPath()
// vertical line
singleBez.move(to: topCenter)
singleBez.addLine(to: botCenter)
// split / stepped line to the left
leftBez.move(to: topCenter)
leftBez.addLine(to: midCenter)
leftBez.addLine(to: midLeft)
leftBez.addLine(to: botLeft)
// split / stepped line to the right
rightBez.move(to: topCenter)
rightBez.addLine(to: midCenter)
rightBez.addLine(to: midRight)
rightBez.addLine(to: botRight)
// set the layer paths
// initializing strokeEnd to 0 for all three
singleLineLayer.path = singleBez.cgPath
singleLineLayer.strokeEnd = 0
leftLineLayer.path = leftBez.cgPath
leftLineLayer.strokeEnd = 0
rightLineLayer.path = rightBez.cgPath
rightLineLayer.strokeEnd = 0
// calculate total line lengths (in points)
// so we can adjust the "draw speed" in the animation
let singleLength = botCenter.y - topCenter.y
let doubleLength = singleLength + (midCenter.x - midLeft.x)
durationFactor = singleLength / doubleLength
}
public func doAnim() -> Void {
// reset the animations
[singleLineLayer, leftLineLayer, rightLineLayer].forEach { lay in
lay.removeAllAnimations()
lay.strokeEnd = 0
}
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.fromValue = 0.0
animation.toValue = 1.0
animation.duration = 2.0
animation.fillMode = .forwards
animation.isRemovedOnCompletion = false
if self.single {
// we want the apparent drawing speed to be the same
// for a single line as for a split / stepped line
// so change the animation duration
animation.duration *= durationFactor
// animate the single line layer
self.singleLineLayer.add(animation, forKey: animation.keyPath)
} else {
// animate the both left and right line layers
self.leftLineLayer.add(animation, forKey: animation.keyPath)
self.rightLineLayer.add(animation, forKey: animation.keyPath)
}
}
}
and a sample view controller showing it in action:
class ConnectTestViewController: UIViewController {
let vTop = UIView()
let vLeft = UIView()
let vCenter = UIView()
let vRight = UIView()
let testConnectView = ConnectView()
override func viewDidLoad() {
super.viewDidLoad()
// give the 4 views different background colors
// add them as subviews
// make them all 100x100 points
let colors: [UIColor] = [
.systemYellow,
.systemRed, .systemGreen, .systemBlue,
]
for (v, c) in zip([vTop, vLeft, vCenter, vRight], colors) {
v.backgroundColor = c
v.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(v)
v.widthAnchor.constraint(equalToConstant: 100.0).isActive = true
v.heightAnchor.constraint(equalTo: v.widthAnchor).isActive = true
}
// add the clear-background Connect View
testConnectView.backgroundColor = .clear
testConnectView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(testConnectView)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
// horizontally center the top box near the top
vTop.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
vTop.centerXAnchor.constraint(equalTo: g.centerXAnchor),
// horizontally center the center box, 200-pts below the top box
vCenter.topAnchor.constraint(equalTo: vTop.bottomAnchor, constant: 200.0),
vCenter.centerXAnchor.constraint(equalTo: g.centerXAnchor),
// align tops of left and right boxes with center box
vLeft.topAnchor.constraint(equalTo: vCenter.topAnchor),
vRight.topAnchor.constraint(equalTo: vCenter.topAnchor),
// position left and right boxes to left and right of center box
vLeft.trailingAnchor.constraint(equalTo: vCenter.leadingAnchor, constant: -20.0),
vRight.leadingAnchor.constraint(equalTo: vCenter.trailingAnchor, constant: 20.0),
// constrain Connect View
// Top to Bottom of Top box
testConnectView.topAnchor.constraint(equalTo: vTop.bottomAnchor),
// Bottom to Top of the row of 3 boxes
testConnectView.bottomAnchor.constraint(equalTo: vCenter.topAnchor),
// Leading to CenterX of Left box
testConnectView.leadingAnchor.constraint(equalTo: vLeft.centerXAnchor),
// Trailing to CenterX of Right box
testConnectView.trailingAnchor.constraint(equalTo: vRight.centerXAnchor),
])
// add a couple buttons at the bottom
let stack = UIStackView()
stack.spacing = 20
stack.distribution = .fillEqually
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
["Run Anim", "Show/Hide"].forEach { str in
let b = UIButton()
b.setTitle(str, for: [])
b.backgroundColor = .red
b.setTitleColor(.white, for: .normal)
b.setTitleColor(.lightGray, for: .highlighted)
b.addTarget(self, action: #selector(buttonTap(_:)), for: .touchUpInside)
stack.addArrangedSubview(b)
}
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
stack.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
stack.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -20.0),
stack.heightAnchor.constraint(equalToConstant: 50.0),
])
}
#objc func buttonTap(_ sender: Any?) -> Void {
guard let b = sender as? UIButton,
let t = b.currentTitle
else {
return
}
if t == "Run Anim" {
// tap button to toggle between
// Top-to-Middle box line or
// Top-to-SideBoxes split / stepped line
testConnectView.single.toggle()
// run the animation
testConnectView.doAnim()
} else {
// toggle background of Connect View between
// clear and yellow
testConnectView.backgroundColor = testConnectView.backgroundColor == .clear ? .yellow : .clear
}
}
}
Running that will give this result:
The first button at the bottom will toggle the connection between Top-Center and Top-Left-Right (re-running the animation each time). The second button will toggle the view's background color between clear and yellow so we can see its frame.
Edit
If you want rounded "step" corners that look like this:
replace the layoutSubviews() code above with this:
override func layoutSubviews() {
super.layoutSubviews()
// for readablility, define the points for our lines
let topCenter = CGPoint(x: bounds.midX, y: 0)
let midCenter = CGPoint(x: bounds.midX, y: bounds.midY)
let botCenter = CGPoint(x: bounds.midX, y: bounds.maxY)
let midLeft = CGPoint(x: bounds.minX, y: bounds.midY)
let midRight = CGPoint(x: bounds.maxX, y: bounds.midY)
let botLeft = CGPoint(x: bounds.minX, y: bounds.maxY)
let botRight = CGPoint(x: bounds.maxX, y: bounds.maxY)
let singleBez = UIBezierPath()
let leftBez = UIBezierPath()
let rightBez = UIBezierPath()
// vertical line
singleBez.move(to: topCenter)
singleBez.addLine(to: botCenter)
// rounded "step" corners
let radius: CGFloat = 20.0
let leftArcP = CGPoint(x: midLeft.x + radius, y: midLeft.y)
let leftArcC = CGPoint(x: midLeft.x + radius, y: midLeft.y + radius)
let rightArcP = CGPoint(x: midRight.x - radius, y: midRight.y)
let rightArcC = CGPoint(x: midRight.x - radius, y: midRight.y + radius)
// split / stepped line to the left
leftBez.move(to: topCenter)
leftBez.addLine(to: midCenter)
leftBez.addLine(to: leftArcP)
leftBez.addArc(withCenter: leftArcC, radius: radius, startAngle: .pi * 1.5, endAngle: .pi, clockwise: false)
leftBez.addLine(to: botLeft)
// split / stepped line to the right
rightBez.move(to: topCenter)
rightBez.addLine(to: midCenter)
rightBez.addLine(to: rightArcP)
rightBez.addArc(withCenter: rightArcC, radius: radius, startAngle: .pi * 1.5, endAngle: 0, clockwise: true)
rightBez.addLine(to: botRight)
// set the layer paths
// initializing strokeEnd to 0 for all three
singleLineLayer.path = singleBez.cgPath
singleLineLayer.strokeEnd = 0
leftLineLayer.path = leftBez.cgPath
leftLineLayer.strokeEnd = 0
rightLineLayer.path = rightBez.cgPath
rightLineLayer.strokeEnd = 0
// calculate total line lengths (in points)
// so we can adjust the "draw speed" in the animation
let singleLength = botCenter.y - topCenter.y
let doubleLength = singleLength + (midCenter.x - midLeft.x)
durationFactor = singleLength / doubleLength
}
Well after some research I have come up with this solution for Swift 5:
class ViewController: UIViewController {
#IBOutlet weak var someView: UIView!
override func viewDidLoad() {
super.viewDidLoad()
let start = CGPoint(x: self.someView.bounds.midX, y: self.someView.bounds.maxY)
let end = CGPoint(x: self.someView.layer.bounds.midX, y: (UIScreen.main.bounds.height / 2) - 100)
let linePath = UIBezierPath()
linePath.move(to: start)
linePath.addLine(to: end)
linePath.addLine(to: CGPoint(x: -50, y: (UIScreen.main.bounds.height / 2) - 100))
linePath.move(to: end)
linePath.addLine(to: CGPoint(x: 250, y: (UIScreen.main.bounds.height / 2) - 100))
linePath.addLine(to: CGPoint(x: lowerViewA.x, y: lowerViewA.y))
linePath.move(to: CGPoint(x: -50, y: (UIScreen.main.bounds.height / 2) - 100))
linePath.addLine(to: CGPoint(x: lowerViewB.x, y: lowerViewB.y))
let shapeLayer = CAShapeLayer()
shapeLayer.path = linePath.cgPath
shapeLayer.fillColor = UIColor.clear.cgColor
shapeLayer.strokeColor = UIColor.green.cgColor
shapeLayer.lineWidth = 2
shapeLayer.lineJoin = CAShapeLayerLineJoin.bevel
self.someView.layer.addSublayer(shapeLayer)
//Basic animation if you want to animate the line drawing.
let pathAnimation: CABasicAnimation = CABasicAnimation(keyPath: "strokeEnd")
pathAnimation.duration = 4.0
pathAnimation.fromValue = 0.0
pathAnimation.toValue = 1.0
//Animation will happen right away
shapeLayer.add(pathAnimation, forKey: "strokeEnd")
}
}
I have a rect. Top corners are always rounded. Bottom corners have animation - rounded or not.
Previous my solution was to split this rect into top and bottom rects (top one is constant, bottom one is animated). The reason is maskedCorners is not animated - you can animate cornerRadius only.
But now I need to add a colored border around the rect which should be animated too. So my solution is not suitable anymore. How to solve this issue?
You can do this by animating a CGPath for the view's layer, and constructing the path by adding arcs at the corners for individual radii.
Here's an example class:
class AnimCornerView: UIView {
public var fillColor: UIColor = .white
public var borderColor: UIColor = .clear
public var borderWidth: CGFloat = 0.0
private var _tl: CGFloat = 0
private var _tr: CGFloat = 0
private var _bl: CGFloat = 0
private var _br: CGFloat = 0
private var theShapeLayer: CAShapeLayer!
override class var layerClass: AnyClass {
return CAShapeLayer.self
}
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
backgroundColor = .clear
theShapeLayer = self.layer as? CAShapeLayer
}
override func layoutSubviews() {
super.layoutSubviews()
setCorners(topLeft: _tl, topRight: _tr, botLeft: _bl, botRight: _br, animated: false)
}
public func setCorners(topLeft tl: CGFloat, topRight tr: CGFloat, botLeft bl: CGFloat, botRight br: CGFloat, animated: Bool, duration: CFTimeInterval = 0.3) -> Void {
_tl = tl
_tr = tr
_bl = bl
_br = br
theShapeLayer.fillColor = fillColor.cgColor
theShapeLayer.strokeColor = borderColor.cgColor
theShapeLayer.lineWidth = borderWidth
let newPath: CGPath = getPath(topLeft: tl, topRight: tr, botLeft: bl, botRight: br)
if animated {
CATransaction.begin()
let animation = CABasicAnimation(keyPath: "path")
animation.duration = duration
animation.toValue = newPath
animation.fillMode = .forwards
animation.isRemovedOnCompletion = false
CATransaction.setCompletionBlock({
self.theShapeLayer.path = newPath
self.theShapeLayer.removeAllAnimations()
})
self.theShapeLayer.add(animation, forKey: "path")
CATransaction.commit()
} else {
theShapeLayer.path = newPath
}
}
private func getPath(topLeft tl: CGFloat, topRight tr: CGFloat, botLeft bl: CGFloat, botRight br: CGFloat) -> CGPath {
var pt = CGPoint.zero
let myBezier = UIBezierPath()
// top-left corner plus top-left radius
pt.x = tl
pt.y = 0
myBezier.move(to: pt)
pt.x = bounds.maxX - tr
pt.y = 0
// add "top line"
myBezier.addLine(to: pt)
pt.x = bounds.maxX - tr
pt.y = tr
// add "top-right corner"
myBezier.addArc(withCenter: pt, radius: tr, startAngle: .pi * 1.5, endAngle: 0, clockwise: true)
pt.x = bounds.maxX
pt.y = bounds.maxY - br
// add "right-side line"
myBezier.addLine(to: pt)
pt.x = bounds.maxX - br
pt.y = bounds.maxY - br
// add "bottom-right corner"
myBezier.addArc(withCenter: pt, radius: br, startAngle: 0, endAngle: .pi * 0.5, clockwise: true)
pt.x = bl
pt.y = bounds.maxY
// add "bottom line"
myBezier.addLine(to: pt)
pt.x = bl
pt.y = bounds.maxY - bl
// add "bottom-left corner"
myBezier.addArc(withCenter: pt, radius: bl, startAngle: .pi * 0.5, endAngle: .pi, clockwise: true)
pt.x = 0
pt.y = tl
// add "left-side line"
myBezier.addLine(to: pt)
pt.x = tl
pt.y = tl
// add "top-left corner"
myBezier.addArc(withCenter: pt, radius: tl, startAngle: .pi, endAngle: .pi * 1.5, clockwise: true)
myBezier.close()
return myBezier.cgPath
}
}
You can specify different radii for each corner, and tell it to animate to the new settings (or not).
For example, you could start with:
testView.setCorners(topLeft: 40, topRight: 40, botLeft: 0, botRight: 0, animated: false)
to round the top-left and top-right corners, then later call:
testView.setCorners(topLeft: 40, topRight: 40, botLeft: 40, botRight: 40, animated: true)
to animate the bottom corners.
Here's a sample controller class to demonstrate. Each time you tap, the bottom corners will animate between rounded and non-rounded:
class AnimCornersViewController : UIViewController {
let testView: AnimCornerView = {
let v = AnimCornerView()
v.translatesAutoresizingMaskIntoConstraints = false
v.fillColor = .green
v.borderColor = .blue
v.borderWidth = 2
v.setCorners(topLeft: 40, topRight: 40, botLeft: 0, botRight: 0, animated: false)
return v
}()
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(testView)
// respect safe area
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
testView.widthAnchor.constraint(equalTo: g.widthAnchor, multiplier: 0.8),
testView.heightAnchor.constraint(equalTo: g.heightAnchor, multiplier: 0.6),
testView.centerXAnchor.constraint(equalTo: g.centerXAnchor),
testView.centerYAnchor.constraint(equalTo: g.centerYAnchor),
])
let t = UITapGestureRecognizer(target: self, action: #selector(gotTap(_:)))
view.addGestureRecognizer(t)
}
var shouldRoundBottom: Bool = false
#objc func gotTap(_ g: UITapGestureRecognizer?) {
shouldRoundBottom.toggle()
if shouldRoundBottom {
testView.setCorners(topLeft: 40, topRight: 40, botLeft: 40, botRight: 40, animated: true)
} else {
testView.setCorners(topLeft: 40, topRight: 40, botLeft: 0, botRight: 0, animated: true)
}
}
}
Note: this is example code only!!!
I have 1 class creating bezier bubble (working)
In tableviewcell class I am creating msg label programmatically using function
I use that function in view controller with table view
Images below are from same code.
Array used for data is ["a","aa","aaa","aaaa","aaaaa"]
Issue = The msg label is not showing/ being created
I have given all required things. The function visible in code is being directly called in tableview's cellforrowat function.
Image 1 = [Hierarchy view 1 after clicking button] (https://ibb.co/QfvsV3C)
Image 2 = [Hierarchy view 2 before clicking button] (https://ibb.co/L0pz2kz)
msg lbl code :
func drawMsgLbl(text: String)
{
//var msg = UILabel()
//msg = UILabel()
//msg?.lineBreakMode = .byWordWrapping
msg.numberOfLines = 0
msg.font = UIFont.init(name: "Avenir", size: 20)
msg.text = text
let txtWidth = text.size(OfFont: msg.font)
msg.textColor = .black
msg.backgroundColor = .yellow
// msg.frame = CGRect(x: 0, y: 0, width: msg.intrinsicContentSize.width, height: msg.intrinsicContentSize.height)
let constraintRect = CGSize(width: (0.66 * self.frame.width),
height: .greatestFiniteMagnitude)
let boundingBox = text.boundingRect(with: constraintRect,
options: .usesLineFragmentOrigin,
attributes: [.font: msg.font],
context: nil)
msg.frame.size = CGSize(width: ceil(boundingBox.width),
height: ceil(boundingBox.height))
print("Bounnding Width = \(boundingBox.width)")
print("msgIntrinsicWidth = \(msg.intrinsicContentSize.width)")
print("Text Width = \(txtWidth)")
print(text)
msg.frame.origin = CGPoint(x: (self.frame.size.width)-((msg.frame.size.width))-20, y: (self.frame.height)+10)
//msg?.center = self.center
let bubbleSize = CGSize(width: ((msg.frame.size.width)+30), height: ((msg.frame.size.height)+20))
let incomingBubblee = incomingBubble()
let outgoingBubblee = outgoingBubble()
outgoingBubblee.frame.size = bubbleSize
outgoingBubblee.frame.origin = CGPoint(x: (frame.size.width)-((msg.frame.size.width))-30, y: frame.size.height)
outgoingBubblee.backgroundColor = .clear
self.addSubview(outgoingBubblee)
self.addSubview(msg)
self.backgroundColor = .gray
}
I want to generate a msg label with width and height depending upon the text it has with a Bezier path that is being used (its code is placed separately, and the code is working fine)
Here is a very simple example, using auto-layout constraints to allow the cells / rows to auto-size their heights.
All via code - no #IBOutlets or prototype cells - so just assign a UITableViewController to this class (ChatBubbleTestTableViewController)
// simple "Message" struct
struct MyMessage {
var incoming: Bool = false
var message: String = ""
}
class ChatBubbleView: UIView {
let bubbleLayer = CAShapeLayer()
let chatLabel: UILabel = {
let v = UILabel()
v.translatesAutoresizingMaskIntoConstraints = false
v.numberOfLines = 0
v.text = "Sample text"
return v
}()
// if it's an incoming message, background will be gray and bubble left-aligned
// otherwise background will be green and bubble right-alinged
var incoming = false
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
commonInit()
}
func commonInit() -> Void {
// add the bubble layer
layer.addSublayer(bubbleLayer)
// add the label
addSubview(chatLabel)
// constrain the label with 12-pts padding on all 4 sides
NSLayoutConstraint.activate([
chatLabel.topAnchor.constraint(equalTo: topAnchor, constant: 12.0),
chatLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -12.0),
chatLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12.0),
chatLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12.0),
])
}
override func layoutSubviews() {
super.layoutSubviews()
let width = bounds.size.width
let height = bounds.size.height
let bezierPath = UIBezierPath()
// NOTE: this bezier path is from
// https://medium.com/#dima_nikolaev/creating-a-chat-bubble-which-looks-like-a-chat-bubble-in-imessage-the-advanced-way-2d7497d600ba
if incoming {
bezierPath.move(to: CGPoint(x: 22, y: height))
bezierPath.addLine(to: CGPoint(x: width - 17, y: height))
bezierPath.addCurve(to: CGPoint(x: width, y: height - 17), controlPoint1: CGPoint(x: width - 7.61, y: height), controlPoint2: CGPoint(x: width, y: height - 7.61))
bezierPath.addLine(to: CGPoint(x: width, y: 17))
bezierPath.addCurve(to: CGPoint(x: width - 17, y: 0), controlPoint1: CGPoint(x: width, y: 7.61), controlPoint2: CGPoint(x: width - 7.61, y: 0))
bezierPath.addLine(to: CGPoint(x: 21, y: 0))
bezierPath.addCurve(to: CGPoint(x: 4, y: 17), controlPoint1: CGPoint(x: 11.61, y: 0), controlPoint2: CGPoint(x: 4, y: 7.61))
bezierPath.addLine(to: CGPoint(x: 4, y: height - 11))
bezierPath.addCurve(to: CGPoint(x: 0, y: height), controlPoint1: CGPoint(x: 4, y: height - 1), controlPoint2: CGPoint(x: 0, y: height))
bezierPath.addLine(to: CGPoint(x: -0.05, y: height - 0.01))
bezierPath.addCurve(to: CGPoint(x: 11.04, y: height - 4.04), controlPoint1: CGPoint(x: 4.07, y: height + 0.43), controlPoint2: CGPoint(x: 8.16, y: height - 1.06))
bezierPath.addCurve(to: CGPoint(x: 22, y: height), controlPoint1: CGPoint(x: 16, y: height), controlPoint2: CGPoint(x: 19, y: height))
bezierPath.close()
} else {
bezierPath.move(to: CGPoint(x: width - 22, y: height))
bezierPath.addLine(to: CGPoint(x: 17, y: height))
bezierPath.addCurve(to: CGPoint(x: 0, y: height - 17), controlPoint1: CGPoint(x: 7.61, y: height), controlPoint2: CGPoint(x: 0, y: height - 7.61))
bezierPath.addLine(to: CGPoint(x: 0, y: 17))
bezierPath.addCurve(to: CGPoint(x: 17, y: 0), controlPoint1: CGPoint(x: 0, y: 7.61), controlPoint2: CGPoint(x: 7.61, y: 0))
bezierPath.addLine(to: CGPoint(x: width - 21, y: 0))
bezierPath.addCurve(to: CGPoint(x: width - 4, y: 17), controlPoint1: CGPoint(x: width - 11.61, y: 0), controlPoint2: CGPoint(x: width - 4, y: 7.61))
bezierPath.addLine(to: CGPoint(x: width - 4, y: height - 11))
bezierPath.addCurve(to: CGPoint(x: width, y: height), controlPoint1: CGPoint(x: width - 4, y: height - 1), controlPoint2: CGPoint(x: width, y: height))
bezierPath.addLine(to: CGPoint(x: width + 0.05, y: height - 0.01))
bezierPath.addCurve(to: CGPoint(x: width - 11.04, y: height - 4.04), controlPoint1: CGPoint(x: width - 4.07, y: height + 0.43), controlPoint2: CGPoint(x: width - 8.16, y: height - 1.06))
bezierPath.addCurve(to: CGPoint(x: width - 22, y: height), controlPoint1: CGPoint(x: width - 16, y: height), controlPoint2: CGPoint(x: width - 19, y: height))
bezierPath.close()
}
bubbleLayer.fillColor = incoming ? UIColor(white: 0.90, alpha: 1.0).cgColor : UIColor.green.cgColor
bubbleLayer.path = bezierPath.cgPath
}
}
class ChatCell: UITableViewCell {
let bubbleView: ChatBubbleView = {
let v = ChatBubbleView()
v.translatesAutoresizingMaskIntoConstraints = false
return v
}()
var leadingOrTrailingConstraint = NSLayoutConstraint()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
commonInit()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
commonInit()
}
func commonInit() -> Void {
// add the bubble view
contentView.addSubview(bubbleView)
// constrain top / bottom with 12-pts padding
// constrain width to lessThanOrEqualTo 2/3rds (66%) of the width of the cell
NSLayoutConstraint.activate([
bubbleView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12.0),
bubbleView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -12.0),
bubbleView.widthAnchor.constraint(lessThanOrEqualTo: contentView.widthAnchor, multiplier: 0.66),
])
}
func setData(_ message: MyMessage) -> Void {
// set the label text
bubbleView.chatLabel.text = message.message
// tell the bubble view whether it's an incoming or outgoing message
bubbleView.incoming = message.incoming
// left- or right-align the bubble view, based on incoming or outgoing
leadingOrTrailingConstraint.isActive = false
if message.incoming {
leadingOrTrailingConstraint = bubbleView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12.0)
} else {
leadingOrTrailingConstraint = bubbleView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12.0)
}
leadingOrTrailingConstraint.isActive = true
}
}
class ChatBubbleTestTableViewController: UITableViewController {
let theData: [MyMessage] = [
MyMessage(incoming: false, message: "A short message."),
MyMessage(incoming: true, message: "A medium length message, longer than short."),
MyMessage(incoming: false, message: "A long message. This one should be long enough to wrap onto multiple lines, showing that this message bubble cell will auto-size itself to the message content."),
MyMessage(incoming: true, message: "Another short message."),
MyMessage(incoming: false, message: "Another medium length message, longer than short."),
MyMessage(incoming: true, message: "Another long message. This one should be long enough to wrap onto multiple lines, showing that this message bubble cell will auto-size itself to the message content."),
]
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(ChatCell.self, forCellReuseIdentifier: "ChatCell")
}
override func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return theData.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "ChatCell", for: indexPath) as! ChatCell
cell.setData(theData[indexPath.row])
return cell
}
}
The result:
and note that it auto-sizes when the cell changes size (such as when you rotate the device):
EDIT
The problem with your code...
In ChatCell.swift, you have:
func commonInit()
{
contentView.addSubview(bubbleView)
NSLayoutConstraint.activate([
bubbleView.topAnchor.constraint(equalTo: topAnchor, constant: 12.0),
bubbleView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -12.0),
bubbleView.widthAnchor.constraint(lessThanOrEqualTo: contentView.widthAnchor, multiplier: 0.66)
])
}
bubbleView needs to be constrained to the cell's contentView:
func commonInit()
{
contentView.addSubview(bubbleView)
NSLayoutConstraint.activate([
bubbleView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12.0),
bubbleView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -12.0),
bubbleView.widthAnchor.constraint(lessThanOrEqualTo: contentView.widthAnchor, multiplier: 0.66)
])
}
EDIT 2
The most straight-forward way to change the bubble background colors...
In ChatBubbleView class, remove this line (at the end of layoutSubviews():
bubbleLayer.fillColor = isIncoming ? UIColor(white: 0.90, alpha: 1.0).cgColor : UIColor.green.cgColor
In ChatCell class, add two properties:
var incomingColor: UIColor = UIColor(white: 0.90, alpha: 1.0)
var outgoingColor: UIColor = UIColor.green
Those will be your "default" colors.
Also in ChatCell class, in setData(), add this line:
bubbleView.bubbleLayer.fillColor = message.incoming ? incomingColor.cgColor : outgoingColor.cgColor
Then, in TestVC in cellForRowAt, you can do this:
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell",for: indexPath) as! ChatCell
cell.incomingColor = .cyan
cell.outgoingColor = .yellow
cell.setData(theData[indexPath.row])
return cell
}
I'll leave it up to you as to how you want to implement color selection by the user.
I am not looking out for code snippet. I am just curious to know how to develop a UI component which is shown below. I have multiple thoughts on creating it, but would love to know the best approach
Create a label and do some operations on its layer
Set background image and add text on label
Set image which has text on it
What will be the good approach to develop it. Any suggestions please.
You want to display a single line of text. You can use a UILabel for that.
You have a shape you want to fill. You can use a CAShapeLayer for that. It's best to wrap the shape layer in a UIView subclass so that UIKit can lay it out properly.
You want to put the text over the shape, so use a parent view to combine the label and the shape as subviews.
import UIKit
class TagView: UIView {
init() {
super.init(frame: .zero)
addSubview(background)
addSubview(label)
background.translatesAutoresizingMaskIntoConstraints = false
label.translatesAutoresizingMaskIntoConstraints = false
label.setContentHuggingPriority(.defaultHigh + 10, for: .horizontal)
label.setContentHuggingPriority(.defaultHigh + 10, for: .vertical)
NSLayoutConstraint.activate([
background.heightAnchor.constraint(equalTo: label.heightAnchor, constant: 4),
background.centerYAnchor.constraint(equalTo: label.centerYAnchor),
background.widthAnchor.constraint(equalTo: label.widthAnchor, constant: 20),
background.centerXAnchor.constraint(equalTo: label.centerXAnchor, constant: -2),
leadingAnchor.constraint(equalTo: background.leadingAnchor),
trailingAnchor.constraint(equalTo: background.trailingAnchor),
topAnchor.constraint(equalTo: background.topAnchor),
bottomAnchor.constraint(equalTo: background.bottomAnchor),
])
}
let label = UILabel()
private let background = BackgroundView()
private class BackgroundView: UIView {
override class var layerClass: AnyClass { return CAShapeLayer.self }
override func layoutSubviews() {
super.layoutSubviews()
layoutShape()
}
private func layoutShape() {
let layer = self.layer as! CAShapeLayer
layer.fillColor = #colorLiteral(red: 0.731529057, green: 0.8821037412, blue: 0.9403864741, alpha: 1)
layer.strokeColor = nil
let bounds = self.bounds
let h2 = bounds.height / 2
let path = UIBezierPath()
path.move(to: CGPoint(x: 0, y: h2))
path.addLine(to: CGPoint(x: h2, y: 0))
path.addLine(to: CGPoint(x: bounds.maxX - h2, y: 0))
path.addArc(withCenter: CGPoint(x: bounds.maxX - h2, y: h2), radius: h2, startAngle: -.pi/2, endAngle: .pi/2, clockwise: true)
path.addLine(to: CGPoint(x: h2, y: bounds.maxY))
path.close()
layer.path = path.cgPath
}
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
import PlaygroundSupport
let root = UIView(frame: CGRect(x: 0, y: 0, width: 200, height: 200))
root.backgroundColor = .white
PlaygroundPage.current.liveView = root
let tag = TagView()
tag.translatesAutoresizingMaskIntoConstraints = false
tag.label.text = "CURRENT"
root.addSubview(tag)
NSLayoutConstraint.activate([
tag.centerXAnchor.constraint(equalTo: root.centerXAnchor),
tag.centerYAnchor.constraint(equalTo: root.centerYAnchor),
])
Result:
How to draw about three circle in horizontally area with main and ring color in rectangle. I need to create custom button with this circles, something like this:
Is there any good way to do this?
We can design such kind of views with UIStackView in very ease manner.
Take a stackView, set its alignment to center, axis to horizontal and distribution to fill. Create a UILabel/UIButton/UIImageView or even UIView and add rounded radius and border to it. Finally, add those views to the main stackView.
Try this.
override func viewDidLoad() {
super.viewDidLoad()
//Setup stackView
let myStackView = UIStackView()
myStackView.axis = .horizontal
myStackView.alignment = .center
myStackView.distribution = .fillEqually
myStackView.spacing = 8
view.addSubview(myStackView)
//Setup circles
let circle_1 = circleLabel()
let circle_2 = circleLabel()
let circle_3 = circleLabel()
myStackView.addArrangedSubview(circle_1)
myStackView.addArrangedSubview(circle_2)
myStackView.addArrangedSubview(circle_3)
myStackView.translatesAutoresizingMaskIntoConstraints = false
myStackView.centerXAnchor.constraint(equalTo: view.centerXAnchor, constant: 0.0).isActive = true
myStackView.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: 0.0).isActive = true
}
func circleLabel() -> UILabel {
let label = UILabel()
label.backgroundColor = UIColor.red
label.layer.cornerRadius = 12.5
label.layer.masksToBounds = true
label.layer.borderColor = UIColor.orange.cgColor
label.layer.borderWidth = 3.0
label.widthAnchor.constraint(equalToConstant: 25.0).isActive = true
label.heightAnchor.constraint(equalToConstant: 25.0).isActive = true
return label
}
To make a Single Circle like that, you need to make use of UIBezierPath and CAShapeLayer .
let outerCirclePath = UIBezierPath(arcCenter: CGPoint(x: 100,y: 100), radius: CGFloat(50), startAngle: CGFloat(0), endAngle:CGFloat(Double.pi * 2), clockwise: true)
let outerCircleShapeLayer = CAShapeLayer()
outerCircleShapeLayer.path = outerCirclePath.cgPath
outerCircleShapeLayer.fillColor = UIColor.white.cgColor
outerCircleShapeLayer.lineWidth = 3.0
view.layer.addSublayer(outerCircleShapeLayer)
// Drawing the inner circle
let innerCirclePath = UIBezierPath(arcCenter: CGPoint(x: 100,y: 100), radius: CGFloat(40), startAngle: CGFloat(0), endAngle:CGFloat(Double.pi * 2), clockwise: true)
let innerCircleShapeLayer = CAShapeLayer()
innerCircleShapeLayer.path = innerCirclePath.cgPath
innerCircleShapeLayer.fillColor = UIColor.blue.cgColor
view.layer.addSublayer(innerCircleShapeLayer)
I have attached an image below for the Playground version of it .
Just play around with arcCenter and radius values and you will get the desired output
My team helped me and here is solution to create this with dynamically changing state of circles (with different stroke and fill colors):
import UIKit
#IBDesignable
class CirclesButton: UIControl {
#IBInspectable
var firstCircle: Bool = false {
didSet {
setNeedsDisplay()
}
}
#IBInspectable
var secondCircle: Bool = false {
didSet {
setNeedsDisplay()
}
}
#IBInspectable
var thirdCircle: Bool = false {
didSet {
setNeedsDisplay()
}
}
override func draw(_ rect: CGRect) {
// get context
guard let context = UIGraphicsGetCurrentContext() else { return }
// make configurations
context.setLineWidth(1.0);
context.setStrokeColor(UIColor.white.cgColor)
context.setFillColor(red: 0.0, green: 0.58, blue: 1.0, alpha: 1.0)
// find view center
let dotSize:CGFloat = 11.0
let viewCenter = CGPoint(x: rect.midX, y: rect.midY)
// find personal dot rect
var dotRect = CGRect(x: viewCenter.x - dotSize / 2.0, y: viewCenter.y - dotSize / 2.0, width: dotSize, height: dotSize)
if secondCircle {
context.fillEllipse(in: dotRect)
}
context.strokeEllipse(in: dotRect)
// find global notes rect
dotRect = CGRect(x: viewCenter.x - dotSize * 1.5 - 4.0, y: viewCenter.y - dotSize / 2.0, width: dotSize, height: dotSize)
if firstCircle {
context.fillEllipse(in: dotRect)
}
context.strokeEllipse(in: dotRect)
// find music rect
dotRect = CGRect(x: viewCenter.x + dotSize / 2.0 + 4.0, y: viewCenter.y - dotSize / 2.0, width: dotSize, height: dotSize)
if thirdCircle {
context.setFillColor(red: 0.0, green: 1.0, blue: 0.04, alpha: 1.0)
context.fillEllipse(in: dotRect)
}
context.strokeEllipse(in: dotRect)
}
}
It will looks like: CirclesButton
Сode:
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let buttonSize: CGFloat = 80
let firstButton = CustomButton(position: CGPoint(x: 0, y: 0), size: buttonSize, color: .blue)
self.view.addSubview(firstButton)
let secondButton = CustomButton(position: CGPoint(x: firstButton.frame.maxX, y: 0), size: buttonSize, color: .blue)
self.view.addSubview(secondButton)
let thirdButton = CustomButton(position: CGPoint(x: secondButton.frame.maxX, y: 0), size: buttonSize, color: .green)
self.view.addSubview(thirdButton)
}
}
class CustomButton: UIButton {
init(position: CGPoint, size: CGFloat, color: UIColor) {
super.init(frame: CGRect(x: position.x, y: position.y, width: size, height: size))
self.backgroundColor = color
self.layer.cornerRadius = size / 2
self.clipsToBounds = true
self.layer.borderWidth = 4.0 // make it what ever you want
self.layer.borderColor = UIColor.white.cgColor
}
required init?(coder aDecoder: NSCoder) {
fatalError()
}
}
You can handle button tapped like:
override func viewDidLoad() {
super.viewDidLoad()
firstButton.addTarget(self, action: #selector(handleFirstButton), for: .touchUpInside)
}
#objc func handleFirstButton(sender: UIButton) {
print("first button tapped")
}
Best and Universal Solution for **Button or Label creation (Fully Dynamic)**
var x = 10
var y = 5
var buttonHeight = 40
var buttonWidth = 40
for i in 0..<3 {
let roundButton = UIButton(frame: CGRect(x: x, y: y, width: buttonWidth, height: buttonHeight))
roundButton.setTitle("Butt\(i)", for: .normal)
roundButton.layer.cornerRadius = roundButton.bounds.size.height/2
yourButtonBackView.addSubview(roundButton)
x = x + buttonWidth + 10
if x >= Int(yourButtonBackView.frame.width - 30) {
y = y + buttonHeight + 10
x = 10
}
}