Resolve ambiguity with content hugging priority - ios

Important: this question is not about adding/removing/modifying constraints!
I need help with understanding why the following layout is ambiguous:
UIView (rootView)
| UIView (topView)
| UIView (bottomView)
Constraints setup: V:|[topView][bottomView]|, and content hugging priority of bottomView is higher than the content hugging of the topView.
I would understand it if both views would have the same content hugging priority, but since the value is higher on bottomView I expect that it should resist more growing larger.
Below I paste the code you can use in a "Single View App" Xcode project template:
//
// ViewController.m
//
#import "ViewController.h"
#interface ViewController ()
#property (strong, nonatomic) UIView* rootView;
#property (strong, nonatomic) UIView* topView;
#property (strong, nonatomic) UIView* bottomView;
#end
#implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
[self setupViewHierarchy];
[self setupIssueIrrelevantConstraints];
[self setupIssueRelevantConstraints];
[self bumpVerticalContentHuggingPriorityOfView:self.bottomView];
}
- (void)setupViewHierarchy {
self.view.backgroundColor = [UIColor lightGrayColor];
self.rootView = [UIView new];
self.rootView.translatesAutoresizingMaskIntoConstraints = NO;
[self.view addSubview:self.rootView];
self.topView = [UIView new];
self.topView.translatesAutoresizingMaskIntoConstraints = NO;
self.topView.backgroundColor = [UIColor greenColor];
[self.rootView addSubview:self.topView];
self.bottomView = [UIView new];
self.bottomView.translatesAutoresizingMaskIntoConstraints = NO;
self.bottomView.backgroundColor = [UIColor blueColor];
[self.rootView addSubview:self.bottomView];
}
- (void)setupIssueIrrelevantConstraints {
[self.rootView.widthAnchor constraintEqualToConstant:200.0].active = YES;
[self.rootView.heightAnchor constraintEqualToConstant:200.0].active = YES;
[self.rootView.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor].active = YES;
[self.rootView.centerYAnchor constraintEqualToAnchor:self.view.centerYAnchor].active = YES;
[self.topView.leftAnchor constraintEqualToAnchor:self.rootView.leftAnchor].active = YES;
[self.topView.rightAnchor constraintEqualToAnchor:self.rootView.rightAnchor].active = YES;
[self.bottomView.leftAnchor constraintEqualToAnchor:self.rootView.leftAnchor].active = YES;
[self.bottomView.rightAnchor constraintEqualToAnchor:self.rootView.rightAnchor].active = YES;
}
- (void)setupIssueRelevantConstraints {
[self.topView.topAnchor constraintEqualToAnchor:self.rootView.topAnchor].active = YES;
[self.bottomView.bottomAnchor constraintEqualToAnchor:self.rootView.bottomAnchor].active = YES;
[self.topView.bottomAnchor constraintEqualToAnchor:self.bottomView.topAnchor].active = YES;
}
- (void)bumpVerticalContentHuggingPriorityOfView:(UIView*)view {
UILayoutPriority contentHuggingPriority = [view contentHuggingPriorityForAxis:UILayoutConstraintAxisVertical];
contentHuggingPriority++;
[view setContentHuggingPriority:contentHuggingPriority
forAxis:UILayoutConstraintAxisVertical];
}
#end
I know what ambiguous layout is, and no more constraints are required to resolve that layout. I expect that bottomView height will be equal to 0 because since its content hugging priority is larger it should resist more growing than topView.

The trouble is that you have a misconception of what content hugging is. It is about content.
Content hugging is relevant only when a view has an intrinsic content size, as does a label or a button.
https://developer.apple.com/documentation/uikit/uiview/1622600-intrinsiccontentsize
Content hugging is the priority with which, in the face of other constraints, a view should obey the dictates of its intrinsic content size. That’s all it is.
But your views do not have any intrinsic content size; there is no content to hug. Therefore your content hugging settings are meaningless and ignored.

Authors solution:
Thank you very much #matt. Your answer did help me. I was aware of everything you wrote, except that "having no intrinsic content size" does not mean that its equal to {0, 0} but {-1, -1}, and that is a difference.
To sum things up my layout does work as expected with a subtle change. I got it to work by replacing UIView with ZeroIntrinsicSizeView which looks like this:
#interface ZeroIntrinsicSizeView : UIView
#end
#implementation ZeroIntrinsicSizeView
- (CGSize)intrinsicContentSize {
return CGSizeZero;
}
#end

Related

UIStackView change item spacing as stack view changes sizes

Overview
I have a stack view that has multiple circle views in it. The circle views could be images (like profile pictures) or anything. These views should be able to overlap if the size of the stack view is too small for the subviews. And the views should spread out if the stack view is too big for the subviews. Also, subviews can be added or removed dynamically, even if the size of the stack view doesn't change.
For example, in the following image the top stack view has these circle views that are overlapping and everything is working fine there (the frame is exactly the size of the subviews views). But then, looking at the second stack view, after adding a few more views, the first view gets compressed. But what I want to happen is for all of the views to overlap a bit more and to not compress any of the views.
Question
What is the best way to implement this behavior? Should I override layoutSubviews, like I am proposing in the next section, or is there a better way to implement this? Again, I just want the views to either spread out, if the stack view is too large for them, or for them to overlap each other, if the stack view is too narrow. And the stack view can change size at any time and also arranged subviews can be added or removed at any time, and all of those things should cause the view spacing to be recalculated.
Proposed Solution
I was considering overriding the layoutSubviews method of the stack view and then somehow measuring all of the views, adding those widths together, and then the spacing that is currently present (I guess go through each of the arranged subviews and see what the spacing is for that subview). So it would be negative spacing for overlap or positive spacing if the items are actually spread out. Then, I would compare that width with the frame in layoutSubviews and if it was too wide, then I would decrease the spacing. Otherwise, if the views did not take up the full stack view, then I would increase their spacing.
Here is my code and the proposed algorithm in layoutSubviews.
Code
MyShelf.h
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
typedef NS_ENUM(NSInteger, MyShelfItemShape) {
MyShelfItemShapeNone = 0,
MyShelfItemShapeCircular
};
#interface MyShelf : UIStackView
#property (assign, nonatomic) CGSize itemSize;
#property (assign, nonatomic) MyShelfItemShape itemShape;
#property (strong, nonatomic) UIColor *itemBorderColor;
#property (assign, nonatomic) CGFloat itemBorderWidth;
#property (assign, nonatomic) CGFloat preferredMinimumSpacing;
#property (assign, nonatomic) CGFloat preferredMaximumSpacing;
#pragma mark - Managing Arranged Subviews
- (void)insertArrangedSubview:(UIView *)view atIndex:(NSUInteger)stackIndex animated:(BOOL)animated;
- (void)addArrangedSubview:(UIView *)view animated:(BOOL)animated;
- (void)removeArrangedSubview:(UIView *)view animated:(BOOL)animated;
#end
NS_ASSUME_NONNULL_END
MyShelf.m
#import "MyShelf.h"
#interface MyShelf ()
#property (strong, nonatomic) UIStackView *stackView;
#end
#implementation MyShelf
#pragma mark - Initializing the View
- (instancetype)init {
return [self initWithFrame:CGRectZero];
}
- (instancetype)initWithCoder:(NSCoder *)coder {
if (self = [super initWithCoder:coder]) {
[self initialize];
}
return self;
}
- (instancetype)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame:frame]) {
[self initialize];
}
return self;
}
- (void)initialize {
self.spacing = -10;
self.axis = UILayoutConstraintAxisHorizontal;
self.alignment = UIStackViewAlignmentCenter;
self.distribution = UIStackViewDistributionFillProportionally;
self.itemSize = CGSizeZero;
self.itemShape = MyShelfItemShapeNone;
self.itemBorderColor = [UIColor blackColor];
self.itemBorderWidth = 1.0;
}
- (void)layoutSubviews {
//if the new frame is different from the old frame
//if the size of the items in the stack view is too large, reduce the spacing down to a minimum of preferredMinimumSpacing
//else if the size of the items in the stack view is too small, increase the spacing up to a maximum of preferredMaximumSpacing
//otherwise keep the spacing as-is
[super layoutSubviews];
}
#pragma mark - Managing Arranged Subviews
- (void)insertArrangedSubview:(UIView *)view atIndex:(NSUInteger)stackIndex animated:(BOOL)animated {
CGFloat height = MAX(view.bounds.size.height, view.bounds.size.width);
if (!CGSizeEqualToSize(self.itemSize, CGSizeZero)) {
[NSLayoutConstraint activateConstraints:#[
[view.widthAnchor constraintEqualToConstant:self.itemSize.width],
[view.heightAnchor constraintEqualToConstant:self.itemSize.height]
]];
height = MAX(self.itemSize.height, self.itemSize.width);
}
switch (self.itemShape) {
case MyShelfItemShapeNone:
break;
case MyShelfItemShapeCircular:
view.layer.cornerRadius = height / 2.0;
break;
}
view.layer.borderColor = self.itemBorderColor.CGColor;
view.layer.borderWidth = self.itemBorderWidth;
if (animated) {
//prepare the view to be initially hidden so it can be animated in
view.alpha = 0.0;
view.hidden = YES;
[super insertArrangedSubview:view atIndex:stackIndex];
[UIView animateWithDuration:0.25
delay:0
options:UIViewAnimationOptionCurveLinear|UIViewAnimationOptionAllowAnimatedContent|UIViewAnimationOptionAllowUserInteraction
animations:^{ view.alpha = 1.0; view.hidden = NO; }
completion:nil];
} else {
[super insertArrangedSubview:view atIndex:stackIndex];
}
[self reorderArrangedSubviews];
}
- (void)insertArrangedSubview:(UIView *)view atIndex:(NSUInteger)stackIndex {
[self insertArrangedSubview:view atIndex:stackIndex animated:NO];
}
- (void)addArrangedSubview:(UIView *)view animated:(BOOL)animated {
[self insertArrangedSubview:view atIndex:self.arrangedSubviews.count animated:animated];
}
- (void)addArrangedSubview:(UIView *)view {
[self addArrangedSubview:view animated:NO];
}
- (void)removeArrangedSubview:(UIView *)view animated:(BOOL)animated {
if (animated) {
[UIView animateWithDuration:0.25
delay:0
options:UIViewAnimationOptionCurveLinear|UIViewAnimationOptionAllowAnimatedContent|UIViewAnimationOptionAllowUserInteraction
animations:^{ view.alpha = 0.0; view.hidden = YES; }
completion:^(BOOL finished) { [super removeArrangedSubview:view]; }];
} else {
[super removeArrangedSubview:view];
}
}
- (void)reorderArrangedSubviews {
for (__kindof UIView *arrangedSubview in self.arrangedSubviews) {
[self sendSubviewToBack:arrangedSubview];
}
}
#end
Requirements
If the view is a fixed width
For this case, the view that contains these circle subviews is a fixed width. It could be that it has a width constraint that specifies the number of points wide it is or it could be constrained by other views such that its width is predetermined.
In this case, the subviews should be arranged next to each other until they can no longer fit in the frame, and at which point they start to collapse (negative spacing between items).
If the view is a flexible width
For this case, the view that contains the circular subviews doesn't have a width specified. Instead, its width is determined by the width of the contents. So it should keep growing up until it can no longer grow, and at which point, then the subviews start to overlap.
The general idea is to use centerX constraints on your circle views - I'll call them ShelfItem, and constrain them to an "invisible positioning view."
The reason to do that, is because when the item's centerX is on the leading edge (or trailing edge) half of it will extend to the left or right of the positioning view.
Think about dividing a width into equal parts (all values are in %)...
If we have 3 items, we need 2 equal parts. To get the percentage spacing, we use 1.0 / (numItems - 1):
With 4 items, we need 3 equal parts:
With 5 items, we need 4 equal parts:
And with 6 items, we need 5 equal parts:
So, by making the "item" views subviews of the "positioning" view, we can loop through and set their centerX constraints like this:
UIView *thisItem;
CGFloat pct = 1.0 / (CGFloat)([subviews count] - 1);
for (int i = 0; i < subviews.count; i++) {
thisItem = subviews[i];
CGFloat thisPCT = pct * i;
// centerX as a percentage of positionView width
NSLayoutConstraint *c = [NSLayoutConstraint constraintWithItem:thisItem
attribute:NSLayoutAttributeCenterX
relatedBy:NSLayoutRelationEqual
toItem:positionView
attribute:NSLayoutAttributeTrailing
multiplier:thisPCT
constant:0.0];
c.active = YES;
}
It's not quite that simple though...
First, auto-layout doesn't like a multiplier: 0.0 ... so the left-most item needs to have centerX equal to positioning view Leading.
The second thing is that your layout requires the item views to be left-aligned when there is enough room, not evenly spaced.
To accomplish that, we'll make each item view's centerX lessThanOrEqualTo the previous item's centerX + itemWidth... and we'll give the "percentage" constraints a less-than-required priority.
So, each time we add (or remove) an item, we'll call a method to update the centerX constraints... it will look about like this:
// clear existing centerX constraints
for (NSLayoutConstraint *oldC in positionView.constraints) {
if (oldC.firstAttribute == NSLayoutAttributeCenterX) {
oldC.active = NO;
}
}
// item views are top-down left-to-right, so reverse the order of the subviews
NSArray *reversedArray = [positionView.subviews.reverseObjectEnumerator allObjects];
// constraints don't like multiplier:0.0
// so first item centerX will always be equal to positionView's Leading
UIView *thisItem = reversedArray[0];
[NSLayoutConstraint constraintWithItem:thisItem
attribute:NSLayoutAttributeCenterX
relatedBy:NSLayoutRelationEqual
toItem:positionView
attribute:NSLayoutAttributeLeading
multiplier:1.0
constant:0.0].active = YES;
// percentage for remaining item spacing
// examples:
// we have 3 items
// item 0 centerX is at leading
// item 1 centerX is at 50%
// item 2 centerX is at 100%
// we have 4 items
// item 0 centerX is at leading
// item 1 centerX is at 33.333%
// item 2 centerX is at 66.666%
// item 3 centerX is at 100%
CGFloat pct = 1.0 / (CGFloat)([reversedArray count] - 1);
UIView *prevItem;
for (int i = 1; i < reversedArray.count; i++) {
prevItem = thisItem;
thisItem = reversedArray[i];
CGFloat thisPCT = pct * i;
// keep items next to each other (left-aligned) when overlap is not needed
[thisItem.centerXAnchor constraintLessThanOrEqualToAnchor:prevItem.centerXAnchor constant:itemWidth].active = YES;
// centerX as a percentage of positionView width
NSLayoutConstraint *c = [NSLayoutConstraint constraintWithItem:thisItem
attribute:NSLayoutAttributeCenterX
relatedBy:NSLayoutRelationEqual
toItem:positionView
attribute:NSLayoutAttributeTrailing
multiplier:thisPCT
constant:0.0];
// needs less-than-required priority so "left-aligned" constraint can be enforced
c.priority = UILayoutPriorityRequired - 1;
c.active = YES;
}
The last task is to add a "framing" view that will match the bounds of the laid-out item views.
Here's a complete example...
ShelfItem.h - a simple round view with a label
#import <UIKit/UIKit.h>
#interface ShelfItem : UIView
#property (strong, nonatomic) UILabel *label;
#end
ShelfItem.m
#import "ShelfItem.h"
#implementation ShelfItem
- (instancetype)initWithFrame:(CGRect)frame
{
self = [super initWithFrame:frame];
if (self) {
[self commonInit];
}
return self;
}
- (instancetype)initWithCoder:(NSCoder *)coder
{
self = [super initWithCoder:coder];
if (self) {
[self commonInit];
}
return self;
}
- (void) commonInit {
self.backgroundColor = UIColor.whiteColor;
_label = [UILabel new];
_label.font = [UIFont systemFontOfSize:12 weight:UIFontWeightLight];
_label.translatesAutoresizingMaskIntoConstraints = NO;
[self addSubview:_label];
[_label.centerXAnchor constraintEqualToAnchor:self.centerXAnchor].active = YES;
[_label.centerYAnchor constraintEqualToAnchor:self.centerYAnchor].active = YES;
self.layer.borderColor = UIColor.blueColor.CGColor;
self.layer.borderWidth = 1.0;
}
- (void)layoutSubviews {
[super layoutSubviews];
self.layer.cornerRadius = self.bounds.size.height * 0.5;
}
#end
ShelfView.h - our view that does all the work
#import <UIKit/UIKit.h>
#interface ShelfView : UIView
- (void)addItem:(NSInteger)n;
- (void)removeItem;
#end
ShelfView.m
#import "ShelfView.h"
#import "ShelfItem.h"
#interface ShelfView () {
UIView *positionView;
UIView *framingView;
CGFloat itemWidth;
NSLayoutConstraint *framingViewTrailingConstraint;
}
#end
#implementation ShelfView
- (instancetype)initWithFrame:(CGRect)frame
{
self = [super initWithFrame:frame];
if (self) {
[self commonInit];
}
return self;
}
- (instancetype)initWithCoder:(NSCoder *)coder
{
self = [super initWithCoder:coder];
if (self) {
[self commonInit];
}
return self;
}
- (void) commonInit {
itemWidth = 60.0;
// framingView will match the bounds of the items
// it will not be their superView, but will look like it
framingView = [UIView new];
framingView.translatesAutoresizingMaskIntoConstraints = NO;
framingView.backgroundColor = UIColor.systemYellowColor;
[self addSubview:framingView];
// positionView is used for the item position constraints
// but is not seen
positionView = [UIView new];
positionView.translatesAutoresizingMaskIntoConstraints = NO;
positionView.backgroundColor = UIColor.clearColor;
[self addSubview:positionView];
// initialize framingView trailing constraint -- it will be updated in updatePositions
framingViewTrailingConstraint = [framingView.trailingAnchor constraintEqualToAnchor:positionView.leadingAnchor];
framingViewTrailingConstraint.priority = UILayoutPriorityRequired;
[NSLayoutConstraint activateConstraints:#[
// positioning view is at vertical center with no height
[positionView.centerYAnchor constraintEqualToAnchor:self.centerYAnchor],
[positionView.heightAnchor constraintEqualToConstant:0.0],
// leading and trailing are 1/2 the item width
[positionView.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:itemWidth * 0.5],
[positionView.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-itemWidth * 0.5],
// framing view leading is at positioning view leading minus 1/2 item width
[framingView.leadingAnchor constraintEqualToAnchor:positionView.leadingAnchor constant:-itemWidth * 0.5],
// constrained top and bottom
[framingView.topAnchor constraintEqualToAnchor:self.topAnchor],
[framingView.bottomAnchor constraintEqualToAnchor:self.bottomAnchor],
]];
}
- (void)updatePositions {
if ([positionView.subviews count] == 0) {
// no items, so all we have to do is update the framing view
framingViewTrailingConstraint.active = NO;
framingViewTrailingConstraint = [framingView.trailingAnchor constraintEqualToAnchor:self.leadingAnchor];
framingViewTrailingConstraint.active = YES;
return;
}
// clear existing centerX constraints
for (NSLayoutConstraint *oldC in positionView.constraints) {
if (oldC.firstAttribute == NSLayoutAttributeCenterX) {
oldC.active = NO;
}
}
// item views are top-down left-to-right, so reverse the order of the subviews
NSArray *reversedArray = [positionView.subviews.reverseObjectEnumerator allObjects];
// constraints don't like multiplier:0.0
// so first item centerX will always be equal to positionView's Leading
UIView *thisItem = reversedArray[0];
[NSLayoutConstraint constraintWithItem:thisItem
attribute:NSLayoutAttributeCenterX
relatedBy:NSLayoutRelationEqual
toItem:positionView
attribute:NSLayoutAttributeLeading
multiplier:1.0
constant:0.0].active = YES;
// percentage for remaining item spacing
// examples:
// we have 3 items
// item 0 centerX is at leading
// item 1 centerX is at 50%
// item 2 centerX is at 100%
// we have 4 items
// item 0 centerX is at leading
// item 1 centerX is at 33.333%
// item 2 centerX is at 66.666%
// item 3 centerX is at 100%
CGFloat pct = 1.0 / (CGFloat)([reversedArray count] - 1);
UIView *prevItem;
for (int i = 1; i < reversedArray.count; i++) {
prevItem = thisItem;
thisItem = reversedArray[i];
CGFloat thisPCT = pct * i;
// keep items next to each other (left-aligned) when overlap is not needed
[thisItem.centerXAnchor constraintLessThanOrEqualToAnchor:prevItem.centerXAnchor constant:itemWidth].active = YES;
// centerX as a percentage of positionView width
NSLayoutConstraint *c = [NSLayoutConstraint constraintWithItem:thisItem
attribute:NSLayoutAttributeCenterX
relatedBy:NSLayoutRelationEqual
toItem:positionView
attribute:NSLayoutAttributeTrailing
multiplier:thisPCT
constant:0.0];
// needs less-than-required priority so "left-aligned" constraint can be enforced
c.priority = UILayoutPriorityRequired - 1;
c.active = YES;
}
// update the trailing anchor of the framing view to the last shelf item
framingViewTrailingConstraint.active = NO;
framingViewTrailingConstraint = [framingView.trailingAnchor constraintEqualToAnchor:thisItem.trailingAnchor];
framingViewTrailingConstraint.active = YES;
}
- (void)addItem:(NSInteger)n {
// create a new shelf item
ShelfItem *v = [ShelfItem new];
v.translatesAutoresizingMaskIntoConstraints = NO;
v.label.text = [NSString stringWithFormat:#"%ld", (long)n];
// add it as a subview of positionView
// at index Zero (so it will be underneath existing items)
[positionView insertSubview:v atIndex:0];
// width and height
[v.widthAnchor constraintEqualToConstant:itemWidth].active = YES;
[v.heightAnchor constraintEqualToAnchor:v.widthAnchor].active = YES;
// vertically centered on positionView
[v.centerYAnchor constraintEqualToAnchor:positionView.centerYAnchor constant:0.0].active = YES;
// update all shelf items
[self updatePositions];
}
- (void)removeItem {
// remove the last-added item
[positionView.subviews[0] removeFromSuperview];
// update all shelf items
[self updatePositions];
}
#end
ViewController.h - controller with two ShelfViews and Add / Remove buttons:
#import <UIKit/UIKit.h>
#interface ViewController : UIViewController
#end
ViewController.m
#import "ViewController.h"
#import "ShelfView.h"
#interface ViewController ()
{
ShelfView *shelfViewA;
ShelfView *shelfViewB;
NSInteger counter;
}
#end
#implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
counter = 0;
// top shelf view has systemYellow background, so we see its
// full width all the time
shelfViewA = [ShelfView new];
shelfViewA.translatesAutoresizingMaskIntoConstraints = NO;
shelfViewA.backgroundColor = UIColor.systemYellowColor;
[self.view addSubview:shelfViewA];
// second shelf view has clear background, so we only see its
// framing view width when items are added
shelfViewB = [ShelfView new];
shelfViewB.translatesAutoresizingMaskIntoConstraints = NO;
shelfViewB.backgroundColor = UIColor.clearColor;
[self.view addSubview:shelfViewB];
UIButton *addBtn = [UIButton new];
addBtn.translatesAutoresizingMaskIntoConstraints = NO;
addBtn.backgroundColor = UIColor.systemGreenColor;
[addBtn setTitle:#"Add" forState:UIControlStateNormal];
[addBtn setTitleColor:UIColor.whiteColor forState:UIControlStateNormal];
[addBtn setTitleColor:UIColor.lightGrayColor forState:UIControlStateHighlighted];
[addBtn addTarget:self action:#selector(addTapped) forControlEvents:UIControlEventTouchUpInside];
[self.view addSubview:addBtn];
UIButton *removeBtn = [UIButton new];
removeBtn.translatesAutoresizingMaskIntoConstraints = NO;
removeBtn.backgroundColor = UIColor.systemGreenColor;
[removeBtn setTitle:#"Remove" forState:UIControlStateNormal];
[removeBtn setTitleColor:UIColor.whiteColor forState:UIControlStateNormal];
[removeBtn setTitleColor:UIColor.lightGrayColor forState:UIControlStateHighlighted];
[removeBtn addTarget:self action:#selector(removeTapped) forControlEvents:UIControlEventTouchUpInside];
[self.view addSubview:removeBtn];
UILabel *info = [UILabel new];
info.translatesAutoresizingMaskIntoConstraints = NO;
info.backgroundColor = [UIColor colorWithWhite:0.90 alpha:1.0];
info.textAlignment = NSTextAlignmentCenter;
info.numberOfLines = 0;
info.text = #"Shelf View Width\n60-pts on each side.";
[self.view addSubview:info];
// respect safeArea
UILayoutGuide *g = self.view.safeAreaLayoutGuide;
[NSLayoutConstraint activateConstraints:#[
[shelfViewA.topAnchor constraintEqualToAnchor:g.topAnchor constant:60.0],
[shelfViewA.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:60.0],
[shelfViewA.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:-60.0],
[shelfViewA.heightAnchor constraintEqualToConstant:60.0],
[info.topAnchor constraintEqualToAnchor:shelfViewA.bottomAnchor constant:8.0],
[info.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:60.0],
[info.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:-60.0],
[shelfViewB.topAnchor constraintEqualToAnchor:info.bottomAnchor constant:8.0],
[shelfViewB.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:60.0],
[shelfViewB.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:-60.0],
[shelfViewB.heightAnchor constraintEqualToConstant:60.0],
[addBtn.topAnchor constraintEqualToAnchor:shelfViewB.bottomAnchor constant:20.0],
[addBtn.centerXAnchor constraintEqualToAnchor:g.centerXAnchor],
[addBtn.widthAnchor constraintEqualToConstant:200.0],
[removeBtn.topAnchor constraintEqualToAnchor:addBtn.bottomAnchor constant:20.0],
[removeBtn.centerXAnchor constraintEqualToAnchor:g.centerXAnchor],
[removeBtn.widthAnchor constraintEqualToConstant:200.0],
]];
}
- (void)addTapped {
++counter;
[shelfViewA addItem:counter];
[shelfViewB addItem:counter];
}
- (void)removeTapped {
if (counter > 0) {
--counter;
[shelfViewA removeItem];
[shelfViewB removeItem];
}
}
#end
Running that gives us this - note the "top" shelf view shows its frame, the "bottom" shelf view only shows the "framing view":
and when the view changes size, such as on device rotation, we don't have to do anything ... auto-layout handles it for us:
You can simply make it by adjust distribution attribute.
self.distribution = UIStackViewDistributionEqualCentering;
What's more, UIStackView

How to calculate UIViewController height before adding view to view tree?

I need to measure the height of a simple autolayout based VC for a given width. For example a simple UIViewController with only one label which is positioned using leading, trailing, top and bottom constraints to the VCs root view. The VC should not have a fixed size but automatically adapt to the label content.
This is only an example, of course the VC could have other content which is influences the size.
How can I calculate the VCs size for a given width and label content without adding it to the view hierarchy?
Background:
I am using a third party FormSheet control which allows to easily show any ViewController as form sheet with different styles, transitions, etc. The only downside is, that one has to specify a fixed sheet size before the VC is presented.
While this works great for VCs with "static" content / fixed sizes even a label with different texts for different languages might break the design.
Thus I am look for something like this:
ContentViewController *contentVC = [ContentViewController new];
CGRect contentBounds = [SomeClass calculateAutoLayoutHeightForView:contentVC.view withFixedWidth:500];
[ThirPartyFormSheetController presentController:contentVC withSize:contentBounds];
How can this be done?
Given a width, you can use systemLayoutSizeFittingSize:UILayoutFittingCompressedSize to determine what the height will be after auto-layout does its work.
Assuming constraints in the view-to-show are set up correctly:
CGFloat w = 500.0;
[loadedView.widthAnchor constraintEqualToConstant:w].active = YES;
// caluclate the size using FittingCompressedSize
CGSize contentBounds = [loadedView systemLayoutSizeFittingSize:UILayoutFittingCompressedSize];
Here is a simple example (only need to assign the ViewController class to a view controller in Storyboard... no IBOutlets needed). Lots of comments in the code should make everything clear:
//
// ViewController.h
// Created by Don Mag on 4/8/19.
//
#import <UIKit/UIKit.h>
#interface ViewController : UIViewController
#end
//
// ViewController.m
// Created by Don Mag on 4/8/19.
//
#import "ViewController.h"
#import "FormViewController.h"
#interface ViewController ()
#end
#implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
// add a button we'll use to show the form VC
UIButton *b = [UIButton new];
b.translatesAutoresizingMaskIntoConstraints = NO;
[b setTitle:#"Show Form" forState:UIControlStateNormal];
[b setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
[b setBackgroundColor:[UIColor redColor]];
[self.view addSubview:b];
[NSLayoutConstraint activateConstraints:
#[
[b.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor constant:20.0],
[b.widthAnchor constraintEqualToAnchor:self.view.widthAnchor multiplier:0.75],
[b.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor]
]
];
[b addTarget:self action:#selector(loadAndShowForm:) forControlEvents:UIControlEventTouchUpInside];
}
- (void) loadAndShowForm:(id)sender {
// instantiate the form view controller
FormViewController *vc = [FormViewController new];
// get a reference to its view
UIView *v = vc.view;
// use auto-layout
v.translatesAutoresizingMaskIntoConstraints = NO;
// set the label text in the form view
vc.topLabel.text = #"This is a bunch of text for the TOP label in the Form VC";
vc.bottomLabel.text = #"This is a bunch of text for the BOTTOM label in the Form VC. It's enough text to cause a few lines of word-wrap, assuming we're running on an iPhone.";
// specify a width for the form view
// we'll use width of current view minus 60 (30-pts on each side)
CGFloat w = self.view.frame.size.width - 60.0;
[v.widthAnchor constraintEqualToConstant:w].active = YES;
// caluclate the size using FittingCompressedSize
CGSize contentBounds = [v systemLayoutSizeFittingSize:UILayoutFittingCompressedSize];
// because we set the width constraint, we now have the "compressed" height
//[ThirdPartyFormSheetController presentController:contentVC withSize:contentBounds];
// debugging from here down
NSLog(#"Auto-layout resulting size: %#", [NSValue valueWithCGSize:contentBounds]);
// set the height for the form view
[v.heightAnchor constraintEqualToConstant:contentBounds.height].active = YES;
// add it to the view, so we can confirm the height calculation
[self.view addSubview:v];
// center it on the view
[NSLayoutConstraint activateConstraints:
#[
[v.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor],
[v.centerYAnchor constraintEqualToAnchor:self.view.centerYAnchor]
]
];
}
#end
//
// FormViewController.h
// Created by Don Mag on 4/8/19.
//
#import <UIKit/UIKit.h>
#interface FormViewController : UIViewController
#property (strong, nonatomic) UILabel *topLabel;
#property (strong, nonatomic) UITextField *theTextField;
#property (strong, nonatomic) UILabel *bottomLabel;
#end
//
// FormViewController.m
// Created by Don Mag on 4/8/19.
//
#import "FormViewController.h"
#interface FormViewController ()
#end
#implementation FormViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor redColor];
// create a multi-line "top label"
_topLabel = [UILabel new];
_topLabel.backgroundColor = [UIColor cyanColor];
_topLabel.text = #"Hello Top Label";
_topLabel.numberOfLines = 0;
// create a text field
_theTextField = [UITextField new];
_theTextField.backgroundColor = [UIColor greenColor]; // just to make it easy to see
_theTextField.borderStyle = UITextBorderStyleRoundedRect;
_theTextField.text = #"The Text Field";
// create a multi-line "bottom label"
_bottomLabel = [UILabel new];
_bottomLabel.backgroundColor = [UIColor cyanColor];
_bottomLabel.text = #"Hello Bottom Label";
_bottomLabel.numberOfLines = 0;
// we're using auto-layout and constraints
_topLabel.translatesAutoresizingMaskIntoConstraints = NO;
_theTextField.translatesAutoresizingMaskIntoConstraints = NO;
_bottomLabel.translatesAutoresizingMaskIntoConstraints = NO;
// add to view
[self.view addSubview:_topLabel];
[self.view addSubview:_theTextField];
[self.view addSubview:_bottomLabel];
// these elements and constraints will define the height of the content
[NSLayoutConstraint activateConstraints:
#[
// constrain top label leading, trailing and top to top of view, all at 20-pts
[_topLabel.topAnchor constraintEqualToAnchor:self.view.topAnchor constant:20.0],
[_topLabel.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor constant:20.0],
[_topLabel.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor constant:-20.0],
// constrain text field leading and trailing, and top to bottom of top label, all at 20-pts
[_theTextField.topAnchor constraintEqualToAnchor:_topLabel.bottomAnchor constant:20.0],
[_theTextField.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor constant:20.0],
[_theTextField.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor constant:-20.0],
// constrain bottom label leading, trailing and top to bottom of text field, all at 20-pts
[_bottomLabel.topAnchor constraintEqualToAnchor:_theTextField.bottomAnchor constant:20.0],
[_bottomLabel.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor constant:-20.0],
[_bottomLabel.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor constant:20.0],
// AND constrain bottom label to bottom of view at 20-pts
[_bottomLabel.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor constant:-20.0]
]
];
}
#end
The result (adding the loaded VC's view as a subview - see the comments in the code):
and with more text to show the automatic height calculation:
If you change the amount of text for the labels (set in ViewController.m), you will see that the height is calculated correctly.
Swift:
If all you need is to calculate height on the basis of label's text, you can use this solution
https://stackoverflow.com/a/25187891/7848711

iOS 7 zooming is not working in ScrollView with AutoLayout but working in iOS8/9

I have made a demo for zoom image using UIScrollView. My ViewController only contains one image.
The problem is the image cannot zoom in iO7 (I have tested on iPhone4S-iOS7) but work perfectly in iOS8/iOS9.
Any ideas on how to fix it?
Here is my code
#import "ViewController.h"
#interface ViewController ()<UIScrollViewDelegate>
#property (weak, nonatomic) IBOutlet UIScrollView *scrollview;
#property (weak, nonatomic) IBOutlet UIView *contentview;
#end
#implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
float minimumScale = [_contentview frame].size.width /[_scrollview frame].size.width;
_scrollview.maximumZoomScale = 5; //Change as per you need
_scrollview.minimumZoomScale = minimumScale; //Change as you need
_scrollview.zoomScale = minimumScale;
_scrollview.delegate =self;
_scrollview.clipsToBounds = YES;
}
- (UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView{
return self.contentview;
}
#end
Here is the layout structure
Screen.png constraint
ContentView constraint
ScrollView constraint
Here is my demo project
https://drive.google.com/file/d/0B679aXO0SBmMeUVHTUdOcmxJSXM/view
The height and width constraints are causing this in iOS 7. A workaround would be, removing those constraints for iOS 7 and calculate minimumScale manually. in IOS 8 and above, do not change anything.
- (void)viewDidLoad {
[super viewDidLoad];
float minimumScale = 1;
if (floor(NSFoundationVersionNumber) < NSFoundationVersionNumber_iOS_8_0) {
[self.view removeConstraints:self.heightWidthConstraints];
minimumScale = self.scrollview.frame.size.width / self.imageView.image.size.width;
}
_scrollview.maximumZoomScale = 5; //Change as per you need
_scrollview.minimumZoomScale = minimumScale; //Change as you need
//_scrollview.zoomScale = minimumScale;
_scrollview.delegate = self;
//_scrollview.clipsToBounds = YES;
[self.scrollview setZoomScale:minimumScale animated:YES];
}
Remove width and height contraint of contentView.
Add centralHorizontal constraint of contentView with scrollView
Let me know if it works.

How to get a button to grow vertically using Auto Layout in iOS7?

Using IB and Auto Layout I would like to have a button that grows in size vertically when the title is long.
I've added top, leading and trailing constraint to the button and a Line break of Word wrap. Then I assign a long title in viewDidLoad. But the resulting button doesn't fit the content. What am I missing here?
Trying to overcome the lack of automated update of the height:
CGFloat width = 280;
NSAttributedString *attributedText =
[[NSAttributedString alloc]
initWithString:self.button.titleLabel.text
attributes:#
{NSFontAttributeName: self.button.titleLabel.font}];
CGRect rect = [attributedText boundingRectWithSize:(CGSize){width, CGFLOAT_MAX}
options:NSStringDrawingUsesLineFragmentOrigin
context:nil];
CGSize size = rect.size;
CGRect buttonRect = self.button.frame;
buttonRect.size.height = size.height;
[self.button setFrame: buttonRect];
But the frame of the button is not being updated.....
With the excellent solution provided by rdelmar below I got the following result:
If you want the title over the image (not beside) you need to use backgroundImage:forState:. As far as I can tell from experimenting with this, you can't get the image to expand with the title in an automatic way. If you have no image, then those constraints work to make the title expand vertically.
After Edit: I never found any good automatic way to do this, but you can do it by calculating the height of the string, and adjusting the height of the button accordingly. I found the best way to do this was to give the button both a width and height constraint in IB -- this will cause your background image to stretch or shrink to fit that frame. I made a UIButton subclass (changed the class of the the button in IB), and put this code in it:
#interface RDButton ()
#property (strong,nonatomic) NSLayoutConstraint *hCon;
#property (strong,nonatomic) NSLayoutConstraint *wCon;
#property (nonatomic) CGFloat startingHeight;
#end
#implementation RDButton
-(id)initWithCoder:(NSCoder *)aDecoder {
if (self = [super initWithCoder:aDecoder]) {
for (NSLayoutConstraint *con in self.constraints) {
if (con.firstAttribute == NSLayoutAttributeHeight) {
self.hCon = con;
self.startingHeight = con.constant;
}else if (con.firstAttribute == NSLayoutAttributeWidth){
self.wCon = con;
}
}
}
self.titleLabel.numberOfLines = 0;
self.titleLabel.textAlignment = NSTextAlignmentCenter;
return self;
}
-(void)setTitle:(NSString *)title forState:(UIControlState)state {
[super setTitle:title forState:state];
NSStringDrawingContext *ctx = [NSStringDrawingContext new];
CGSize s = [title boundingRectWithSize:CGSizeMake(self.wCon.constant, CGFLOAT_MAX) options:NSStringDrawingUsesLineFragmentOrigin attributes:#{NSFontAttributeName:self.titleLabel.font} context:ctx].size;
self.hCon.constant = fmax (s.height, self.startingHeight - 5) + 5; // the +5 gives about the same padding as the original button.
}
In the view controller where I'm setting the new title, I have this:
- (void)viewDidLoad {
[super viewDidLoad];
[self.button setTitle:#"A long long long Title" forState:UIControlStateNormal];
UIImage *img = [UIImage imageNamed:#"pic.jpg"];
[self.button setBackgroundImage:img forState:UIControlStateNormal];
}

Why are animations on bounds of an UILabel only working when increasing the size?

I noticed that when i change the bounds of an UILabel in an animation block it only works if i increase the size, when i decrease the size the UILabel just changes his size but doesn't animate.
Replacing the UILabel with a plain UIView works as intended.
Note: Changing the contentMode property of the UILabel to UIViewContentModeScaleToFill fixes this issue, but i still don't understand why it works when increasing the size without changing the contentMode property.
#import "FooView.h"
#interface FooView ()
#property (strong, nonatomic) UILabel *label;
#property (assign, nonatomic) BOOL resized;
#end
#implementation FooView
- (id)initWithFrame:(CGRect)frame
{
self = [super initWithFrame:frame];
if (self) {
self.backgroundColor = [UIColor lightGrayColor];
self.label = [[UILabel alloc] initWithFrame:(CGRect){0, 0, frame.size}];
self.label.backgroundColor = [UIColor greenColor];
[self addSubview:self.label];
_resized = false;
UITapGestureRecognizer *tapRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:#selector(changeSize)];
tapRecognizer.numberOfTapsRequired = 1;
[self addGestureRecognizer:tapRecognizer];
}
return self;
}
- (void)changeSize {
[UIView animateWithDuration:0.8
delay:0.0
options:UIViewAnimationOptionCurveEaseIn
animations:^{
CGRect bounds = self.label.bounds;
if (self.resized) {
bounds.size.height *= 2;
bounds.size.width *= 2;
} else {
bounds.size.width /= 2;
bounds.size.height /= 2;
}
self.label.bounds = bounds;
}
completion:^(BOOL finished) {
self.resized = !self.resized;
}];
}
#end
It's because UILabel sets its layer's contentsGravity to the direction text is being rendered, which happens to default to UIViewContentModeLeft (or #"left"). Thus, when the layer is told to animate, it first takes a glance at its contents gravity and bases subsequent animations on that. Because it sees #"left" where there should be #"resize", it assumes that the scaling animation should begin from the left, but it also has to respect the constraints you've given it (the bounds changes), so your label appears to jump into its final size then settle where it should in the center of the screen.
If you want to leave contentMode alone, use CATransform3D's and scale the label's layer that way instead of a bounds change.

Resources