Victor Ronin
Victor Ronin

Reputation: 23298

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:

Research

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

I checked following things

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:

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.

Upvotes: 11

Views: 1675

Answers (4)

Daniel Hall
Daniel Hall

Reputation: 13689

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

Upvotes: 3

Stephenye
Stephenye

Reputation: 814

enter image description here

Are you looking for something like this ^^? The cell has dynamic heights to facilitate the content of the UILabel, and there's no code to calculate size/width/height whatsoever - just some constraints.

Essentially, the label at left-hand side has top, bottom and leading margin to the cell, and trailing margin to the right-hand side label, which has trailing margin to the cell. Just need one label? Ignore the right hand side label then, and configure the left hand side label with a trailing constraint to the cell.

And if you need the label to be multi-line, configure that. Set the numberOfLines to 2, 3, or 0, up to you.

You don't need to calculate table view cell's height, the auto layout will calculate for you; but you need to let it know that, by telling it to use auto dimension: self.tableView.rowHeight = UITableViewAutomaticDimension, or return it in tableView:heightForRowAtIndexPath:. And you can also tell table view a "rough" estimation in tableView:estimatedHeightForRowAtIndexPath: for a better performance.

Still not working? Set the Content Compression Resistance Priority - Vertical to 1000 / Required for the UILabel in question, so that the label's content will try its best to "resist the compression", and the numberOfLines configuration will be fully acknowledged.

And it rotates? Try to observe the rotation (there're orientation change notifications) and then update layout (setNeedsLayout).

Still not working? More reads here: Using Auto Layout in UITableView for dynamic cell layouts & variable row heights

Upvotes: -2

Wain
Wain

Reputation: 119041

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.

Upvotes: 1

Wingzero
Wingzero

Reputation: 9754

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.)

Upvotes: 6

Related Questions