Summary: I have a child view within a stackview and that child view that is programatically sized. It has a couple of empty views to fill up the empty space. At runtime, however, attempts to resize the child don't work and it remains the same size as in the storyboard. How can I resize the child view so that the stack view honours its new size and positions it accordingly.
Details:
I have a UICollectionView with a custom layout. The layout calculates the positions of subviews correctly (they display where I want) and return the correct content size. The content size is narrower than the screen but possibly longer depending upon orientation.
The custom layout returns the correct, calculated size but the collection view does not resize.
I've tried programatically changing the collection view's size on the parent's viewDidLoad. Didn't work.
I've tried programatically changing the collection view's layoutMargins on the parent's viewDidLoad. Didn't work.
I am using Swift 3, XCode 8.
If I understand your question, you need your UICollectionView to have its size equals to its content, in other words, you want your UICollectionView to have an intrinsic size (just like a label which resizes automatically based on its text).
In that case, you can subclass UICollectionView to add this behaviour, then you don't need to set its size.
class IntrinsicSizeCollectionView: UICollectionView {
// MARK: - lifecycle
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
self.setup()
}
override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) {
super.init(frame: frame, collectionViewLayout: layout)
self.setup()
}
override func layoutSubviews() {
super.layoutSubviews()
if !self.bounds.size.equalTo(self.intrinsicContentSize) {
self.invalidateIntrinsicContentSize()
}
}
override var intrinsicContentSize: CGSize {
get {
let intrinsicContentSize = self.contentSize
return intrinsicContentSize
}
}
// MARK: - setup
func setup() {
self.isScrollEnabled = false
self.bounces = false
}
}
ps: In your .xib, don't forget to set your IntrinsicSizeCollectionView height constraint as a placeholder constraint (check "Remove at build time")
Related
I'm trying to build something
I'm trying to build a tag list view using UICollectionView and nest it into my custom UITableViewCell.
What do I have now
After searching the internet, I find the key to the problem:
Subclass UICollectionView and implement it's intrinsic content size property.
However, when I nest my custom UICollectionView into a self-sizing UITableViewCell, the whole thing doesn't work well. The layout is broken.
No matter how do I change the code, I get one of the following 3 buggy UIs.
The height of the collection view is always wrong, either too small or too large, it can not hug it's content just right.
When I use Debug View Hierarchy to check the views, I find that although the UI is broken, the contentSize property of the collection view has a correct value. It seems that the content size property can not be reflected to the UI in time.
class IntrinsicCollectionView: UICollectionView {
override var contentSize: CGSize {
didSet {
invalidateIntrinsicContentSize()
}
}
override var intrinsicContentSize: CGSize {
layoutIfNeeded()
return CGSize(width: UIView.noIntrinsicMetric, height: collectionViewLayout.collectionViewContentSize.height)
}
override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) {
super.init(frame: frame, collectionViewLayout: layout)
isScrollEnabled = false
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
There are many solution about how to create a custom UICollectionView with intrinsic content size. Some of them can work correctly. But when nesting them into a UITableViewCell, none of them works well.
There are also some answer about just nest one UICollectionView into UITableViewCell without other views. But if there are also some UILabel in UITableViewCell, it won't work.
I upload all the code to github. https://github.com/yunhao/nest-collectionview-in-tableviewcell
Thank you!
I'll try to explain what's going on....
To make it easy to understand, in your ListViewController let's work with just one row to begin with:
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return 1 // items.count
}
In your ListViewCell class, add these lines at the end of prepareViews():
// so we can see the element frames
titleLabel.backgroundColor = .green
subtitleLabel.backgroundColor = .cyan
collectionView.backgroundColor = .yellow
In your IntrinsicCollectionView class, let's add a print() statement to give us some information:
override var intrinsicContentSize: CGSize {
layoutIfNeeded()
// add this line
print("collView Width:", frame.width, "intrinsic height:", collectionViewLayout.collectionViewContentSize.height)
return CGSize(width: UIView.noIntrinsicMetric, height: collectionViewLayout.collectionViewContentSize.height)
}
When I then run the app on an iPhone 8, I get this result:
and I see this in the debug console:
collView Width: 66.0 intrinsic height: 350.0
collView Width: 343.0 intrinsic height: 30.0
What that tells me is that the collection view is asked for its intrinsicContentSize before it has a complete frame.
At that point, it fills in its cells, and its layout ends up with a .collectionViewContentSize.height of 350 (this row has six "tags" cells).
Auto-layout then performs another pass... the collection view now has a valid frame width (based on the cell width)... and the cells are re-laid-out.
Unfortunately, the table view has already set the row height(s), based on the initial collection view intrinsicContentSize.height.
So, two steps that may (should) fix this:
In ListViewCell, invalidate the content size of the collection view when you get the tags:
func setTags(_ tags: [String]) {
self.tags = tags
collectionView.reloadData()
// add this line
collectionView.invalidateIntrinsicContentSize()
}
Then, in ListViewController, we need to reload the table after its frame has changed:
// add this var
var currentWidth: CGFloat = 0
// implement viewDidLayoutSubviews()
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if view.frame.width != currentWidth {
currentWidth = view.frame.width
tableView.reloadData()
}
}
That seems (with very quick testing) to give me reliable results:
and on device rotation:
I have a UIView on a XIB, containing an UIImageView and a UILabel with a small space in between. Horizontal layout is a simple chained |--image--label--| in which -- is some fixed space. The height is fixed to 40, this view is horizontally centred in its view controller, and it has an >= 100 width constraint.
If I change the label text, the width of my composed view updates as expected width the changed width of its label, and it stays nicely centred on the view controller.
Because I need this UIView, containing an image and label, in other places, I've created a custom class consisting of a XIB and Swift file. Let's call it ItemView.
Issue I have is that the empty UIView on my view controller XIB, which I've changed class to ItemView, no longer accepts the >= 40 width constraint. This is of course because the view controller XIB no longer sees the variable width UILabel, but instead just a plain UIView of class ItemView. I get an 'Inequality Constraint Ambiguity' IB error.
The result is that the width of my custom view remains 40. It works a little bit if I specify a larger >= label width; the text is then only cut off when this width is reached. But in that second case my custom view is no longer horizontally centred, but shifted a bit to the left instead.
How do I resolve this? Or, how can I tell IB to treat my custom ItemView in a similar way as a UILabel?
In my ItemView I've done all I could find:
override class var requiresConstraintBasedLayout: Bool
{
return true
}
Call setNeedsLayout() after setting the label text.
Call myLabel.translatesAutoresizingMaskIntoConstraints = false.
Call self.translatesAutoresizingMaskIntoConstraints = false.
And call self.setNeedsUpdateConstraints() in both init()s.
Configure this pop-up in the view's Size inspector:
Now IB won't worry about your size specifications. You know better than IB does, and this is how to tell IB that fact.
Another way: configure this pop-up in the view's Size inspector:
This tells IB that the view will have an intrinsic content size that it doesn't know about.
Either of those will work. As you can see in this screenshot, I've given my custom view a width constraint of greater-than-or-equal-to-40, but IB is not complaining of any error:
How I structured my custom UIView: My XIB's Files Owner is ItemView. ItemView has an #IBOutlet var: UIView! that's connected to the view in the XIB. Then I have:
init()
{
super.init(frame: .zero)
self.initView()
}
required init?(coder aDecoder: NSCoder)
{
super.init(coder: aDecoder)
self.initView()
}
private func initView()
{
Bundle.main.loadNibNamed("ItemView", owner: self, options: nil)
self.view.frame = CGRect(x: 0.0, y: 0.0, width: self.width, height: self.height)
self.addSubview(self.view!)
}
(I seems this extra UIView is needed when creating a custom UIView with a XIB. Would love to hear if it's not needed after all.)
To make it work I needed to call self.invalidateIntrinsicContentSize() after updating the label. And override intrinsicContentSize:
override open var intrinsicContentSize: CGSize
{
return self.view.systemLayoutSizeFitting(self.bounds.size)
}
Note that I'm calling this on self.view, not on self.
Thanks #matt for pointing me in the right direction. Was the first time I've encountered something like this.
The following things I tried we all not necessary:
override class var requiresConstraintBasedLayout: Bool
{
return true
}
Call setNeedsLayout() after setting the label text.
Call myLabel.translatesAutoresizingMaskIntoConstraints = false.
Call self.translatesAutoresizingMaskIntoConstraints = false.
And call self.setNeedsUpdateConstraints() in both init()s.
I'm trying to subclass UIScrollView, to do some custom drawing and creation of customized UIViews. The drawing and creation of UIViews works fine, but the view just doesn't scroll.
The internal height of the view is fixed, and I calculate it in the init method. I also override the intrinsticContentSize method, but that doesn't work.
What am I doind wrong?
import UIKit
class CustomView: UIScrollView, UIScrollViewDelegate {
// MARK: - layout constants
private var _intrinsicSize: CGSize?;
override init(frame: CGRect) {
super.init(frame: frame);
self.didLoad();
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder);
self.didLoad();
}
private func didLoad() {
self.delegate = self;
var result = CGSize();
result.height = CGFloat(_halfHourHeight * 48);
result.width = 500;
_intrinsicSize = result;
}
override func intrinsicContentSize() -> CGSize {
return self._intrinsicSize!;
}
override func drawRect(rect: CGRect) {
super.drawRect(rect);
// some custom drawing here
}
}
Scroll views generally don't have an intrinsic size, it usually doesn't mean anything. They have a frame, bounds and content size - it's the content size you're interested in setting and it goes into setting the bounds.
The content size is the total size of all the subviews, and the bounds is the window onto the currently visible area of those subviews.
You also wouldn't usually have custom drawing code, though you can. You'd usually add subviews to do that drawing for the scroll view.
So I have a custom UIView class
class MessageBox: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
createSubViews()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
createSubViews()
}
func createSubViews() {
let testView = UIView(frame: self.frame)
testView.backgroundColor = UIColor.brown
self.addSubview(testView)
}
}
I added a UIView inside the storyboard and gave it some constraints:
100 from the top (superview), 0 from the left and right, height is 180
But when I run the app the brown subview I created in the code is way to big. I printed self.frame in my custom view and it turns out that the frame is (0,0,1000,1000). But why? I set constraints, it should be something like (0,0,deviceWith, 180).
What did I do wrong?
EDIT: That's my Storyboard setup:
Short and simple answer:
You're doing it too early.
Detailed answer:
When a view is initialized from an Interface Builder file (a xib or a storyboard) its frame is initially set to the frame it has in Interface Builder. You can look at it as a temporary placeholder.
When using Auto Layout the constraints are resolved (= the view's actual frame is computed) inside the view's layoutSubviews() method.
Thus, there are two possible solutions for your problem:
(preferrable) If you use Auto Layout, use it throughout your view.
Either add your testView in Interface Builder as well and create an outlet for it
or create your testView in code as you do, then set its translatesAutoResizingMaskIntoConstraints property to false (to sort of "activate Auto Layout") and add the required constraints for it in code.
Set your testView's frame after the MessageBox view's frame itself has been set by the layout engine. The only place where you can be sure that the system has resolved the view's frame from the constraints is when layoutSubviews() is called.
override func layoutSubviews() {
super.layoutSubviews()
testView.frame = self.frame
}
(You need to declare your testView as a property / global variable, of course.)
Try to use the anchors for your view:
MessageBox.centerXAnchor.constraintEqualToAnchor(self.view.centerXAnchor).active
= true
MessageBox.centerYAnchor.constraintEqualToAnchor(self.view.centerYAnchor).active
= true
MessageBox.widthAnchor.constraintEqualToConstant(150).active = true
MessageBox.heightAnchor.constraintEqualToConstant(100).active = true
This method have to be used inside your class
override func layoutSubviews() {
super.layoutSubviews()
testView.frame = self.frame
}
this also works when you add a custom class to a UIView in the storyboard and that uses autolayout.
thanks Mischa !
try to add a height and width constraint relative to the superview height, with some multiplier.
I have some weird behavior related to frame sizes that I can't fix after hours of trying different things. This just doesn't make any sense.
I have a custom UIView and a related .xib file:
ErrorView.swift
import UIKit
class ErrorView: UIView {
#IBOutlet weak var labelErrorTitle: UILabel!
#IBOutlet weak var view: UIView!
required init(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)!
setupView()
}
override init(frame: CGRect) {
super.init(frame: frame)
setupView()
}
func setupView() {
NSBundle.mainBundle().loadNibNamed("ErrorView", owner: self, options: nil)
self.addSubview(view)
}
ErrorView.xib (using inferred size, the label is centered using constraints)
I want to add this view to a custom UITableView, at the bottom. I want to make it slim, with a height of 45 and a width of the view screen width. I want to add it to the bottom.
Very easy!! I just set the size with a frame like this:
class LoadingTableView: UITableView {
var errorView: ErrorView = ErrorView () // Here is the Error View
var errorFrame: CGRect! // The frame (size) I will use
required init(coder aDecoder: NSCoder) {
}
override func awakeFromNib() {
super.awakeFromNib()
// I create the frame here to put the error at the top
errorFrame = CGRectMake(0,0,self.frame.width,45)
// Init the ErrorView
errorView = ErrorView(frame: errorFrame)
// I add the subview to root (I have this rootView created)
rootView?.addSubview(errorView)
}
// This is needed, it updates the size when layout changes
override func layoutSubviews() {
super.layoutSubviews()
// Create the size again
errorFrame = CGRectMake(0,0,self.frame.width,45)
// I update the frame
errorView.frame = frame
}
This should work but it doesn't. The size is just weird. It takes the size from the nib which is 320x568. Then it just moves the frame, but the size doesn't change.
And here comes the great part. If I set the errorView.frame size to .frame, which is the frame of the tableView then it works! With orientation changes and all!
But as long as I change the frame to a custom size, whatever is in awakeFromNib or layoutSubviews it doesn't and starts to act weird.
Why does it keeps the size of the nib? And why if I put .frame it works at it should but a custom size doesn't? It looks like I'm super close, it's frustrating as hell.
The objective is to say tableView.error("errorCode") and then that errorView appears. And it works on all devices and orientations.
Instead of adding a subview to UITableView, use it's tableFooterView and tableHeaderView properties:
Replace
rootView?.addSubview(errorView)
With:
tableFooterView = errorView