11

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.

Victor Ronin
  • 22,758
  • 18
  • 92
  • 184
  • don't use auto layout? :) – nielsbot Mar 03 '16 at 01:26
  • @Wingzero. I want to write a compont which behave itself like a UILabel. It could be placed in self-sizing cell and properly wrap it's content (while extending height of this cell-sized cell). This is pretty much it. BTW. UILabel intristicContentSize isn't completely frame agnostic. It uses preferredLayoutWidth (which is pretty much frame.width) to figure out how it should be layed out. – Victor Ronin Mar 03 '16 at 02:54
  • @VictorRonin, yeah I think you are on the right way, and you are saying you are having trouble to calculate intristicContentSize, don't know how to get the width? then what's inside your UIView?, you should know the width already when you are overrding intrinsic size. – Wingzero Mar 03 '16 at 03:24
  • @Wingzero. Are you referring to a width of a UIView or my content. If you are referring the width of UIView, that's a #2 which I described. The frame will be wrong (IB builder value vs real runtime value) while call to intrinsicContentSize. If you are referring the width of my content then yes I know it. However, the same as with text, it could be expressed as example as 900 pt for 1 line of ext of 300 pt for 3 lines of text. I need to return multiple lines to let iOS make a cell of correct height. And this requires me to know a UIView / cell width. – Victor Ronin Mar 03 '16 at 15:58
  • @VictorRonin True, it involves more work. But have you tried doing layout with CGRectDivide/CGRectInset or their Swift equivalents? That is way less work than dealing with edges/pos(x,y). – nielsbot Mar 03 '16 at 18:06
  • @nielsbot I haven't tried it. Frankly, it's my plan B. However, if I can do it with autolayout it would be way cleaner and elegant and will allow encapsulate everything within a component. – Victor Ronin Mar 04 '16 at 02:27
  • Which methods did you override and check exactly? And which did you see called? – Wain Jul 05 '16 at 06:51
  • @Wain I overriden updateConstraints, intrinsicContentSize and saw that both of them are called on initial rendering and when a device orientation changed. – Victor Ronin Jul 05 '16 at 19:51

4 Answers4

6

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

Wingzero
  • 9,644
  • 10
  • 39
  • 80
  • I need to look into this carefully. Two comments: it's interesting that "self.superview.bounds.size.width" worked for you. I was under impression that cell size isn't figured out until later in the game. On one hand, this could be a "good enough" shortcut. On other hand, for UILabel you can set up constraints (so it will occupy only part of the cell). This solution won't be able to handle it, because it uses only superview (cell) width. – Victor Ronin Mar 04 '16 at 22:23
  • @VictorRonin the tool is called Reveal, not a design tool but a ui debugging tool, you will never regret to buy. – Wingzero Mar 04 '16 at 22:33
  • @VictorRonin the cell will layout like several times I remember. for second one, what you mean part of it? if it cannot occupy the full height of cell, a content hugging/resistance may help – Wingzero Mar 04 '16 at 22:36
  • @VictorRonin have you sorted it out? – Wingzero Mar 09 '16 at 10:56
  • I will award you a bounty, taking into account that you are the only person who worked on this problem. However, the question which is still unanswered is how to get a control width based on all kind of constraints which may exist in the cell (if there other controls in the cell) – Victor Ronin Mar 09 '16 at 18:50
  • Thank, but I think I need to earn it :) do you mean, if this UIView is on left, and other view on right, so the UIView is not as cell's width? If this is true, then what I think is, at least we can iterate all of the cell's constraints, to manually calculate what width is left for this UIView. Also, you can check if this UIView's frame is calculated by the autolayout engine at this moment. If this is true, then it will be quite easy. If you have a demo project that's better so we know the problem. – Wingzero Mar 10 '16 at 01:05
  • Finally, I got back to look into it. There are two problems currently. Problem #1 is that we use "self.superview.bounds.size.width". In the real project the cell composition could be complex and a self-sizing control may occupy only part of the cell. You suggested: "we can iterate all of the cell's constraints, to manually calculate what width". I don't think that UILabel does that and it's complex task (pretty much you need to build a constraint resolution engine on your own vs using iOS one). – Victor Ronin Jul 04 '16 at 16:01
  • Second problem is that intrinsicContentSize isn't called on a device rotation. I wonder how UILabel handles it. I am pretty sure that it doesn't require to add rotation handling in a viewcontroller. Thinking aloud: Does it override some method and call invalidateIntristicSize? – Victor Ronin Jul 04 '16 at 16:02
  • Actually. I haven't thought about it. I will do it. Thanks. – Victor Ronin Jul 05 '16 at 01:52
3

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

Daniel Hall
  • 13,457
  • 4
  • 41
  • 37
  • Daniel. This is amazing. Thanks a lot. I was looking for this for a long time. Let me award the bounty for you. If you don't mind, can you do two things: add some comments through the code (I will be able to figure it out from a working example, but it will help everybody else who will be looking into it). Also, there is a small bug. First time when you start it, it shows a wrong number of squares per cell (and only after rotation it start showing the right one). – Victor Ronin Jul 08 '16 at 05:53
  • BTW. Making it designable is really nice touch :) – Victor Ronin Jul 08 '16 at 05:53
  • that's an awesome one, though a little bit complicated, but I think the core part is still IntrinsicContentSize() concept – Wingzero Jul 08 '16 at 11:43
1

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.

Wain
  • 118,658
  • 15
  • 128
  • 151
  • Multiple questions here. Something should set preferredMaxLayoutWidth. I can create such property in my class. However, it's not clear when it needs to be set (specifically to make sure that it matches a size calculated by other constraints). I agree that UILabel doesn't directly subscribe for device orientation changes. However, it overrides some UIView methods to see that layout has changed. You can put a label in UITableView and you will notice that you don't need any additional code to handle rotation. – Victor Ronin Jul 05 '16 at 19:57
  • @Wingzero custom control don't override these methods and as result don't see that layout has changed (as result of device orientation change) – Victor Ronin Jul 05 '16 at 19:57
  • rotation will only be handled with zero additional code if you're using a table view controller - because it contains the code resize the table and run a new layout (or, specifically, table height calculation)... you need to set the `preferredMaxLayoutWidth` when you're designing the cell contents. – Wain Jul 05 '16 at 20:12
  • I believe UITableView (vs UITableViewController) handles rotation too. meaning that it runs new layout. So, if you have generic UIViewController. Place UITableView in it and put cells with UILabels in it then it will work without any special code too. – Victor Ronin Jul 06 '16 at 18:14
  • it's possible that the table view itself invalidates its layout when its frame changes – Wain Jul 06 '16 at 18:32
-2

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

Community
  • 1
  • 1
Stephenye
  • 806
  • 6
  • 12
  • You are talking about making a self-sizing cells and how to use them in the table. I am looking for a way to create a _custom component_ with a dynamical height which works correctly in a self-sizing cell. – Victor Ronin Jul 07 '16 at 04:32
  • @VictorRonin so you are saying that, the cell already somehow has self-sizing logic, and you want to have a component within the cell with correct size? If that's the case, you can still have a UIView with top/bottom/leading/trailing constraints to the cell, so that the component will be resized accordingly. Sorry if that's not what you wanted. – Stephenye Jul 07 '16 at 15:01
  • I think you need to read my question carefully and check other answers. The cell is already self-sizing (you can read about them here - https://developer.apple.com/library/ios/documentation/UserExperience/Conceptual/AutolayoutPG/WorkingwithSelf-SizingTableViewCells.html).Self sizing cells asks views which are inside of them what size they should be (and this is very different from common layout when through constraints a cell will force a view to be some specific size). As a result, it's a custom control job to figure out what size it needs to be. – Victor Ronin Jul 08 '16 at 02:11
  • What you said is definitely true, and that's also what I said - the components inside the cell should tell the cell what size it should be, and the components do that based on 1) calculating their intrinsic content size, or 2) by setting up constraints among them and against the cell. Other answers here are trying to calculate intrinsic size, but I don't think that has to be the way of doing it. If you have say 5 views inside a cell, and you properly configured constraints, the cell will observe the constraints and dynamically size itself (particularly its height). – Stephenye Jul 08 '16 at 14:37