iosswiftuikitautolayout

Sizing DTAttributedTextContentView based on its attributedString


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: enter image description here

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),
    ])
  }
}

Solution

  • 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:

    enter image description here

    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:

    enter image description here