Why does sizeThatFits() return a size that is too small? - ios

I'm learning swift with cs193p and I have a problem with UITextView.sizeThatFits(...). It should return a recommended size for popover view to display an [int] array as a text. As you can see in Paul Hegarty's example (https://youtu.be/gjl2gc70YHM?t=1h43m17s), he gets perfectly-fit popover window without scrollbar. I'm using almost the same code that was in this lecture, but instead i've got this:
the text string equals [100], but the sizeThatFits() method is returning a size that is too small to display it nicely, even though there is plenty of free space.
It is getting a bit better after I've added some text, but still not precise and with the scrollbar:
Here is the part of the code where the size is being set:
override var preferredContentSize: CGSize {
get {
if textView != nil && presentingViewController != nil {
// I've added these outputs so I can see the exact numbers to try to understand how this works
print("presentingViewController!.view.bounds.size = \(presentingViewController!.view.bounds.size)")
print("sizeThatFits = \(textView.sizeThatFits(presentingViewController!.view.bounds.size))")
return textView.sizeThatFits(presentingViewController!.view.bounds.size)
} else { return super.preferredContentSize }
}
set { super.preferredContentSize = newValue }
}
What should I do so this will work in the same way as in the lecture?

It looks like there are 16 pt margins between the label and its parent view. You need to take that into account when returning the preferred size of the popover.
You should try both of the following:
Add 32 to the width that's returned from preferredContentSize
In Interface Builder, clear the layout constraints on your UILabel, then re-add top, bottom, leading, and trailing constraints and make sure that "Constrain to Margins" option is not enabled.
Finally, instead of overriding preferredContentSize, you can simply set the preferredContentSize when your view is ready to display, and you can ask Auto Layout to choose the best size:
override func viewDidLayoutSubviews() {
self.preferredContentSize = self.view.systemLayoutSizeFitting(UILayoutFittingCompressedSize)
}
If your layout is configured correctly, systemLayoutSizeFitting(UILayoutFittingCompressedSize) will return the smallest possible size for your view, taking into account all of the margins and sub-views.

Related

A mystery about iOS autolayout with table views and self-sizing table view cells

To help in following this question, I've put up a GitHub repository:
https://github.com/mattneub/SelfSizingCells/tree/master
The goal is to get self-sizing cells in a table view, based on a custom view that draws its own text rather than a UILabel. I can do it, but it involves a weird layout kludge and I don't understand why it is needed. Something seems to be wrong with the timing, but then I don't understand why the same problem doesn't occur for a UILabel.
To demonstrate, I've divided the example into three scenes.
Scene 1: UILabel
In the first scene, each cell contains a UILabel pinned to all four sides of the content view. We ask for self-sizing cells and we get them. Looks great.
Scene 2: StringDrawer
In the second scene, the UILabel has been replaced by a custom view called StringDrawer that draws its own text. It is pinned to all four sides of the content view, just like the label was. We ask for self-sizing cells, but how will we get them?
To solve the problem, I've given StringDrawer an intrinsicContentSize based on the string it is displaying. Basically, we measure the string and return the resulting size. In particular, the height will be the minimal height that this view needs to have in order to display the string in full at this view's current width, and the cell is to be sized to that.
class StringDrawer: UIView {
#NSCopying var attributedText = NSAttributedString() {
didSet {
self.setNeedsDisplay()
self.invalidateIntrinsicContentSize()
}
}
override func draw(_ rect: CGRect) {
self.attributedText.draw(with: rect, options: [.truncatesLastVisibleLine, .usesLineFragmentOrigin], context: nil)
}
override var intrinsicContentSize: CGSize {
let measuredSize = self.attributedText.boundingRect(
with: CGSize(width:self.bounds.width, height:10000),
options: [.truncatesLastVisibleLine, .usesLineFragmentOrigin],
context: nil).size
return CGSize(width: UIView.noIntrinsicMetric, height: measuredSize.height.rounded(.up) + 5)
}
}
But something's wrong. In this scene, some of the initial cells have some extra white space at the bottom. Moreover, if you scroll those cells out of view and then back into view, they look correct. And all the other cells look fine. That proves that what I'm doing is correct, so why isn't it working for the initial cells?
Well, I've done some heavy logging, and I've discovered that at the time intrinsicContentSize is called initially for the visible cells, the StringDrawer does not yet correctly know its own final width, the width that it will have after autolayout. We are being called too soon. The width we are using is too narrow, so the height we are returning is too tall.
Scene 3: StringDrawer with workaround
In the third scene, I've added a workaround for the problem we discovered in the second scene. It works great! But it's horribly kludgy. Basically, in the view controller, I wait until the view hierarchy has been assembled, and then I force the table view to do another round of layout by calling beginUpdates and endUpdates.
var didInitialLayout = false
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
if !didInitialLayout {
didInitialLayout = true
UIView.performWithoutAnimation {
self.tableView.beginUpdates()
self.tableView.endUpdates()
}
}
}
The Mystery
Okay, so here are my questions:
(1) Is there a better, less kludgy workaround?
(2) Why do we need this workaround at all? In particular, why do we have this problem with my StringDrawer but not with a UILabel? Clearly, a UIlabel does know its own width early enough for it to give its own content size correctly on the first pass when it is interrogated by the layout system. Why is my StringDrawer different from that? Why does it need this extra layout pass?

Autolayout Can I combine CenterY with Top and Bottom Constraints?

I'm trying to make this layout somehow dynamic. The options here are dynamic (unknown count), so we can easily put these options in a tableView, collectionView, or just simply scrollView.
The problem is that I wanna make this white container small if possible and centering vertically. And when I combine centerY constraint with Top+Bottom insets, only the top and bottom constraints seem to be activated.
And when the options are quite long, options can be scrollable, BUT maintaining the fact that there are top and bottom insets.
I have already some ideas in mind, such as observing if the height of the container view exceeds the device height.
I use snapKit, but the constraints should be understandable. Here's my current layout:
func setupUI() {
self.view.backgroundColor = .clear
self.view.addSubviews(
self.view_BGFilter,
self.view_Container
)
self.view_BGFilter.snp.makeConstraints {
$0.edges.equalToSuperview()
}
self.view_Container.snp.makeConstraints {
$0.centerY.equalToSuperview().priority(.high)
//$0.top.bottom.greaterThanOrEqualToSuperview().inset(80.0).priority(.medium)
$0.leading.trailing.equalToSuperview().inset(16.0)
}
// Setup container
self.view_Container.addSubviews(
self.label_Title,
self.stackView,
self.button_Submit
)
self.label_Title.snp.makeConstraints {
$0.top.equalToSuperview().inset(40.0)
$0.leading.trailing.equalToSuperview().inset(16.0)
}
self.stackView.snp.makeConstraints {
$0.top.equalTo(self.label_Title.snp.bottom).offset(29.0)
$0.leading.trailing.equalToSuperview().inset(24.0)
}
self.button_Submit.snp.makeConstraints {
$0.height.equalTo(52.0)
$0.top.equalTo(self.stackView.snp.bottom).offset(30.0)
$0.bottom.leading.trailing.equalToSuperview().inset(24.0)
}
self.generateButtons()
}
My answer your question - CenterY with Top and Bottom constraints? - comes with just a little commentary...
I know that SnapKit is popular, and I'm sure at times it can be very helpful, especially if you use it all the time.
However... when using it you can never be absolutely sure what it's doing. And, in my experience, folks who use SnapKit often don't really understand what constraints are or how they work (not implying that's the case with you ... just an observation from looking at various questions).
In this specific case, either SnapKit has a bit of a bug, or this particular line is not quite right for the desired result:
$0.top.bottom.greaterThanOrEqualToSuperview().inset(80.0)
You can confirm it with a simple test:
class TestViewController: UIViewController {
let testView: UIView = {
let v = UIView()
v.backgroundColor = .red
return v
}()
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(testView)
testView.snp.makeConstraints {
$0.centerY.equalToSuperview()
// height exactly 200 points
$0.height.equalTo(200.0)
// top and bottom at least 80 points from superview
$0.top.bottom.greaterThanOrEqualToSuperview().inset(80.0)
$0.leading.trailing.equalToSuperview().inset(16.0)
}
}
}
This is the result... along with [LayoutConstraints] Unable to simultaneously satisfy constraints. message in debug console:
If we replace that line as follows:
// replace this line
//$0.top.bottom.greaterThanOrEqualToSuperview().inset(80.0)
// with these two lines
$0.top.greaterThanOrEqualToSuperview().offset(80.0)
$0.bottom.lessThanOrEqualToSuperview().offset(-80.0)
which certainly seems to be doing the same thing, we get what we expected:
So, something in SnapKit is fishy.
That will fix your issue. Change your view_Container constraint setup like this:
self.view_Container.snp.makeConstraints {
$0.centerY.equalToSuperview().priority(.required)
// replace this line
//$0.top.bottom.greaterThanOrEqualToSuperview().inset(80.0)
// with these two lines
$0.top.greaterThanOrEqualToSuperview().offset(80.0)
$0.bottom.lessThanOrEqualToSuperview().offset(-80.0)
$0.leading.trailing.equalToSuperview().inset(16.0)
}
Before the change:
After the change:
As to adding scrolling when you have many Option Buttons, I can give you an example for either scrolling all the content or scrolling only the Option Buttons.

TableView resizing parent view iOS

This is a problem that has been bugging me for quite some time.
Assume a view that holds a tableView with X items. The goal is to make that view resize so that it is as high as the contents of the tableView.
An approach
Calculate the contents of the tableView in total ( e.g if there are 5 rows and each is 50 units high, its just a multiplication matter ). Then set the tableView constrained at a 0 0 0 0 into the view and set the view height to 250.
This works well for fixed height cell sizes. However!
a) How would the problem be approached for dynamic height cells though with complex constraints in a scenario where resizing happens automatically and the tableHeightForRow is set to UITableViewAutomaticDimension?
b) An idea could be using tableView.contentSize. However when would we retrieve that value safely in order to set the parent view frame accordingly? Is that even possible?
Thanks everyone
If you have a UITableView subclass, you can set up a property observer on the contentSize like this:
override var contentSize: CGSize {
didSet {
// make delegate call or use some other mechanism to communicate size change to parent
}
}
The most straightforward approach to this in my opinion is to use Autolayout. If you take this approach, you can use the contentSize to automatically invalidate the intrinsicContentSize which is what autolayout uses to dynamically size elements (as long as they don't have higher priority placement constraints restricting or explicitly setting their size).
Something like this:
override var contentSize: CGSize {
didSet {
self.invalidateIntrinsicContentSize()
}
}
override var intrinsicContentSize: CGSize {
return contentSize
}
Then, just add your table view to your parent view hierarchy with valid placement constraints and a content hugging/compression resistance of required.

UILabel text property when set to nil or "" makes UILabel disappear from view (Swift / Autolayout/ iOS9.1)

I am going through the Stanford Winter 2015 Swift/iOS course and while doing the assignments I run into a behavior I'd like to change.
I use Autolayout as described in the videos (making the display pin to leading and trailing view edges) and the Calculator app "Display" UILabel is fine with an initial value of 0 and whenever the value used to set it (a String) is non-nil and non "".
If it is either nil or "", the entire UILabel disappears. What I am trying to do is to "clear" the display whenever there is no value to display or an incorrect calculation resulted in nil.
Any tips on who to deal with this in general? "Clearing" a UILabel without changing it's on-screen dimensions?
Edit (thanks Rob)
The UILabel has the following constraints
1. Option-click drag-left to containing UIView, selected "leading" something (on commute to work can't check yet for exact wording.
2. Same method as (1) except that the drag is to the right edge and selecting "trailing"
3. Option click-drag up to top of view, select "vertical" menu option.
4. Same as (3) except that drag is to a UIButton underneath the UILabel on the GUI.
With those settings, the label when it contains a number is always visible and (if understand, will color it to verify) stretches across the screen even if the text doesn't.
The layout looks correct in profile and landscape as long as content of UILabel is not empty. If empty, it seems to "shrink to fit" so much that the buttons below get moved up towards the top.
I'm a C++ dev since mid 90s but I have little UI experience and no more than a couple weeks experience in iOS/Swift development.
Thanks!
You can always give the UILabel a min width and min height or constraints that holds the left and right side of the label. That should keep the label from changing it's dimensions to zero.
Use a custom UILabel class assigned in Interface Builder >> Identity inspector >> Custom Class >> Class to override UILabel intrinsic content size.
No need to create any superfluous auto-layout constraints.
Swift:
class UILabelNonCompressible: UILabel
{
private static let NonCompressibleInvisibleContent = " "
override var intrinsicContentSize: CGSize
{
if /* zero-width */ text == nil ? true : text!.isEmpty
{
// prefer mirror-and-calculate over modify-calculate-restore due to KVO
let doppelganger = createCopy()
// calculate for any non-zero -height content
doppelganger.text = UILabelNonCompressible.NonCompressibleInvisibleContent
// override
return doppelganger.intrinsicContentSize
}
else
{
return super.intrinsicContentSize
}
}
}
You will also need "How do copy for UILabel?":
extension UILabel
{
func createCopy() -> UILabel
{
let archivedData = NSKeyedArchiver.archivedData(withRootObject: self)
return NSKeyedUnarchiver.unarchiveObject(with: archivedData) as! UILabel
}
}

Where to update Autolayout constraints when size changes?

I have several UIViews laid out along the bottom of a containing UIView. I want these views to always be equal width, and always stretch to collectively fill the width of the containing view (like the emoji keyboard buttons at the bottom). The way I'm approaching this is to set equal widths to one of the views, then just update the width constraint of that view to be superviewWidth / numberOfViews which will cause all of the other views to update to that same value.
I am wondering where the code to change the constraint constant needs to go. It needs to be set before the keyboard appears on screen for the first time and update when rotating the device.
My first attempt at a solution was to place it in updateViewConstraints and calculate the width via containerView.frame.size.width. But this method is called twice upon load, the first time it calculates the values correctly, but the second time for some reason the containerView's width is 0.0. Another issue is that when rotating, the containerView's width is not the value that it will be after rotation, it's the current value before rotation. But I don't want to wait until after the rotation completes to update the constraint, because the buttons will be the original size then change which will be jarring to the user.
My question is: where is the most appropriate place to put this code? Is there a better way to calculate what the width will be? I can guarantee it will always be the exact same width as the screen width. And I am using Size Classes in Xcode 6, so willRotateToInterfaceOrientation and similar methods are deprecated.
On all classes that implement the UITraitEnvironment protocol the method traitCollectionDidChange will be called when the trait collection changes, like on rotation. This is the appropiate place to manually update the constraints when using the new Size Classes. You can also animate the transition with the method willTransitionToTraitCollection
Basic example:
class ViewController: UIViewController {
var constraints = [NSLayoutConstraint]()
func updateConstraintsWithTraitCollection(traitCollection: UITraitCollection) {
// Remove old constraints
view.removeConstraints(constraints)
// Create new constraints
}
override func willTransitionToTraitCollection(newCollection: UITraitCollection!,
withTransitionCoordinator coordinator: UIViewControllerTransitionCoordinator!) {
super.willTransitionToTraitCollection(newCollection, withTransitionCoordinator: coordinator)
coordinator.animateAlongsideTransition({ (context: UIViewControllerTransitionCoordinatorContext!) in
self.updateConstraintsWithTraitCollection(newCollection)
self.view.setNeedsLayout()
}, completion: nil)
}
override func traitCollectionDidChange(previousTraitCollection: UITraitCollection!) {
updateConstraintsWithTraitCollection(traitCollection)
}
}
Besides that I want to recommend Cartography, which is a nice library that helps to make auto layout more readable and enjoyable. https://github.com/robb/Cartography
There is no reason to update the width manually:
Place all the views with equal width in your view with no spacing in between each other
Add an equal width constraint to all of them
Add constraints with 0 width for spacing between sides and each other
Lower the priority of one or more of the equal width constraints just in case the width cannot be divided equally.
Then auto layout will handle everything for you.

Resources