Search code examples
swiftautolayoutcollectionview

How can I get the contentView to resize when the content changes?


Below you can see two posts, one with an image, and one without. I placed borders around the views to better understand what is happening. I want the posts without images to be sized smaller than images with posts. I attempted to do this by doing the following:

func setupViews() {

      backgroundColor = UIColor.white

      addSubview(titleLabel)
      addSubview(iconImageView)
      addSubview(messageTextView)
      addSubview(messageImageView)

      iconImageView.anchor(top: contentView.topAnchor,
                           leading: contentView.leadingAnchor,
                           bottom: nil, trailing: nil, 
                           padding: .init(top: 0, left: 8, bottom: 0, right: 0), size: CGSize(width: 44, height: 44))

      titleLabel.anchor(top: contentView.topAnchor, 
                        leading: iconImageView.trailingAnchor,
                        bottom: nil, trailing: nil,
                        padding: .init(top: 12, left: 8, bottom: 0, right: 0))

      messageTextView.anchor(top: titleLabel.bottomAnchor,
                             leading: contentView.leadingAnchor,
                             bottom: nil,
                             trailing: contentView.trailingAnchor,
                             padding: .init(top: 4, left: 10, bottom: 0, right: 10))

      messageImageViewHeightConstraint = messageImageView.heightAnchor.constraint(equalToConstant: 200)
      messageImageViewHeightConstraint.isActive = true
      messageImageView.anchor(top: messageTextView.bottomAnchor, 
                              leading: contentView.leadingAnchor,
                              bottom: contentView.bottomAnchor,
                              trailing: contentView.trailingAnchor,
                              padding: .init(top: 4, left: 10, bottom: 0, right: 10))
}

When the posts are loading, I set the messageImageViewHeightConstraint.constant = 0 if the post does not have an image (optionals). This works to collapse the imageView. Unfortunately as you can see the textView expands to cover the remaining space. I don't want this, I want the contentView's intrinsic size to shrink, and I just want the text to expand to meet the content's intrinsic size. How can I do this? Thank you in advance.

Edit: more code for reference

     private let iconImageView: UIImageView = {
      let iv = UIImageView()
      iv.contentMode = .scaleAspectFit
      iv.layer.cornerRadius = 10
      iv.translatesAutoresizingMaskIntoConstraints = false
      iv.clipsToBounds = true
      iv.layer.borderWidth = 1
      return iv
 }()

 private let titleLabel: UILabel = {
      let label = UILabel()
      label.numberOfLines = 0
      label.translatesAutoresizingMaskIntoConstraints = false
      label.textColor = .black
      label.layer.borderWidth = 1
      return label
 }()

 private let messageTextView: UILabel = {
      let labelView = UILabel()
      labelView.numberOfLines = 0
      labelView.translatesAutoresizingMaskIntoConstraints = false
      labelView.font = UIFont.systemFont(ofSize: 14)
      labelView.layer.borderWidth = 1
      return labelView
 }()

 private let messageImageView: UIImageView = {
      let imageView = UIImageView()
      imageView.contentMode = .scaleAspectFit
      imageView.layer.masksToBounds = true
      imageView.layer.borderWidth = 1
      imageView.translatesAutoresizingMaskIntoConstraints = false
      return imageView
 }()

Example image

Edit #2 After following suggestions, here is the new code:

var post: Post?{
      didSet{
           guard let post = post else {return}

           // Adding user's name
           let attributedText = NSMutableAttributedString(string: post.author.name + " → " + post.group.name, attributes: [NSAttributedString.Key.font: UIFont.boldSystemFont(ofSize: 14)])

           // Adding date and user's first name
           let dateFormatter = DateFormatter()
           dateFormatter.dateStyle = .long
           dateFormatter.timeStyle = .short
           attributedText.append(NSAttributedString(string: "\n" + dateFormatter.string(from: post.timeCreated), attributes: [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 12), NSAttributedString.Key.foregroundColor: UIColor(r: 155/255, g: 161/255, b: 171/255)]))

           // Increasing Spacing
           let paragraphStyle = NSMutableParagraphStyle()
           paragraphStyle.lineSpacing = 4
           attributedText.addAttribute(NSAttributedString.Key.paragraphStyle, value: paragraphStyle, range: NSMakeRange(0, attributedText.length))

           titleLabel.attributedText = attributedText

           // Setting profile image
           iconImageView.setImage(for: post.author, setContentMode: .scaleAspectFit)

           DispatchQueue.main.async {
                self.setupTextAndImageSubviews()
           }
      }
 }
     override init(frame: CGRect) {
      super.init(frame: frame)

      setupDefaultViews()
 }

 required init?(coder: NSCoder) {
      fatalError("init(coder:) has not been implemented")
 }

 func setupDefaultViews(){
      backgroundColor = UIColor.white

      addSubview(titleLabel)
      addSubview(iconImageView)

      iconImageView.anchor(top: contentView.topAnchor, leading: contentView.leadingAnchor, bottom: nil, trailing: nil, padding: .init(top: 0, left: 8, bottom: 0, right: 0), size: CGSize(width: 44, height: 44))
      titleLabel.anchor(top: contentView.topAnchor, leading: iconImageView.trailingAnchor, bottom: nil, trailing: nil, padding: .init(top: 12, left: 8, bottom: 0, right: 0))
 }

 private func setupTextAndImageSubviews() {
      addSubview(messageTextView)

      var textViewBottomAnchor: NSLayoutYAxisAnchor? = contentView.bottomAnchor

      if self.post?.messageImageURL != nil {
           textViewBottomAnchor = nil // dont need to anchor text view to bottom if image exists
      }

      // Setting body text
      messageTextView.text = self.post?.body

      messageTextView.anchor(top: titleLabel.bottomAnchor,
                             leading: contentView.leadingAnchor,
                             bottom: textViewBottomAnchor,
                             trailing: contentView.trailingAnchor,
                             padding: .init(top: 4, left: 10, bottom: 0, right: 10))

      guard let imageURL = self.post?.messageImageURL else {return} // if no image exists, return, preventing image view from taking extra memory and performance to initialize and calculate constraints

      // initialize here instead of globally, so it doesnt take extra memory holding this when no image exists.
      let messageImageView: UIImageView = {
           let imageView = UIImageView()
           imageView.kf.setImage(with: imageURL, placeholder: UIImage(systemName: "person.crop.circle.fill")!.withTintColor(.gray).withRenderingMode(.alwaysOriginal))
           imageView.contentMode = .scaleAspectFit
           imageView.layer.masksToBounds = true
           imageView.layer.borderWidth = 1
           imageView.translatesAutoresizingMaskIntoConstraints = false
           return imageView
      }()

      addSubview(messageImageView)
      messageImageView.anchor(top: messageTextView.bottomAnchor,
                              leading: contentView.leadingAnchor,
                              bottom: contentView.bottomAnchor,
                              trailing: contentView.trailingAnchor,
                              padding: .init(top: 4, left: 10, bottom: 0, right: 10))
 }

Constraint error: 2021-05-11 13:18:28.184077-0700 GroupUp[8223:1981252] [LayoutConstraints] Unable to simultaneously satisfy constraints. Probably at least one of the constraints in the following list is one you don't want. (1) look at each constraint and try to figure out which you don't expect; (2) find the code that added the unwanted constraint or constraints and fix it. "<NSLayoutConstraint:0x283552170 V:[UILabel:0x10598a4e0]-(4)-[UIImageView:0x10881ca00] (active)>", "<NSLayoutConstraint:0x283550af0 UIImageView:0x10881ca00.bottom == UIView:0x10598a750.bottom (active)>", "<NSLayoutConstraint:0x28356db80 UILabel:0x10598a4e0.bottom == UIView:0x10598a750.bottom (active)>" Will attempt to recover by breaking constraint <NSLayoutConstraint:0x283552170 V:[UILabel:0x10598a4e0]-(4)-[UIImageView:0x10881ca00] (active)> Make a symbolic breakpoint at UIViewAlertForUnsatisfiableConstraints to catch this in the debugger. The methods in the UIConstraintBasedLayoutDebugging category on UIView listed in <UIKitCore/UIView.h> may also be helpful.


Solution

  • It's because the image view still has an anchor to the top of the text view, and one to the bottom of the content view, so the text view never has an anchor to bottom content view to resize it self and the content view itself, it only has an anchor to the top of the image view.

    if you set your imageView's background color to something like red and set the height 2 instead of 0 you would see what's happening.

    there are multiple routes you can take to fixing this, the one that I personally think would be the most performance friendly would be to only set the text view and image view and their constraints when you know what data you are dealing with here. image or no image. since right now if you have no image there is an empty imageView inside your view hierarchy just sitting there taking memory (and constraint calculation). and if you were to have some constraints/anchors by default and change them based on new data it would mean re calculating constraints that have already been calculated which would cost performance.

    so my approach would look something like:

    var data: yourDataModel? {
        didSet {
         self.updateUI()
        }
    }
    
    private func updateUI() {
    
    //do all the normal stuff you do with your data here
    
       //run in main thread in case your data is being loaded from the background thread
       DispatchQueue.main.async {
          self.setupContentSubviews() 
       }
    
    }
    
    private func setupContentSubviews() {
    
       addSubview(messageTextView)
    
       var textViewBottomAnchor: NSLayoutYAxisAnchor? = contentView.bottomAnchor
    
       if self.data.image != nil {
          textViewBottomAnchor = nil // dont need to anchor text view to bottom if image exists
       }
    
       messageTextView.anchor(top: titleLabel.bottomAnchor,
                              leading: contentView.leadingAnchor,
                              bottom: textViewBottomAnchor,
                              trailing: contentView.trailingAnchor,
                              padding: .init(top: 4, left: 10, bottom: 0, right: 10))
    
       guard let image = self.data.image else {return} // if no image exists, return, preventing image view from taking extra memory and performance to initialize and calculate constraints
    
       // initialize here instead of globally, so it doesnt take extra memory holding this when no image exists.
       private let messageImageView: UIImageView = {
             let imageView = UIImageView(image: image)
             imageView.contentMode = .scaleAspectFit
             imageView.layer.masksToBounds = true
             imageView.layer.borderWidth = 1
             imageView.translatesAutoresizingMaskIntoConstraints = false
             return imageView
        }()
    
        addSubview(messageImageView)
        messageImageViewHeightConstraint = messageImageView.heightAnchor.constraint(equalToConstant: 200)
        messageImageViewHeightConstraint.isActive = true
        messageImageView.anchor(top: messageTextView.bottomAnchor, 
                                leading: contentView.leadingAnchor,
                                bottom: contentView.bottomAnchor,
                                trailing: contentView.trailingAnchor,
                                padding: .init(top: 4, left: 10, bottom: 0, right: 10))
         }
    
    }