Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Self-sizing cells and dynamic size controls for iOS

Problem definition

I am trying to build a custom control which will behave similarly to UILabel. I should be able to place such a control inside of a self-sizing table cell and it should:

  • Wrap it's content (like UILabel with numberOfLines=0 does)
  • automatically extend self-sized cell height size
  • handle a device rotation
  • don't require any special code in UITableCellView or ViewControll to implement this functionality (UILabel doesn't require any special code for that).

Research

The first thing which I did is very simple. I decided to observe how UILabel works. I did following:

  • created a table with self-sizing cells
  • created a custom cell, put UILabel (with numberOfLines=0) in it
  • created constraints to make sure that UILabel occupies a whole cell
  • subclasses UILabel and overrode a bunch of methods to see how it behaves

I checked following things

  • Run it in a portrait (the label is displayed correctly over several lines) and the cell height is correct
  • Rotate it. The table width and height was updated and they are correct too.

I observed that it behaves well. It doesn't require any special code and I saw the order of (some) calls which system does to render it.

A partial solution

@Wingzero wrote a partial solution below. It creates cells of a correct size.

However, his solution has two problems:

  • It uses "self.superview.bounds.size.width". This could be used if your control occupies a whole cell. However, if you have anything else in the cell which uses constraints then such code won't calculate a width correctly.

  • It doesn't handle rotation at all. I am pretty sure it doesn't handle other resizing events (there are bunch of less common resizing events - like a statusbar getting bigger on a phone call etc).

Do you know how to solve these problems for this case? I found a bunch of articles which talks about building more static custom controls and using pre-built controls in self-sizing cells.

However, I haven't found anything which put together a solution to handle both of these.

like image 784
Victor Ronin Avatar asked Mar 01 '16 00:03

Victor Ronin


People also ask

How do you make a table dynamic cell height?

To get dynamic cell heights working properly, you need to create a custom table view cell and set it up with the right Auto Layout constraints. In the project navigator, select the Views group and press Command-N to create a new file in this group.


3 Answers

I have to use the answer section to post my ideas and moving forward, though it may not be your answer, since I am not fully understand what's blocking you, because I think you already know the intrinsic size and that's it.

based on the comments, I tried to create a view with a text property and override the intrinsic:

header file, later I found maxPreferredWidth is not used totally, so ignore it:

@interface LabelView : UIView

IB_DESIGNABLE
@property (nonatomic, copy) IBInspectable NSString *text;
@property (nonatomic, assign) IBInspectable CGFloat maxPreferredWidth;

@end

.m file:

#import "LabelView.h"

@implementation LabelView

-(void)setText:(NSString *)text {
    if (![_text isEqualToString:text]) {
        _text = text;
        [self invalidateIntrinsicContentSize];
    }
}

-(CGSize)intrinsicContentSize {
    CGRect boundingRect = [self.text boundingRectWithSize:CGSizeMake(self.superview.bounds.size.width, CGFLOAT_MAX)
                                           options:NSStringDrawingUsesLineFragmentOrigin|NSStringDrawingUsesFontLeading
                                        attributes:@{NSFontAttributeName:[UIFont systemFontOfSize:16]}
                                           context:nil];
    return boundingRect.size;
}

@end

and a UITableViewCell with xib:

header file:

@interface LabelCell : UITableViewCell

@property (weak, nonatomic) IBOutlet LabelView *labelView;

@end

.m file:

@implementation LabelCell

- (void)awakeFromNib {
    [super awakeFromNib];
}

@end

xib, it's simple, just top, bottom, leading, trailing constraints: enter image description here

So running it, based on the text's bounding rect, the cell's height is different, in my case, I have two text to loop: 1. "haha", 2. "asdf"{repeat many times to create a long string}

so the odd cell is 19 height and even cell is 58 height: enter image description here

Is this what are you looking for?

My ideas:

the UITableView's cell's width is always the same as the table view, so that's the width. UICollectionView may be more issues there, but the point is we will calculate it and just return it is enough.

Demo project: https://github.com/liuxuan30/StackOverflow-DynamicSize
(I changed based on my old project, which has some images, ignore those.)

like image 125
Wingzero Avatar answered Oct 21 '22 05:10

Wingzero


Here's a solution that meets your requirements and is also IBDesignable so it previews live in Interface Builder. This class will lay out a series of squares (the total number is equal to the IBInspectable count property). By default, it will just lay them all out in one long line. But if you set the wrap IBInspectable property to On, it will wrap the squares and increase its height based on its constrained width (like a UILabel with numberOfLines == 0). In a self-sizing table view cell, this will have the effect of pushing out the top and bottom to accommodate the wrapped intrinsic size of the custom view.

The code:

import Foundation
import UIKit


@IBDesignable class WrappingView : UIView {

    private class InnerWrappingView : UIView {

        private var lastPoint:CGPoint = CGPointZero
        private var wrap = false
        private var count:Int = 100
        private var size:Int = 8
        private var spacing:Int = 3

        private func calculatedSize() -> CGSize {
            lastPoint = CGPoint(x:-(size + spacing), y: 0)
            for _ in 0..<count {
                var nextPoint:CGPoint!
                if wrap {
                    nextPoint = lastPoint.x + CGFloat(size + spacing + size) <= bounds.width ? CGPoint(x: lastPoint.x + CGFloat(size + spacing), y: lastPoint.y) : CGPoint(x: 0, y: lastPoint.y + CGFloat(size + spacing))
                } else {
                    nextPoint = CGPoint(x: lastPoint.x + CGFloat(size + spacing), y: lastPoint.y)
                }
                lastPoint = nextPoint
            }
            return CGSize(width: wrap ? bounds.width : lastPoint.x + CGFloat(size), height: lastPoint.y + CGFloat(size))
        }

        override func layoutSubviews() {
            super.layoutSubviews()
            guard bounds.size != calculatedSize() || subviews.count == 0 else {
                return
            }
            for subview in subviews {
                subview.removeFromSuperview()
            }
            lastPoint = CGPoint(x:-(size + spacing), y: 0)
            for _ in 0..<count {
                let square = createSquareView()
                var nextPoint:CGPoint!
                if wrap {
                    nextPoint = lastPoint.x + CGFloat(size + spacing + size) <= bounds.width ? CGPoint(x: lastPoint.x + CGFloat(size + spacing), y: lastPoint.y) : CGPoint(x: 0, y: lastPoint.y + CGFloat(size + spacing))
                } else {
                    nextPoint = CGPoint(x: lastPoint.x + CGFloat(size + spacing), y: lastPoint.y)
                }
                square.frame = CGRect(origin: nextPoint, size: square.bounds.size)
                addSubview(square)
                lastPoint = nextPoint
            }
            let newframe = CGRect(origin: frame.origin, size: calculatedSize())
            frame = newframe
            invalidateIntrinsicContentSize()
            setNeedsLayout()
        }


        private func createSquareView() -> UIView {
            let square = UIView(frame: CGRect(x: 0, y: 0, width: size, height: size))
            square.backgroundColor = UIColor.blueColor()
            return square
        }

        override func intrinsicContentSize() -> CGSize {
            return calculatedSize()
        }
    }

    @IBInspectable var count:Int = 500 {
        didSet {
            innerView.count = count
            layoutSubviews()
        }
    }

    @IBInspectable var size:Int = 8 {
        didSet {
            innerView.size = size
            layoutSubviews()
        }
    }

    @IBInspectable var spacing:Int = 3 {
        didSet {
            innerView.spacing = spacing
            layoutSubviews()
        }
    }

    @IBInspectable var wrap:Bool = false {
        didSet {
            innerView.wrap = wrap
            layoutSubviews()
        }
    }

    private var _innerView:InnerWrappingView! {
        didSet {
            clipsToBounds = true
            addSubview(_innerView)
            _innerView.clipsToBounds = true
            _innerView.frame = bounds
            _innerView.wrap = wrap
            _innerView.translatesAutoresizingMaskIntoConstraints = false
            _innerView.leftAnchor.constraintEqualToAnchor(leftAnchor).active = true
            _innerView.rightAnchor.constraintEqualToAnchor(rightAnchor).active = true
            _innerView.topAnchor.constraintEqualToAnchor(topAnchor).active = true
            _innerView.bottomAnchor.constraintEqualToAnchor(bottomAnchor).active = true
            _innerView.setContentCompressionResistancePriority(750, forAxis: .Vertical)
            _innerView.setContentHuggingPriority(251, forAxis: .Vertical)
        }
    }

    private var innerView:InnerWrappingView! {
        if _innerView == nil {
            _innerView = InnerWrappingView()
        }
        return _innerView
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        if innerView.bounds.width != bounds.width {
            innerView.frame = CGRect(origin: CGPointZero, size: CGSize(width: bounds.width, height: 0))
        }
        innerView.layoutSubviews()
        if innerView.bounds.height != bounds.height {
            invalidateIntrinsicContentSize()
            superview?.layoutIfNeeded()
        }
    }

    override func intrinsicContentSize() -> CGSize {
        return innerView.calculatedSize()
    }
}

In my sample application, I set the table view to dequeue a cell containing this custom view for each row, and set the count property of the custom view to 20 * the indexPath's row. The custom view is constrained to 50% of the cell's width, so its width will change automatically when moving between landscape and portrait. Because each successive table cell wraps a longer and longer string of squares, each cell is automatically sized to be taller and taller.

When running, it looks like this (includes demonstration of rotation):

custom view in self-sizing table cells

like image 35
Daniel Hall Avatar answered Oct 21 '22 07:10

Daniel Hall


To build on the other answer from @Wingzero, layout is a complex problem...

The maxPreferredWidth mentioned is important, and relates to preferredMaxLayoutWidth of UILabel. The point of this attribute is to tell a label not to just be one long line and to instead prefer to wrap if the width gets to that value. So when calculating the intrinsic size you would use the minimum of the preferredMaxLayoutWidth (if set) or the view width as the max width.

Another key aspect is invalidateIntrinsicContentSize which the view should call on itself whenever something changes and means a new layout is required.

UILabel doesn't handle rotation - it doesn't know about it. It's the responsibility of the view controller to detect and handle rotation, generally by invalidating the layout and updating the view size before triggering a new layout run. The labels (and other views) are just there to handle the resulting layout. As part of the rotation you (i.e. a view controller) may change the preferredMaxLayoutWidth as it makes sense to allow more width in landscape layout for example.

like image 33
Wain Avatar answered Oct 21 '22 07:10

Wain