This is my AttributedLabel class:
import UIKit
class AttributedLabel: UILabel {
var cornerRadius: CGFloat = 3 {
didSet {
layer.cornerRadius = cornerRadius
}
}
var borderColor: UIColor = .clear {
didSet {
layer.borderColor = borderColor.cgColor
layer.borderWidth = 1
}
}
var masksToBounds: Bool = false {
didSet {
layer.masksToBounds = masksToBounds
}
}
var borderWidth: CGFloat = 1 {
didSet {
layer.borderWidth = borderWidth
}
}
private var strikeThrough = false
private var paddingTop: CGFloat = 0
private var paddingBottom: CGFloat = 0
private var paddingLeft: CGFloat = 0
private var paddingRight: CGFloat = 0
override var text: String? {
didSet {
if strikeThrough {
attributedText = NSAttributedString(string: text ?? "", strikeThrough: true)
}
}
}
// MARK: - Initialization
convenience init(
paddingTop: CGFloat = 0,
paddingBottom: CGFloat = 0,
paddingLeft: CGFloat = 0,
paddingRight: CGFloat = 0,
strikeThrough: Bool = false
) {
self.init()
self.paddingTop = paddingTop
self.paddingBottom = paddingBottom
self.paddingLeft = paddingLeft
self.paddingRight = paddingRight
self.strikeThrough = strikeThrough
backgroundColor = .yellow
}
// MARK: - Overriden
override func drawText(in rect: CGRect) {
let insets = UIEdgeInsets(top: paddingTop, left: paddingLeft, bottom: paddingBottom, right: paddingRight)
super.drawText(in: rect.inset(by: insets))
}
override var intrinsicContentSize: CGSize {
var contentSize = super.intrinsicContentSize
contentSize.height += paddingTop
contentSize.height += paddingBottom
contentSize.width += paddingLeft
contentSize.width += paddingRight
return contentSize
}
}
And this is the result:
First label is truncated. Why? It is defined like this:
private let titleLabel: UILabel = {
let label = AttributedLabel(paddingLeft: 20, paddingRight: 20)
label.textColor = .mineShaft
label.font = UIFont.poppinsMedium.withSize(18)
label.textAlignment = .left
label.numberOfLines = 0
return label
}()
and has text assigned:
titleLabel.text = "Czy jesteś za wdrożeniem aplikacji PLZ?"
I don't know if it matters, but titleLabel is added into UIStackView:
contentStackView.addArrangedSubview(titleLabel)
After changes:
With your code:
override var intrinsicContentSize: CGSize {
var contentSize = super.intrinsicContentSize
// etc...
return contentSize
}
The text will not be wrapped, because the label has no intrinsic width. So, if you use this whole paragraph as .text
contentSize
may end up being something like (1500, 20)
You want to use an internal CGSize
property, calculate the height of the text in layoutSubviews()
, and then call invalidateIntrinsicContentSize()
.
Here's a quick example:
Helper extension:
extension NSAttributedString {
func boundingRect(for size: CGSize) -> CGRect {
let options: NSStringDrawingOptions = [.usesLineFragmentOrigin, .usesFontLeading]
return boundingRect(with: size, options: options, context: nil)
}
}
Your class, with a few modifications:
class AttributedLabel: UILabel {
var cornerRadius: CGFloat = 3 {
didSet {
layer.cornerRadius = cornerRadius
}
}
var borderColor: UIColor = .clear {
didSet {
layer.borderColor = borderColor.cgColor
layer.borderWidth = 1
}
}
var masksToBounds: Bool = false {
didSet {
layer.masksToBounds = masksToBounds
}
}
var borderWidth: CGFloat = 1 {
didSet {
layer.borderWidth = borderWidth
}
}
// will be set in layoutSubviews()
private var mySize: CGSize = .zero
private var strikeThrough = false
private var paddingTop: CGFloat = 0
private var paddingBottom: CGFloat = 0
private var paddingLeft: CGFloat = 0
private var paddingRight: CGFloat = 0
override var text: String? {
didSet {
let attributedString = NSAttributedString(string: text ?? "", attributes: [
.font: self.font ?? UIFont.systemFont(ofSize: 18, weight: .regular)
])
self.attributedText = attributedString
setNeedsLayout()
}
}
// MARK: - Initialization
convenience init(
paddingTop: CGFloat = 0,
paddingBottom: CGFloat = 0,
paddingLeft: CGFloat = 0,
paddingRight: CGFloat = 0,
strikeThrough: Bool = false
) {
self.init()
self.paddingTop = paddingTop
self.paddingBottom = paddingBottom
self.paddingLeft = paddingLeft
self.paddingRight = paddingRight
self.strikeThrough = strikeThrough
backgroundColor = .yellow
}
// MARK: - Overriden
override func drawText(in rect: CGRect) {
let insets = UIEdgeInsets(top: paddingTop, left: paddingLeft, bottom: paddingBottom, right: paddingRight)
super.drawText(in: rect.inset(by: insets))
}
override var intrinsicContentSize: CGSize {
// super.intrinsicContentSize will NOT wrap the text
//var contentSize = super.intrinsicContentSize
var contentSize = mySize
contentSize.height += paddingTop
contentSize.height += paddingBottom
contentSize.width += paddingLeft
contentSize.width += paddingRight
return contentSize
}
override func layoutSubviews() {
super.layoutSubviews()
// this will be called more than once...
// during development, log the calls to make sure we're not in an infinite loop
print(#function)
let attributedString = self.attributedText ?? NSAttributedString(string: "")
mySize = attributedString.boundingRect(for: .init(width: bounds.width - (paddingLeft + paddingRight), height: .greatestFiniteMagnitude)).size
invalidateIntrinsicContentSize()
}
}
Example view controller:
class LabelTestVC: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let st = UIStackView()
st.axis = .vertical
st.spacing = 12
st.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(st)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
st.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
st.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
st.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
])
let samples: [String] = [
"Czy jesteś za wdrożeniem aplikacji PLZ?",
"single line",
"This will be long enough that we're sure it will need to wrap.",
"This example has\nembedded\nnewLine characters\nso we get 4 lines.",
]
for str in samples {
let label = AttributedLabel(paddingLeft: 20, paddingRight: 20)
label.textColor = .red // .mineShaft
label.font = .systemFont(ofSize: 18.0, weight: .regular) //UIFont.poppinsMedium.withSize(18)
label.textAlignment = .left
label.numberOfLines = 0
label.text = str
st.addArrangedSubview(label)
// so we can see the framing
label.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
}
// so we can see the framing
st.backgroundColor = .systemBlue
}
}
Result (iPhone 15 Pro):
and device rotated:
Edit
There are several problems with this approach... we could find a way to manage it by "double-calculating" bounding boxes, but we hit some additional issues when dynamically changing the label's text.
A more reliable approach can be found here: Adding space/padding to a UILabel
Here is a modified version that should fit your needs...
class PaddingLabel: UILabel {
public var cornerRadius: CGFloat = 3 {
didSet {
layer.cornerRadius = cornerRadius
}
}
public var borderColor: UIColor = .clear {
didSet {
layer.borderColor = borderColor.cgColor
layer.borderWidth = 1
}
}
public var masksToBounds: Bool = false {
didSet {
layer.masksToBounds = masksToBounds
}
}
public var borderWidth: CGFloat = 1 {
didSet {
layer.borderWidth = borderWidth
}
}
private let strikeThroughAttribute: [NSAttributedString.Key : Any] = [
.strikethroughStyle: NSUnderlineStyle.single.rawValue,
.strikethroughColor: UIColor.red,
]
private var strikeThrough = false
private var paddingTop: CGFloat = 0
private var paddingBottom: CGFloat = 0
private var paddingLeft: CGFloat = 0
private var paddingRight: CGFloat = 0
override var text: String? {
didSet {
var attribs: [NSAttributedString.Key : Any] = [
.font: self.font ?? UIFont.systemFont(ofSize: 18, weight: .regular)
]
if strikeThrough {
attribs.merge(strikeThroughAttribute) { (a, _) in a }
}
let attributedString = NSAttributedString(string: text ?? "", attributes: attribs)
self.attributedText = attributedString
}
}
convenience init(
paddingTop: CGFloat = 0,
paddingBottom: CGFloat = 0,
paddingLeft: CGFloat = 0,
paddingRight: CGFloat = 0,
strikeThrough: Bool = false
) {
self.init()
self.paddingTop = paddingTop
self.paddingBottom = paddingBottom
self.paddingLeft = paddingLeft
self.paddingRight = paddingRight
self.strikeThrough = strikeThrough
backgroundColor = .yellow
}
override func drawText(in rect: CGRect) {
let insets = UIEdgeInsets(top: paddingTop, left: paddingLeft, bottom: paddingBottom, right: paddingRight)
super.drawText(in: rect.inset(by: insets))
}
override var intrinsicContentSize: CGSize {
let size = super.intrinsicContentSize
return CGSize(width: size.width + paddingLeft + paddingRight,
height: size.height + paddingTop + paddingBottom)
}
override var bounds: CGRect {
didSet {
// ensures this works within stack views if multi-line
preferredMaxLayoutWidth = bounds.width - (paddingLeft + paddingRight)
}
}
}
And, a new test controller with both a non-width-contrained label and several labels in a stack view. Tapping anywhere will dynamically update the text in the labels:
class LabelTestVC: UIViewController {
var singleTexts: [String] = [
"Zakończony",
"This is a longer line of text.",
"Four",
"The last non-stackView text."
]
var samples: [String] = [
"Tap anywhere to update the label texts dynamically.",
"single line",
"Czy jesteś za wdrożeniem aplikacji PLZ?",
"This will be long enough that we're sure it will need to wrap.",
"This example has\nembedded\nnewLine characters\nso we get 4 lines.",
]
override func viewDidLoad() {
super.viewDidLoad()
let testLabel = PaddingLabel(paddingLeft: 20, paddingRight: 20)
let st = UIStackView()
st.axis = .vertical
st.spacing = 12
// if the label has no constraint controlling its width,
// it cannot have .numberOfLines = 0
testLabel.numberOfLines = 1
testLabel.font = .systemFont(ofSize: 18.0, weight: .regular) //UIFont.poppinsMedium.withSize(18)
testLabel.textAlignment = .left
testLabel.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(testLabel)
st.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(st)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
testLabel.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
testLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
testLabel.trailingAnchor.constraint(lessThanOrEqualTo: g.trailingAnchor, constant: -20.0),
st.topAnchor.constraint(equalTo: testLabel.bottomAnchor, constant: 20.0),
st.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
st.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
])
testLabel.text = singleTexts[0]
testLabel.backgroundColor = UIColor(red: 0.5, green: 1.0, blue: 0.75, alpha: 1.0)
for str in samples {
let label = PaddingLabel(paddingLeft: 20, paddingRight: 20)
label.textColor = .red // .mineShaft
label.font = .systemFont(ofSize: 18.0, weight: .regular) //UIFont.poppinsMedium.withSize(18)
label.textAlignment = .left
label.numberOfLines = 0
label.text = str
st.addArrangedSubview(label)
// so we can see the framing
label.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
}
// so we can see the framing
st.backgroundColor = .systemBlue
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
if let v = view.subviews.first as? UILabel {
let s = singleTexts.removeFirst()
singleTexts.append(s)
v.text = singleTexts[0]
}
if let sv = view.subviews.last as? UIStackView {
let s = samples.removeFirst()
samples.append(s)
for (v, str) in zip(sv.arrangedSubviews, samples) {
if let v = v as? UILabel {
v.text = str
}
}
}
}
}