Autolayout Can I combine CenterY with Top and Bottom Constraints? - ios

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.

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?

iOS interface builder auto constraints problem

I currently trying to learn iOS development, however I’ve got stuck in interface builder. What I am trying to do is actually quite basic view. Unfortunately I feel pretty confused about „Less Than or Equal” relations. I thought that if a constraint is set as less than or equal, it will mean that it will have max size stetted in constant when there will be a plenty of space, otherwise it will be smaller. Turns out that no matter what, it always have the biggest size, which is not what I am trying to achieve.
On iPhone 11 interface looks like this:
On iPhone 8 interface looks like this:
For sure I don’t have all necessary knowledge about auto constraints right now, but maybe someone know where is a problem in this case? Also I would appreciate any good tutorials about interface builder or some good habits.
Thanks
Peter
So, this seems to be the sort of thing you're after. Here it is on a 6s:
And here it is on an iPhone 11:
And here it is, for good measure, on the iPhone 6s rotated:
As you can see, there are four "groups" - the two labels, the label-and-text-field, the second label-and-text-field, and the button. They are evenly distributed from top to bottom on both screens.
That's the right idea, isn't it?
So how is that done? Simple. One vertical stack view filling the screen, with distribution set to Equal Spacing. Inside that, a UIView containing each group (except the button which is on its own), and each UIView given a fixed height by its own height constraint. There's a little more to it but that's the heart of the matter. Once you have that, you can tweak further as desired, of course.
Absolutely no code; the whole thing was configured in a few moments in Xcode's nib editor ("interface builder").
It sounds like you are trying to create spacing between inputs based on the size of the device? I don't know if it will look like you expect, but I've had to do this before in different situations. You can use something like this NSLayoutConstraint subclass to accommodate to accomplish what you want. Essentially, depending on whether the constraint is vertical or horizontal, this class will calculate the screen size at runtime and modify the constraint size based on the percentage value you give it.
/// Layout constraint to calculate size based on multiplier.
class PercentLayoutConstraint: NSLayoutConstraint {
#IBInspectable var marginPercent: CGFloat = 0
var screenSize: (width: CGFloat, height: CGFloat) {
return (UIScreen.mainScreen().bounds.width, UIScreen.mainScreen().bounds.height)
}
override func awakeFromNib() {
super.awakeFromNib()
guard marginPercent > 0 else { return }
NSNotificationCenter.defaultCenter().addObserver(self,
selector: #selector(layoutDidChange),
name: UIDeviceOrientationDidChangeNotification,
object: nil)
}
/**
Re-calculate constant based on orientation and percentage.
*/
func layoutDidChange() {
guard marginPercent > 0 else { return }
switch firstAttribute {
case .Top, .TopMargin, .Bottom, .BottomMargin:
constant = screenSize.height * marginPercent
case .Leading, .LeadingMargin, .Trailing, .TrailingMargin:
constant = screenSize.width * marginPercent
default: break
}
}
deinit {
guard marginPercent > 0 else { return }
NSNotificationCenter.defaultCenter().removeObserver(self)
}
}
source: https://basememara.com/percentage-based-margin-using-autolayout-storyboard/

How to set alignments right for dynamic UIStackView with inner XIB

My original ViewController consists of only one scrollView like this:
Now I also have my own xib file (CheckBoxView) which mainly consists of one button, see this screenshot:
I dynamically create some UIStackViews and add them to the ScrollView Inside these UIStackViews I add multiple instances of my xib file.
What I want to achieve is, that the StackViews are just vertically stacked. And inside the StackViews the UIViews from my xib-file should also be vertically stacked.
At the moment it looks like this:
So the xib-Views are not in the whole view. Since I am using multi-os-engine I can't provide swift/obj-c code. But here is my Java-Code:
for (ItemConfiguration config : itemInstance.getConfigurations()) {
List<DLRadioButton> radioButtons = new ArrayList<DLRadioButton>();
UIStackView configView = UIStackView.alloc().initWithFrame(new CGRect(new CGPoint(0, barHeight), new CGSize(displayWidth, displayHeight - barHeight)));
configView.setAxis(UILayoutConstraintAxis.Vertical);
configView.setDistribution(UIStackViewDistribution.EqualSpacing);
configView.setAlignment(UIStackViewAlignment.Center);
configView.setSpacing(30);
for (ConfigurationOption option : config.getOptions()) {
UIView checkBox = instantiateFromNib("CheckBoxView");
for (UIView v : checkBox.subviews()) {
if (v instanceof DLRadioButton) {
((DLRadioButton) v).setTitleForState(option.getName(), UIControlState.Normal);
//((DLRadioButton) v).setIconSquare(true);
radioButtons.add((DLRadioButton) v);
}
}
configView.addArrangedSubview(checkBox);
}
// group radiobuttons
//groupRadioButtons(radioButtons);
configView.setTranslatesAutoresizingMaskIntoConstraints(false);
scrollView().addSubview(configView);
configView.centerXAnchor().constraintEqualToAnchor(scrollView().centerXAnchor()).setActive(true);
configView.centerYAnchor().constraintEqualToAnchor(scrollView().centerYAnchor()).setActive(true);
}
private UIView instantiateFromNib(String name) {
return (UIView) UINib.nibWithNibNameBundle(name, null).instantiateWithOwnerOptions(null, null).firstObject();
}
How do I need to set the Alignments etc. to Achieve what I want. It should look like this:
I don't know if there is a reason to not use UITableView, that i highly recommend for your case. In case it's not possible, below you can find some pieces of advice that should help.
If you use Auto Layout, you should set constraints for all views instantiated in your code. The constraints must be comprehensive for iOS to know each view's position and size.
Remove redundant constraints
configView.centerXAnchor().constraintEqualToAnchor(scrollView().centerXAnchor()).setActive(true);
configView.centerYAnchor().constraintEqualToAnchor(scrollView().centerYAnchor()).setActive(true);
These two constraint just doesn't make sense to me. You need the stackviews be stacked within you ScrollView, but not centered. If i understand you goal correctly, this should be removed
Set width/x-position constraints for UIStackViews
configView.setTranslatesAutoresizingMaskIntoConstraints(false);
scrollView().addSubview(configView);
Right after a stack view is added to the ScrollView, you need to set up constraints for it. I'll provide my code in swift, but it looks quite similar to what your Java code is doing, so hopefully you'll be able to transpile it without difficulties:
NSLayoutConstraint.activate([
configView.leadingAnchor.constraintEqualToAnchor(scrollView.leadingAnchor),
configView.trailingAnchor.constraintEqualToAnchor(scrollView.trailingAnchor)
]);
Set height constraints for UIStackViews
StackViews doesn't change their size whenever you add arranged view in it. So you need to calculate a desired stackview size yourself and specify it explicitly via constraints. It should be enough to accommodate items and spaces between them. I suppose that all items should be of the same size, let it be 32 points, then height should be:
let stackViewHeight = items.count * 32 + stackView.space * (items.count + 1)
And make new height constraint for the stack view:
configView.heightAnchor.constraint(equalToConstant: stackViewHeight).isActive = true
Set y-position for UIStackView
This is a little bit more challenging part, but the most important for the views to work properly in a scroll view.
1) Change loop to know the index of a UIStackView
A scroll view should always be aware of height of its content, so you need to understand which stack view is the top one, and which is the bottom. In order to do that, you need to change for each loop to be written as for(;;) loop:
for (int i = 0; i < itemInstance.getConfigurations().length; i++) {
ItemConfiguration config = itemInstance.getConfigurations()[i]
...
}
I'm not aware of which type your array is, so if it doesn't have subscript functionality, just replace it with corresponding method.
2) Set top anchor for stack views
For the first stack view in the array, top anchor should be equal to the scroll view top anchor, for others it should be bottom anchor of the previous stack view + spacing between them (say, 8 points in this example):
if i == 0 {
configView.topAnchor.constraintEqualToAnchor(scrollView.topAnchor, constant: 8).isActive = true
} else {
let previousConfigView = itemInstance.getConfigurations()[i - 1]
configView.topAnchor.constraintEqualToAnchor(previousConfigView.bottomAnchor, constant: 8).isActive = true
}
3) Set bottom anchor for the last stack view
As was said - for the Scroll View to be aware of content size, we need to specify corresponding constraints:
if i == itemInstance.getConfigurations() - 1 {
configView.bottomAnchor.constraintEqualToAnchor(scrollView.bottomAnchor, constant: 8).isActive = true
}
Note: please be advised, that all constraints should be set on views that are already added to the scroll view.

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

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.

How can I define animations for views using auto layout without relying on tons of constraint references and boilerplate code?

Before the switch to auto layout, applying animations to a view was easy - you'd just change the frame in UIView.layoutWithDuration:. When constraints come into the picture, things get more complicated. The most common method of animating a view that uses auto layout is to keep a reference to the constraint(s) you want to change, then set the constant value, but this is very difficult to design around, and can affect other views if their constraints depend on the position of the view you want to animate.
There must be a better way to do this. I'd love to be able to do something like view.translateFrom(direction: .left, distance: 30, duration: 0.3, delay: 0).
Ultimately you will want to use constraints to achieve simple animations. The overriding reason for this is -
"Choosing any other way is simply swimming against the stream"
If you don't want to 'litter' your classes with #IBOutlets for the constraints you wish to animate then you can in most cases obtain a reference to a pertinent constraint in code:
Handy extension
import UIKit
extension UIView {
func constraint(attribute: NSLayoutAttribute) -> NSLayoutConstraint? {
var constraint : NSLayoutConstraint? = .none
for potentialCenterXConstraint in self.constraints {
if potentialCenterXConstraint.firstAttribute == attribute {
constraint = potentialCenterXConstraint
break
}
}
return constraint
}
}
client code use
if let centerXConstraint = someView.constraint(attribute: .centerX) {
// Do something funky with centerXConstraint
}

Resources