I am trying to create my own custom label as a subclass of UIView
:
import UIKit
class MyLabel: UIView {
private var preferredMaxLayoutWidth: CGFloat? = nil
var attributedText : NSAttributedString? {
didSet{
setupTextKit()
preferredMaxLayoutWidth = nil
invalidateIntrinsicContentSize()
setNeedsDisplay()
}
}
private let layoutManager = NSLayoutManager()
private let textContainer = NSTextContainer(size: .zero)
private var textStorage : NSTextStorage?
override func draw(_ rect: CGRect) {
super.draw(rect)
print("draw!!! rect: \(rect), preferredMaxLayoutWidth: \(preferredMaxLayoutWidth)")
guard let attributedText = attributedText, attributedText.length > 0 else {
return
}
attributedText.draw(with: bounds, options: [.usesLineFragmentOrigin], context: nil)
}
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit(){
contentMode = .redraw
backgroundColor = .red.withAlphaComponent(0.2)
textContainer.lineFragmentPadding = 0
textContainer.maximumNumberOfLines = 0 // 0 means no limit
textContainer.lineBreakMode = .byWordWrapping
textContainer.layoutManager = layoutManager
layoutManager.addTextContainer(textContainer)
}
private func setupTextKit() {
textStorage?.removeLayoutManager(layoutManager)
if let attributedText = attributedText {
textStorage = NSTextStorage(attributedString: attributedText)
textStorage?.addLayoutManager(layoutManager)
layoutManager.textStorage = textStorage
} else {
textStorage = nil
}
}
override func layoutSubviews() {
super.layoutSubviews()
print("layoutSubviews: \(bounds.size), \(preferredMaxLayoutWidth)")
if preferredMaxLayoutWidth != bounds.width {
print("preferredMaxLayoutWidth is different from bounds.width")
preferredMaxLayoutWidth = bounds.width
invalidateIntrinsicContentSize()
setNeedsDisplay()
} else {
print("preferredMaxLayoutWidth is same as bounds.width")
}
}
override var intrinsicContentSize: CGSize {
guard let currentTextStorage = textStorage, currentTextStorage.length > 0 else {
return .zero
}
var calculationWidth: CGFloat
if let pMLW = preferredMaxLayoutWidth {
calculationWidth = pMLW
} else {
calculationWidth = CGFloat.greatestFiniteMagnitude
}
print("preferredMaxLayoutWidth: \(preferredMaxLayoutWidth), calculationWidth: \(calculationWidth)")
textContainer.size = CGSize(width: calculationWidth, height: CGFloat.greatestFiniteMagnitude)
layoutManager.ensureLayout(for: textContainer)
let usedRect = layoutManager.usedRect(for: textContainer)
print("usedRect: \(usedRect)")
return usedRect.size
}
}
I am placing it in a UITableViewCell
:
import UIKit
import SnapKit
class ViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
let tableView = UITableView(frame: .zero, style: .plain)
let records = ["Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. "]
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(Cell.self, forCellReuseIdentifier: "Cell")
view.addSubview(tableView)
tableView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
tableView.dataSource = self
tableView.delegate = self
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
UITableView.automaticDimension
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
records.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell") as! Cell
cell.label.attributedText = NSAttributedString(string: records[indexPath.row], attributes: [.foregroundColor: UIColor.label, .font: UIFont.systemFont(ofSize: 18)])
return cell
}
}
class Cell: UITableViewCell {
let label = MyLabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
contentView.addSubview(label)
label.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(10)
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
This however doesn't render my full view:
Even though I have implemented my own intrinsicContentSize
which does return the correct size of (420.0, 128.8828125)
, the draw(_ rect: CGRect)
is being called with the wrong sized rect
. This is observable from the print logs:
preferredMaxLayoutWidth: nil, calculationWidth: 1.7976931348623157e+308
usedRect: (0.0, 0.0, 2018.9322268366814, 21.48046875)
layoutSubviews: (420.0, 21.666666666666668), nil
preferredMaxLayoutWidth is different from bounds.width
preferredMaxLayoutWidth: Optional(420.0), calculationWidth: 420.0
usedRect: (0.0, 0.0, 420.0, 128.8828125)
draw!!! rect: (0.0, 0.0, 420.0, 21.666666666666664), preferredMaxLayoutWidth: Optional(420.0)
As you can see, in second last line, my intrinsicContentSize
returns the correct size of 420.0, 128.8828125
. However, in the next line, the draw
function has the old size still of 420.0, 21.666666666666664
.
How can I fix this?
EDIT:
Could I be running into the same problem as one described here?:
https://developer.apple.com/forums/thread/72243
EDIT 2:
Following DonMag's answer below which requires overriding the systemLayoutSizeFitting
in the UITableViewCell, I was able to use method swizzling to avoid having to override systemLayoutSizeFitting
in each instance of my custom cells.
Add a global swizzleStuff
closure.
private var swizzleStuff: Void = {
UITableViewCell.swizzle1()
UITableViewCell.swizzle2()
}()
Add _ = swizzleStuff
to the commonInit()
of MyLabel
.
extension UITableViewCell {
fileprivate static func swizzle1() {
let originalSelector = NSSelectorFromString("systemLayoutSizeFittingSize:withHorizontalFittingPriority:verticalFittingPriority:")
let swizzledSelector = NSSelectorFromString("swizzledSystemLayoutSizeFitting:withHorizontalFittingPriority:verticalFittingPriority:")
guard
let originalMethod = class_getInstanceMethod(self, originalSelector),
let swizzledMethod = class_getInstanceMethod(self, swizzledSelector)
else { return }
print("swizzle1")
method_exchangeImplementations(originalMethod, swizzledMethod)
}
fileprivate static func swizzle2() {
let originalSelector = NSSelectorFromString("systemLayoutSizeFittingSize:")
let swizzledSelector = NSSelectorFromString("swizzledSystemLayoutSizeFitting:")
guard
let originalMethod = class_getInstanceMethod(self, originalSelector),
let swizzledMethod = class_getInstanceMethod(self, swizzledSelector)
else { return }
print("swizzle2")
method_exchangeImplementations(originalMethod, swizzledMethod)
}
@objc fileprivate func swizzledSystemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize {
print("Swizzled: \(type(of: self)) systemLayoutSizeFittingSize withHorizontalFittingPriority verticalFittingPriority \(targetSize)")
setNeedsLayout()
layoutIfNeeded()
return swizzledSystemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: horizontalFittingPriority, verticalFittingPriority: verticalFittingPriority)
}
@objc fileprivate func swizzledSystemLayoutSizeFitting(_ targetSize: CGSize) -> CGSize {
print("Swizzled: \(type(of: self)) systemLayoutSizeFittingSize \(targetSize)")
setNeedsLayout()
layoutIfNeeded()
return swizzledSystemLayoutSizeFitting(targetSize)
}
}
This is a common issue with custom "self sizing" UI components in a UITableViewCell
(also happens with UICollectionViewCell
).
The problem is related to the timing / order in which auto layout evaluates and sizes the cell.
Adding cell.label.layoutIfNeeded() // add this line to your code
in cellForRowAt
only partially solves it.
Note that on initial layout, we sometimes get an extra, blank line:
Also note that if the cell size changes, such as on device rotation, the layout fails:
and rotating back:
The most reliable approach that I've found involves overriding systemLayoutSizeFitting
in the cell class:
class Cell: UITableViewCell {
let label = MyLabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
contentView.addSubview(label)
// since I'm not using SnapKit
label.translatesAutoresizingMaskIntoConstraints = false
let g = contentView.layoutMarginsGuide
NSLayoutConstraint.activate([
label.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
label.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
label.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
label.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
])
// label.snp.makeConstraints { make in
// make.edges.equalToSuperview().inset(10)
// }
}
override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize {
//force layout of all subviews including RectsView, which
//updates RectsView's intrinsic height, and thus height of a cell
self.setNeedsLayout()
self.layoutIfNeeded()
//now intrinsic height is correct, so we can call super method
return super.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: horizontalFittingPriority, verticalFittingPriority: verticalFittingPriority)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
Now, initial layout becomes:
and it behaves on size change / device rotation: