I am trying to self size DTAttributedTextContentView inside the collection cell based on its attributed text. The problem I face is that when I set attributedTextContentView width constraints like so:
attributedTextContentView.widthAnchor.constraint(lessThanOrEqualToConstant: 260)
it applies the whole constant width (in this case 260) to the textContentView, even if attributedString length is smaller than the width, leaving some extra space:
My question is, how to size the frame of DTAttributedTextContentView so that it just encloses the text that it contains?
Initially I used basic UITextView, but the scrolling of cells through collection view is not that smooth when there are multiple cells, and also it gives possibility to easy access the last line of the text inside, which I need for my app, so I would like to stick to DTAttributedTextContentView.
Here is the sample code:
import UIKit
import DTCoreText
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
configureCollectionView()
}
// MARK: - Collection view setup
let collectionView: UICollectionView = {
let layout = UICollectionViewCompositionalLayout { (section, environment) -> NSCollectionLayoutSection? in
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(10))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let group = NSCollectionLayoutGroup.vertical(layoutSize: itemSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 5
return section
}
layout.configuration.scrollDirection = .vertical
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.register(ConversationCollectionViewCell.self, forCellWithReuseIdentifier: "ConversationCell")
return collectionView
}()
private func configureCollectionView() {
collectionView.dataSource = self
collectionView.backgroundColor = .brown
view.addSubview(collectionView)
collectionView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
collectionView.topAnchor.constraint(equalTo: view.topAnchor),
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}
}
// MARK: - Collection Data Source
extension ViewController: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return 10
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ConversationCell", for: indexPath) as! ConversationCollectionViewCell
return cell
}
}
// MARK: - Collection View Custon Cell
final class ConversationCollectionViewCell: UICollectionViewCell, DTAttributedTextContentViewDelegate {
var mainCellContainerView = UIView()
var attributedTextContentView = DTAttributedTextContentView()
//MARK: - LIFECYCLE
override init(frame: CGRect) {
super.init(frame: frame)
setupmainCellContainerView()
setupAttributedTextContentView()
layoutIfNeeded()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - UI STEUP
private func setupmainCellContainerView() {
contentView.addSubview(mainCellContainerView)
mainCellContainerView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
mainCellContainerView.topAnchor.constraint(equalTo: contentView.topAnchor),
mainCellContainerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
mainCellContainerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
mainCellContainerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
])
}
private func setupAttributedTextContentView() {
mainCellContainerView.addSubview(attributedTextContentView)
attributedTextContentView.backgroundColor = .systemIndigo
attributedTextContentView.delegate = self
attributedTextContentView.sizeToFit()
let attributedString = NSAttributedString(string: "Simple message for testing purpose @", attributes: [
.font: UIFont(name: "HelveticaNeue", size: 17),
.foregroundColor: UIColor.white,
.paragraphStyle: {
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = .left
paragraphStyle.lineBreakMode = .byWordWrapping
return paragraphStyle
}()
])
attributedTextContentView.attributedString = attributedString
attributedTextContentView.contentMode = .redraw
attributedTextContentView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
attributedTextContentView.widthAnchor.constraint(lessThanOrEqualToConstant: 260),
attributedTextContentView.topAnchor.constraint(equalTo: mainCellContainerView.topAnchor),
attributedTextContentView.bottomAnchor.constraint(equalTo: mainCellContainerView.bottomAnchor),
])
}
}
The DTAttributedTextContentView
(as with UITextView
and multi-line UILabel
) wraps the text within the width that you give it.
In your code, you're setting the width to lessThanOrEqualToConstant: 260
... so DTAttributedTextContentView
is using that width. Actually, because you don't actually assign a width, I'm a bit surprised that it doesn't produce something like this:
Anyway, to get your desired result, you need to calculate the width of the text yourself using attributedString.boundingRect
.
So, we modify your cell class to use a variable width constraint on the "DT" view, and update that when we set the text:
public func updateText(_ str: String) {
let attributedString = NSAttributedString(string: str, attributes: [
.font: UIFont(name: "HelveticaNeue", size: 17),
.foregroundColor: UIColor.white,
.paragraphStyle: {
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = .left
paragraphStyle.lineBreakMode = .byWordWrapping
return paragraphStyle
}()
])
// max width of 260.0
let r: CGRect = attributedString.boundingRect(with: .init(width: 260.0, height: .greatestFiniteMagnitude), options: .usesLineFragmentOrigin, context: nil)
widthConstraint.constant = ceil(r.width)
attributedTextContentView.attributedString = attributedString
}
and cellForItemAt
becomes:
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ConversationCell", for: indexPath) as! ConversationCollectionViewCell
cell.updateText(yourData[indexPath.item])
return cell
}
Here is your code, modified:
Cell class
final class ConversationCollectionViewCell: UICollectionViewCell, DTAttributedTextContentViewDelegate {
var mainCellContainerView = UIView()
var attributedTextContentView = DTAttributedTextContentView()
var widthConstraint: NSLayoutConstraint!
//MARK: - LIFECYCLE
override init(frame: CGRect) {
super.init(frame: frame)
setupmainCellContainerView()
setupAttributedTextContentView()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - UI STEUP
private func setupmainCellContainerView() {
contentView.addSubview(mainCellContainerView)
mainCellContainerView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
mainCellContainerView.topAnchor.constraint(equalTo: contentView.topAnchor),
mainCellContainerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
mainCellContainerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
mainCellContainerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
])
}
private func setupAttributedTextContentView() {
mainCellContainerView.addSubview(attributedTextContentView)
attributedTextContentView.backgroundColor = .systemIndigo
attributedTextContentView.delegate = self
attributedTextContentView.contentMode = .redraw
attributedTextContentView.translatesAutoresizingMaskIntoConstraints = false
// initialize the width constraint - it will be updated when the text is set
widthConstraint = attributedTextContentView.widthAnchor.constraint(equalToConstant: 260.0)
NSLayoutConstraint.activate([
widthConstraint,
attributedTextContentView.topAnchor.constraint(equalTo: mainCellContainerView.topAnchor),
attributedTextContentView.bottomAnchor.constraint(equalTo: mainCellContainerView.bottomAnchor),
])
}
public func updateText(_ str: String) {
let attributedString = NSAttributedString(string: str, attributes: [
.font: UIFont(name: "HelveticaNeue", size: 17),
.foregroundColor: UIColor.white,
.paragraphStyle: {
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = .left
paragraphStyle.lineBreakMode = .byWordWrapping
return paragraphStyle
}()
])
// max width of 260.0
let r: CGRect = attributedString.boundingRect(with: .init(width: 260.0, height: .greatestFiniteMagnitude), options: .usesLineFragmentOrigin, context: nil)
widthConstraint.constant = ceil(r.width)
attributedTextContentView.attributedString = attributedString
layoutIfNeeded()
}
}
Controller class
class ViewController: UIViewController {
let samples: [String] = [
"Sample 1",
"The second sample.",
"This is the third sample string.",
"Simple message for testing purpose @",
"This is yet another sample, which should wrap onto at least three lines (based on a max-width of 260 points).",
"Here is the final sample string for this example.",
]
override func viewDidLoad() {
super.viewDidLoad()
configureCollectionView()
}
// MARK: - Collection view setup
let collectionView: UICollectionView = {
let layout = UICollectionViewCompositionalLayout { (section, environment) -> NSCollectionLayoutSection? in
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(10))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let group = NSCollectionLayoutGroup.vertical(layoutSize: itemSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 5
return section
}
layout.configuration.scrollDirection = .vertical
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.register(ConversationCollectionViewCell.self, forCellWithReuseIdentifier: "ConversationCell")
return collectionView
}()
private func configureCollectionView() {
collectionView.dataSource = self
collectionView.backgroundColor = .brown
view.addSubview(collectionView)
collectionView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
collectionView.topAnchor.constraint(equalTo: view.topAnchor),
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}
}
// MARK: - Collection Data Source
extension ViewController: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return samples.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ConversationCell", for: indexPath) as! ConversationCollectionViewCell
cell.updateText(samples[indexPath.item])
return cell
}
}
Should look like this on an iPhone 15 Pro: