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:
numberOfLines=0)
in it 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:
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.
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.
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:
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:
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.)
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):
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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With