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
Related
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
Using Interface Builder, I have built a really long ScrollView filled with Custom UIViews, regular UIViews, StackViews, UILabels, UIButtons, etc.
For some of the Custom UIViews, if they do not have any data, then I want to replace them with a UILabel that says "No Data Available" and I want to be able to set the margins and center the text of that UILabel.
What's the best/easiest way to do this programmatically in my ViewController given that all the views are arranged using interface builder?
Thanks for your help in advance!
You can do this by adding a UILabel, with some simple constraints, over the views you want to cover instead of inside them if you want to ensure you aren't messing with controls you don't, well, control.
I set up a simple test app to show how this method can work
This has a stack view with some images in it, a text view, and a button to trigger the sample.
You should be able to apply this method to your views as you determine in your code that you have no data to show, and want to show the placeholder, but in my example I've set up an IBOutletCollection that has both the stack view and the text view in it, and am running this on both views when the button is pressed.
All you need to do is provide the placeholder text and the view you want to replace to this method
/// This method will hide a view and put a placeholder label in that view's superview, centered in the target view's frame.
- (void)showPlaceholderText:(NSString *)placeholder forView:(UIView *)view
{
// Build the placeholder with the same frame as the target view
UILabel *placeholderLabel = [[UILabel alloc] initWithFrame:view.frame];
placeholderLabel.textAlignment = NSTextAlignmentCenter;
placeholderLabel.text = placeholder;
placeholderLabel.translatesAutoresizingMaskIntoConstraints = NO;
// Hide the target view
view.hidden = YES;
// Put our placeholder into the superview, overtop the target view
[view.superview addSubview:placeholderLabel];
// Set up some constraints to ensure the placeholder label stays positioned correctly
[view.superview addConstraint:[NSLayoutConstraint constraintWithItem:view attribute:NSLayoutAttributeTop relatedBy:NSLayoutRelationEqual toItem:placeholderLabel attribute:NSLayoutAttributeTop multiplier:1.0f constant:0.0f]];
[view.superview addConstraint:[NSLayoutConstraint constraintWithItem:view attribute:NSLayoutAttributeRight relatedBy:NSLayoutRelationEqual toItem:placeholderLabel attribute:NSLayoutAttributeRight multiplier:1.0f constant:0.0f]];
[view.superview addConstraint:[NSLayoutConstraint constraintWithItem:view attribute:NSLayoutAttributeBottom relatedBy:NSLayoutRelationEqual toItem:placeholderLabel attribute:NSLayoutAttributeBottom multiplier:1.0f constant:0.0f]];
[view.superview addConstraint:[NSLayoutConstraint constraintWithItem:view attribute:NSLayoutAttributeLeft relatedBy:NSLayoutRelationEqual toItem:placeholderLabel attribute:NSLayoutAttributeLeft multiplier:1.0f constant:0.0f]];
}
The constraints added to the placeholder should keep it positioned correctly, through rotation or any other layout activity in the view.
One idea is, instead of replacing the custom views with labels, give them an "noData" mode where they present the right thing if there's no data...
// CustomView.h
#interface CustomView : UIView
#property(assign,nonatomic) BOOL noData;
#end
// CustomView.m
#interface CustomView ()
#property(weak,nonatomic) UILabel *noDataLabel;
#end
- (void)setNoData:(BOOL)noData {
_noData = noData;
self.noDataLabel.alpha = (noData)? 1.0 : 0.0;
}
- (UILabel *)noDataLabel {
if (!_noDataLabel) {
UILabel *noDataLabel = [[UILabel alloc] initWithFrame:self.bounds];
noDataLabel.backgroundColor = self.backgroundColor;
noDataLabel.textAlignment = NSTextAlignmentCenter;
noDataLabel.text = #"NO DATA";
// configure font, etc.
[self addSubview:noDataLabel];
_noDataLabel = noDataLabel;
}
return _noDataLabel;
}
EDIT
If you want to treat the custom views as untouchable, you can handle the state in the view controller that contains them, but it's a little awkward because we need to solve the problem of associating the noData label with the subview. Something like this can work...
// in the view controller that contains the views that should be covered with labels
#interface ViewController ()
#property(weak,nonatomic) NSMutableArray *noDataViews;
#end
// initialize noDataViews early, like in viewDidLoad
_noDataViews = [#[] mutableCopy];
The array noDataViews can contain dictionaries. The dictionary will contain the view that has noData (this can be an instance of your third-party custom view), and a UILabel intended to cover it.
- (void)setView:(UIView *)view hasNoData:(BOOL)noData {
// find the dictionary corresponding to view
NSDictionary *dictionary;
for (NSDictionary *d in self.noDataViews) {
if (d[#"view"] == view) {
dictionary = d;
break;
}
}
// if it doesn't exist, insert it
if (!dictionary) {
UILabel *label = [self labelToCover:view];
dictionary = #{ #"view":view, #"label":label };
[self.noDataViews addObject:dictionary];
}
// get the label
UILabel *label = dictionary[#"label"];
label.alpha = (noData)? 1.0 : 0.0;
}
// create a label that will cover the passed view, add it as a subview and return it
- (UILabel *)labelToCover:(UIView *)view {
UILabel *noDataLabel = [[UILabel alloc] initWithFrame:view.frame];
noDataLabel.backgroundColor = view.backgroundColor;
noDataLabel.textAlignment = NSTextAlignmentCenter;
noDataLabel.text = #"NO DATA";
// configure font, etc.
[self.view addSubview:noDataLabel];
return noDataLabel;
}
Depending on how often the views change state to the noData state, you might want to clean up the dictionaries, removing those whose label's alpha == 0.0.
- (void)releaseNoDataViews {
NSMutableArray *removeThese = [#[] mutableCopy];
// work out which ones to remove
for (NSDictionary *d in self.noDataViews) {
UILabel *label = d[#"label"];
if (label.alpha == 0.0) {
[removeThese addObject:d];
}
}
for (NSDictionary *d in removeThese) {
UILabel *label = d[#"label"];
[label removeFromSuperview];
[self.noDataViews removeObject:d];
}
}
This a little verbose because by keeping our hands off the custom views, we put the logic to change how they look (cover them) in the view controller.
Maybe a better idea that keeps hands off the custom views is to wrap them in a containing view that does the additional work adding the noData state.
For example, say CustomView comes from the third party. Create a class called CustomViewWrapper that contains the CustomView as a child and adds the noData behavior outlined above. Instead of painting CustomViews in IB, paint CustomViewWrappers....
// CustomViewWrapper.h
#class CustomView;
#interface CustomViewWrapper : UIView
#property(assign,nonatomic) BOOL noData;
#end
// CustomViewWrapper.m
#import "CustomView.h"
#interface CustomViewWrapper ()
#property(weak,nonatomic) CustomView *customView;
#property(weak,nonatomic) UILabel *noDataLabel;
#end
- (id)initWithCoder:(NSCoder *)aDecoder {
self = [super initWithCoder:aDecorder];
if (self) {
CustomView *customView = [[CustomView alloc] init];
[self addSubView:customView];
_customView = customView;
}
return self;
}
- (void)layoutSubviews {
[super layoutSubviews];
self.customView.frame = self.bounds;
}
- (void)setNoData:(BOOL)noData {
_noData = noData;
self.noDataLabel.alpha = (noData)? 1.0 : 0.0;
}
- (UILabel *)noDataLabel {
if (!_noDataLabel) {
UILabel *noDataLabel = [[UILabel alloc] initWithFrame:self.bounds];
noDataLabel.backgroundColor = self.backgroundColor;
noDataLabel.textAlignment = NSTextAlignmentCenter;
noDataLabel.text = #"NO DATA";
// configure font, etc.
[self addSubview:noDataLabel];
_noDataLabel = noDataLabel;
}
return _noDataLabel;
}
I want to add a green line below UIButton for selected state, like following image.
I have set content offset for image and title in storyboard but i am facing issue while using auto layouts. Also selected state has image below text and un-selected state has no image below. What is the best way to handle this?
Here is a subclass that I have created for our App.
Header file:
#import <UIKit/UIKit.h>
IB_DESIGNABLE
#interface CMTabButton : UIButton
/// The color for the bottom line of the button when it is selected
#property (nonatomic)IBInspectable UIColor *selectedColor;
/// The color for the bottom line of the button when it is not selected
#property (nonatomic)IBInspectable UIColor *defaultColor;
/// The height of the bottom line of the button (default 2 pixels)
#property(nonatomic,assign)IBInspectable CGFloat bottomLineHeight;
#end
Implementation file:
#import "CMTabButton.h"
#interface CMTabButton() {
UIView *bottomLine;
NSLayoutConstraint *bottomLineHeightConstraint;
}
#end
#implementation CMTabButton
- (id)initWithCoder:(NSCoder *)aDecoder {
self = [super initWithCoder:aDecoder];
if (self) {
[self setup];
}
return self;
}
- (id)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
[self setup];
}
return self;
}
- (void)didMoveToWindow {
[super didMoveToWindow];
[self uppdateUI];
}
- (void)setup {
//add the bottom line
[self addBottomLine];
}
- (void)setSelected:(BOOL)selected {
//if the unselected color is transparent, we animate the line in/out by changing the height of the line
if (self.defaultColor==nil) {
bottomLineHeightConstraint.constant = (selected) ? self.bottomLineHeight : 0;
//we force the selected color for the animation so the animation is visible
bottomLine.backgroundColor = self.selectedColor;
[UIView animateWithDuration:0.15
animations:^{
[self layoutIfNeeded];
}
completion:^(BOOL finished) {
if (!selected)
bottomLine.backgroundColor = self.defaultColor;
[super setSelected:selected];
}
];
}
else {
bottomLineHeightConstraint.constant = self.bottomLineHeight;
//else, we animate the color change
[UIView animateWithDuration:0.15
animations:^{
bottomLine.backgroundColor = selected ? self.selectedColor : self.defaultColor;
}
completion:^(BOOL finished) {
[super setSelected:selected];
}
];
}
}
- (void)addBottomLine {
bottomLine = [UIView new];
bottomLine.translatesAutoresizingMaskIntoConstraints = NO;
[self addSubview:bottomLine];
bottomLineHeightConstraint = [NSLayoutConstraint constraintWithItem:bottomLine attribute:NSLayoutAttributeHeight relatedBy:NSLayoutRelationEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1 constant:0];
[self addConstraint:bottomLineHeightConstraint];
[self addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:#"H:|[bottomLine]|" options:0 metrics:nil views:#{#"bottomLine":bottomLine}]];
[self addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:#"V:[bottomLine]|" options:0 metrics:nil views:#{#"bottomLine":bottomLine}]];
}
- (void)prepareForInterfaceBuilder {
[self uppdateUI];
}
- (void)uppdateUI {
bottomLine.backgroundColor = self.selected ? self.selectedColor : self.defaultColor;
if (self.bottomLineHeight>0) {
bottomLineHeightConstraint.constant = self.bottomLineHeight;
[self layoutIfNeeded];
}
}
#end
To use it, select your UIButton on Interface builder, then change the class name of it from UIButton to CMTabButton and then you can set the properties from the Interface builder directly
I've got a segmentedControl with three views in my app, one of which is a scrollView which works like a sort of gallery without zoom, with pageControl and an imageView at the center.
The hierarchy is like
--> Segmented Control (3 views) : descriptionView, imageTabView, shareView
----> imagesTabView (UIView)
------> scrollView
------> imageView
----> pageControl
When the device is portrait or landscape, the imageView images are shown correctly, they're centered and scrolling works perfectly fine.
The only problem is that when you turn the device again, if the image is "in the middle" (e.g. is the 2nd of 3 or the 3rd of 6), it's being shown decentered, far left or right, and with a little swipe it goes back at the center, while if the image is the first or the last one, it works properly.
I've looked here on S.O. on various threads, tried to set a contentView as a subview of the scrollView and add the imageView as subview of contentView, but didn't work, tried to attach the imageView to the bottom or the right of the scrollView but didn't work either.
I feel like I'm a step away to achieve what I want to do, the only problem is that I can't get why it's not centered.
In viewWillLayoutSubviews I've specified the contentSize, in order that when it rotates, the size it's set correctly, like
-(void)viewWillLayoutSubviews{
[super viewWillLayoutSubviews];
self.scrollView.contentSize = CGSizeMake (self.scrollView.frame.size.width * photosArray.count, 1);
}
Here's how I'm initializing the pageControl, the scrollView and the imageView:
-(void)configureImageTab{
pageControl = [UIPageControl new];
[pageControl addTarget:self action:#selector(changePage) forControlEvents:UIControlEventValueChanged];
pageControl.translatesAutoresizingMaskIntoConstraints = NO;
//Don't show pageControl when there are no photos
if (photosURL.count == 0)
pageControl.hidden = YES;
//Configuring scrollView
self.scrollView = [[UIScrollView alloc] initWithFrame:CGRectMake(0, 0, self.imageSegmentView.frame.size.width, self.imageSegmentView.frame.size.height-pageControl.frame.size.height)];
self.scrollView.pagingEnabled = YES;
self.scrollView.delegate = self;
self.scrollView.userInteractionEnabled = YES;
self.scrollView.showsHorizontalScrollIndicator = NO;
self.scrollView.translatesAutoresizingMaskIntoConstraints = NO;
//... Code cut - adding remote images to fetch to array
//Actual setup -> scrollView adding imageView as subview with all the images
for (int i =0; i< photosArray.count; i++){
CGRect frame;
frame.origin.x = self.scrollView.frame.size.width * i;
frame.origin.y = 0;
frame.size = self.scrollView.frame.size;
//imageView setup
imageView = [[UIImageView alloc]initWithFrame:frame];
imageView.backgroundColor = [UIColor clearColor];
imageView.clipsToBounds = YES;
imageView.userInteractionEnabled = YES;
imageView.autoresizingMask = UIViewAutoresizingFlexibleHeight | UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin;
imageView.translatesAutoresizingMaskIntoConstraints = YES;
imageView.contentMode = UIViewContentModeScaleAspectFit;
//Setting images urls
[imageView setImageWithURL:[NSURL URLWithString:[photosArray objectAtIndex:i]] completed:^(UIImage *image, NSError *error, SDImageCacheType cacheType, NSURL *imageURL) {
//Error handling
}
}usingActivityIndicatorStyle:UIActivityIndicatorViewStyleGray];
//Adding gesture recognizer to scrollView and imageView as subview
[self.scrollView addGestureRecognizer:singleTap];
[self.scrollView addSubview:imageView];
}
//Setting the contentSize
pageControl.numberOfPages = [photosURL count];
[self.imageSegmentView addSubview:self.scrollView];
[self.imageSegmentView addSubview:pageControl];
//Constraints
NSDictionary *views = #{#"pageControl" : pageControl, #"scrollView" : self.scrollView};
[self.imageSegmentView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:#"H:|-0-[pageControl]-0-|" options:0 metrics:nil views:views]];
[self.imageSegmentView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:#"V:|[scrollView]-1-[pageControl]-1-|" options:0 metrics:nil views:views]];
[self.imageSegmentView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:#"H:|[scrollView]|" options:0 metrics:nil views:views]];
[pageControl addConstraint:[NSLayoutConstraint constraintWithItem:pageControl attribute:NSLayoutAttributeHeight relatedBy:NSLayoutRelationEqual toItem:self.imageSegmentView attribute:NSLayoutAttributeHeight multiplier:0 constant:30]];
}
#pragma mark - scrollView delegate -
-(void)scrollViewDidScroll:(UIScrollView *)sView{
CGFloat pageWidth = self.scrollView.frame.size.width;
int page = floor ((self.scrollView.contentOffset.x - pageWidth /2) /pageWidth) +1;
self.pageControl.currentPage = page;
}
-(IBAction)changePage {
CGRect frame;
frame.origin.x = self.scrollView.frame.size.width * self.pageControl.currentPage;
frame.origin.y = 0;
frame.size = self.scrollView.frame.size;
[self.scrollView scrollRectToVisible:frame animated:YES];
}
-(void)scrollViewWillBeginDragging:(UIScrollView *)scrollView{
pageControlBeingUsed = NO;
}
-(void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView{
pageControlBeingUsed = NO;
}
One note to make: imageView is using autoresizingMask: without that, it wouldn't be able to show the images properly.
My guess is that probably there's something to fix within the scrollView delegate, but I'm not quite sure.
Any suggestion appreciated!
EDIT
I've noticed that the same bug occurs in Twitter app when browsing a user's pictures and then turning the device.
EDIT 2 for TL;DR
Basically, let's say I have 3 images in an horizontal scrollView with paging.
I turn the device from Portrait to Landscape on the first photo, and it's shown at its own place, correctly centered.
I move to the next photo, shown centered, and then I turn the device again to Portrait. The photo is not aligned correctly, is not centered
Practically, the first and the last images, when the device rotates multiple times, are shown centered. The others are not centered
EDIT 3
I've extracted some of the lines and made a sample project to demonstrate the issue I'm having. I guess there's definitely something up with contentSize.
We can fix the specific bug you're talking about (scroll view not aligned to page boundary after rotation) by recording the current page when the interface is about to rotate, and then setting the scroll view's contentOffset appropriately during the rotation, after the system has updated the scroll view's bounds size. Let's add a pageNumberPriorToRotation instance variable:
#implementation ViewController {
CGFloat pageNumberPriorToRotation;
}
Then, we set it when the interface is about to rotate:
- (void)willRotateToInterfaceOrientation:(UIInterfaceOrientation)toInterfaceOrientation duration:(NSTimeInterval)duration {
[super willRotateToInterfaceOrientation:toInterfaceOrientation duration:duration];
[self setPageNumberPriorToRotation];
}
- (void)setPageNumberPriorToRotation {
CGRect bounds = self.scrollView.bounds;
static const int kNumberOfImages = 3;
pageNumberPriorToRotation = fmin(round(bounds.origin.x / bounds.size.width),
kNumberOfImages - 1);
}
and we use it to set the scroll view's contentOffset during the interface rotation:
-(void)willAnimateRotationToInterfaceOrientation:(UIInterfaceOrientation)toInterfaceOrientation duration:(NSTimeInterval)duration{
[super willAnimateRotationToInterfaceOrientation:toInterfaceOrientation duration:duration];
[self updateScrollViewLayout];
}
- (void)updateScrollViewLayout {
CGRect bounds = self.scrollView.bounds;
bounds.origin.x = bounds.size.width * pageNumberPriorToRotation;
self.scrollView.bounds = bounds;
}
This takes care of your primary complaint: the scroll view will always be aligned to a page view boundary after a rotation.
However…
There are some other problems with the scroll view interaction. In landscape orientation, I can't scroll to the third image. After rotating to landscape and back to portrait, I can scroll to a blank fourth page. These problems are presumably what you meant by “there's definitely something up with contentSize”.
Furthermore, your code has a number of problems. It uses some outdated style, like explicitly declaring instance variables for properties and putting instance variables in the header file. It also suffers from Massive View Controller. It could really stand to be rewritten in modern style, and using features like UITabBarController and UIPageViewController.
Anyway, you probably have neither the time nor the inclination to do that amount of work, so I will show you how to solve the contentSize problems and slim down your VC a little at the same time.
I'll make a UIScrollView subclass called ImageScrollView. You give me the array of images and I'll take care of setting up its subviews and aligning to a page boundary after a rotation. Here's my header file:
ImageScrollView.h
#import <UIKit/UIKit.h>
#interface ImageScrollView : UIScrollView
#property (nonatomic, copy) NSArray *images;
#end
To implement this, I'll need some instance variables:
ImageScrollView.m
#import "ImageScrollView.h"
#import <tgmath.h>
#implementation ImageScrollView {
NSMutableArray *imageSubviews;
CGSize priorSize;
CGFloat pageNumber;
BOOL needsToSyncSubviewsWithImages : 1;
}
Anyway, first I'll implement the public API, which is just the images property:
#pragma mark - Public API
#synthesize images = _images;
- (void)setImages:(NSArray *)images {
_images = [images copy];
needsToSyncSubviewsWithImages = YES;
}
Note that when you set the images array, I don't immediately create the subviews. For now, I just set the needsToSyncSubviewsWithImages flag so I'll know to do it during the layout phase.
#pragma mark - UIView overrides
Next, I need to override layoutSubviews so I can do the real work during the layout phase. The system sends me layoutSubviews during the layout phase if my subviews array has changed, or if my bounds has changed.
Because I'm a scroll view, and because a scroll view's contentOffset is really just an alias for its bounds.origin, the system sends me layoutSubviews a lot: every time the scroll view scrolls. So I want to be careful to do only necessary work in layoutSubviews.
- (void)layoutSubviews {
The first thing I do is call super, which takes lets auto layout work (if you're using it) and updates my scroll indicators (if they're visible).
[super layoutSubviews];
Next, if I got new images, I set up the subviews that display them.
if (needsToSyncSubviewsWithImages) {
[self syncSubviewsWithImages];
}
Next, if I've set up new subviews, or if I've changed size, I lay out my subviews' frames for the new size, and align to a page boundary.
if (needsToSyncSubviewsWithImages || !CGSizeEqualToSize(self.bounds.size, priorSize)) {
[self layoutForNewSize];
}
Finally, I update my state.
needsToSyncSubviewsWithImages = NO;
priorSize = self.bounds.size;
[self updatePageNumber];
}
Of course, I delegated all the real work to helper methods, so now I need to implement those.
#pragma mark - Implementation details
To synchronize my subviews with my images, I need to do three things. I need to make sure I've actually allocated my imageSubviews array, I need to make sure every image is in a subview, and I need to make sure I don't have any extra image subviews (in case my images array was made smaller).
- (void)syncSubviewsWithImages {
[self ensureImageSubviewsArrayExists];
[self putImagesInSubviews];
[self removeExtraSubviews];
}
- (void)ensureImageSubviewsArrayExists {
if (imageSubviews == nil) {
imageSubviews = [NSMutableArray arrayWithCapacity:self.images.count];
}
}
- (void)putImagesInSubviews {
[self.images enumerateObjectsUsingBlock:^(id obj, NSUInteger i, BOOL *stop) {
[self putImage:obj inSubviewAtIndex:i];
}];
}
- (void)removeExtraSubviews {
while (imageSubviews.count > self.images.count) {
[imageSubviews.lastObject removeFromSuperview];
[imageSubviews removeLastObject];
}
}
- (void)putImage:(UIImage *)image inSubviewAtIndex:(NSUInteger)i {
UIImageView *imageView = [self imageViewAtIndex:i];
imageView.image = image;
}
When I want to get the image view for an index, I might find that I haven't actually created enough subviews yet, so I create them on demand:
- (UIImageView *)imageViewAtIndex:(NSUInteger)i {
while (i >= imageSubviews.count) {
UIView *view = [[UIImageView alloc] init];
view.contentMode = UIViewContentModeScaleAspectFit;
view.autoresizingMask = UIViewAutoresizingFlexibleRightMargin | UIViewAutoresizingFlexibleBottomMargin;
[self addSubview:view];
[imageSubviews addObject:view];
}
return imageSubviews[i];
}
Note that I've set the autoresizingMask such that autoresizing won't actually modify my subview frames. Instead, I'll lay them out “manually”.
OK, now I need to implement the methods that set my subviews' frames and align to a page boundary when my size changes.
- (void)layoutForNewSize {
[self setSubviewFramesAndContentSize];
[self alignToNearestPage];
}
Setting the subview frames requires looping over them, laying them out from left to right. After I've laid out the last one, I know my contentSize. Note that I need to loop over imageSubviews only, not self.subviews, because self.subviews also contains the scroll indicators.
- (void)setSubviewFramesAndContentSize {
CGRect frame = self.bounds;
frame.origin = CGPointZero;
for (UIView *subview in imageSubviews) {
subview.frame = frame;
frame.origin.x += frame.size.width;
}
self.contentSize = CGSizeMake(frame.origin.x, frame.size.height);
}
To align to the nearest page, I set my contentOffset based on the last known page number and my new size.
- (void)alignToNearestPage {
self.contentOffset = CGPointMake(pageNumber * self.bounds.size.width, 0);
}
Finally, I need to update my page number every time I scroll, so I'll have it in case of rotation:
- (void)updatePageNumber {
// Note that self.contentOffset == self.bounds.origin.
CGRect bounds = self.bounds;
pageNumber = fmin(round(bounds.origin.x / bounds.size.width), self.images.count - 1);
}
#end
Now you can update ViewController to use the ImageScrollView. This mostly involves ripping stuff out:
-(void)configureImageTab{
//Page control
pageControl = [UIPageControl new];
pageControl.currentPageIndicatorTintColor = [UIColor blackColor];
pageControl.pageIndicatorTintColor = [UIColor grayColor];
[pageControl addTarget:self action:#selector(changePage) forControlEvents:UIControlEventValueChanged];
pageControl.translatesAutoresizingMaskIntoConstraints = NO;
//Configuring scrollView
self.scrollView = [[ImageScrollView alloc] initWithFrame:CGRectMake(0, 0, self.imageSegmentView.frame.size.width, self.imageSegmentView.frame.size.height-pageControl.frame.size.height)];
self.scrollView.backgroundColor = [UIColor clearColor];
self.scrollView.pagingEnabled = YES;
self.scrollView.delegate = self;
self.scrollView.userInteractionEnabled = YES;
self.scrollView.showsHorizontalScrollIndicator = NO;
self.scrollView.translatesAutoresizingMaskIntoConstraints = NO;
//Adding imageURLS to array
photos = #[ [UIImage imageNamed:#"createBootableUSBInstallDrive1"], [UIImage imageNamed:#"createBootableUSBInstallDrive2"], [UIImage imageNamed:#"createBootableUSBInstallDrive3"]];
self.scrollView.images = photos;
pageControl.numberOfPages = [photos count];
[self.imageSegmentView addSubview:self.scrollView];
[self.imageSegmentView addSubview:pageControl];
NSDictionary *views = #{#"pageControl" : pageControl, #"scrollView" : self.scrollView};
[self.imageSegmentView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:#"H:|-0-[pageControl]-0-|" options:0 metrics:nil views:views]];
[self.imageSegmentView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:#"H:|[scrollView]|" options:0 metrics:nil views:views]];
[self.imageSegmentView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:#"V:|[scrollView]-1-[pageControl]-1-|" options:0 metrics:nil views:views]];
[pageControl addConstraint:[NSLayoutConstraint constraintWithItem:pageControl attribute:NSLayoutAttributeHeight relatedBy:NSLayoutRelationEqual toItem:self.imageSegmentView attribute:NSLayoutAttributeHeight multiplier:0 constant:30]];
}
You also need to change the declared type of scrollView to ImageScrollView in the header file. You can eliminate the viewWillLayoutSubviews, willRotateToInterfaceOrientation:duration:, and willAnimateRotationToInterfaceOrientation:duration: methods entirely.
I've uploaded my modified version of your test project to this github repository.
I have a need for floating autolayout. I find that its quite hard problem to solve but i think it can be done by using some of the tips described here: http://www.objc.io/issue-3/advanced-auto-layout-toolbox.html
Maybe someone have already tried solving such problem or would like to try it.
So here is the challenge:
nicely floated views which drops to new line if content width exceeded, like in next picture view1 width is bigger so view2 drops to new line. (same would happen if view2 width would become bigger)
All views come in sequence, views can have defined min width and max width should be the container width. views can stretch in height but then they always take full content width.
I worked on this challenge tonight. I ran the code in the iPhone Simulator; it seems to work. However, I did not attempt to match the exact specifications of the OP, nor did I follow the link to tips on how to do this. I just wanted to see what I could knock out on my own in a couple of hours.
There's nothing to see in storyboard except an empty, yellow scroll view pinned to the sides of the root view.
The gray floating views are inside a yellow scroll view. The width of the scroll view's content size is the width of the root view; the height of the content size shrinks and expands to accommodate the varying number of rows.
The only place I didn't use Auto Layout was for the scroll view's content view (here, I used Apple's so-called "Mixed Approach").
The widths of the floating cells are randomly generated whenever viewWillLayoutSubviews is called. Hence, all floating cells change their width upon device rotation. I held the height of all floating cells to a constant.
#interface ViewController ()
#property (nonatomic, strong) NSMutableArray *floatingViews;
#property (weak, nonatomic) IBOutlet UIScrollView *scrollView;
#property (nonatomic, strong) UIView *contentView;
#end
#implementation ViewController
#define NUM_OF_VIEWS 18
#define HEIGHT 30.0f
#define HORIZONTAL_SPACER 20.0f
#define VERTICAL_SPACER 10.0f
- (void)viewDidLoad
{
[super viewDidLoad];
self.contentView = [[UIView alloc] initWithFrame:self.view.bounds];
[self.scrollView addSubview:self.contentView];
self.floatingViews = [[NSMutableArray alloc] init];
for (int i = 0; i < NUM_OF_VIEWS; i++) {
UIView *view = [[UIView alloc] init];
view.backgroundColor = [UIColor grayColor];
view.translatesAutoresizingMaskIntoConstraints = NO;
[self.floatingViews addObject:view];
[self.contentView addSubview:view];
}
}
- (void)viewWillLayoutSubviews
{
[self configureSizeConstraintsForAllViews];
CGFloat superviewWidth = self.view.bounds.size.width;
int row = 0;
CGFloat leftMargin = 0.0f;
for (int i = 0; i < [self.floatingViews count]; i++) {
UIView *currentView = self.floatingViews[i];
// is there room for the current view on this row?
NSLayoutConstraint *widthConstaint = [self widthConstraintForView:currentView];
CGFloat currentViewWidth = widthConstaint.constant;
if ((leftMargin + currentViewWidth) > superviewWidth) {
row++;
leftMargin = 0.0f;
}
// position current view
[self configureTopConstraintForView:currentView forRow:row];
[self configureLeftConstraintForView:currentView withConstant:leftMargin];
// update leftMargin
leftMargin += currentViewWidth + HORIZONTAL_SPACER;
}
// update size of content view and scroll view's content size
CGRect rect = self.contentView.frame;
rect.size.width = superviewWidth;
rect.size.height = row * (HEIGHT + VERTICAL_SPACER) + HEIGHT;
self.contentView.frame = rect;
[self.scrollView setContentSize:rect.size];
}
- (void)configureSizeConstraintsForAllViews
{
static BOOL firstTime = YES;
if (firstTime) {
firstTime = NO;
[self configureHeightConstraintsForAllViews];
}
for (int i = 0; i < [self.floatingViews count]; i++) {
[self configureRandomWidthForView:self.floatingViews[i]];
}
}
- (void)configureRandomWidthForView:(UIView *)view
{
CGFloat maxWidth = self.view.bounds.size.width;
CGFloat minWidth = 30.0f;
CGFloat randomScale = (arc4random() % 101) / 100.0f; // 0.0 - 1.0
CGFloat randomWidth = minWidth + randomScale * (maxWidth - minWidth);
assert(randomWidth >= minWidth && randomWidth <= maxWidth);
NSLayoutConstraint *widthConstraint = [self widthConstraintForView:view];
if (!widthConstraint) {
widthConstraint = [NSLayoutConstraint constraintWithItem:view attribute:NSLayoutAttributeWidth relatedBy:NSLayoutRelationEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1.0f constant:0.0f];
[view addConstraint:widthConstraint];
}
widthConstraint.constant = randomWidth;
}
- (NSLayoutConstraint *)widthConstraintForView:(UIView *)view
{
NSLayoutConstraint *widthConstraint = nil;
for (NSLayoutConstraint *constraint in view.constraints) {
if (constraint.firstAttribute == NSLayoutAttributeWidth) {
widthConstraint = constraint;
break;
}
}
return widthConstraint;
}
- (NSLayoutConstraint *)topConstraintForView:(UIView *)view
{
NSLayoutConstraint *topConstraint = nil;
for (NSLayoutConstraint *constraint in view.superview.constraints) {
if (constraint.firstItem == view || constraint.secondItem == view) {
if (constraint.firstAttribute == NSLayoutAttributeTop) {
topConstraint = constraint;
break;
}
}
}
return topConstraint;
}
- (NSLayoutConstraint *)leftConstraintForView:(UIView *)view
{
NSLayoutConstraint *leftConstraint = nil;
for (NSLayoutConstraint *constraint in view.superview.constraints) {
if (constraint.firstItem == view || constraint.secondItem == view) {
if (constraint.firstAttribute == NSLayoutAttributeLeft) {
leftConstraint = constraint;
break;
}
}
}
return leftConstraint;
}
- (void)configureHeightConstraintsForAllViews
{
assert(self.floatingViews);
for (int i = 0; i < [self.floatingViews count]; i++) {
UIView *view = self.floatingViews[i];
[view addConstraint:[NSLayoutConstraint constraintWithItem:view attribute:NSLayoutAttributeHeight relatedBy:NSLayoutRelationEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1.0f constant:HEIGHT]];
}
}
- (void)configureTopConstraintForView:(UIView *)view forRow:(NSUInteger)row
{
NSLayoutConstraint *topConstraint = [self topConstraintForView:view];
if (!topConstraint) {
topConstraint = [NSLayoutConstraint constraintWithItem:view attribute:NSLayoutAttributeTop relatedBy:NSLayoutRelationEqual toItem:view.superview attribute:NSLayoutAttributeTop multiplier:1.0f constant:0.0f];
[view.superview addConstraint:topConstraint];
}
topConstraint.constant = row * (HEIGHT + VERTICAL_SPACER);
}
- (void)configureLeftConstraintForView:(UIView *)view withConstant:(CGFloat)constant
{
NSLayoutConstraint *leftConstraint = [self leftConstraintForView:view];
if (!leftConstraint) {
leftConstraint = [NSLayoutConstraint constraintWithItem:view attribute:NSLayoutAttributeLeft relatedBy:NSLayoutRelationEqual toItem:view.superview attribute:NSLayoutAttributeLeft multiplier:1.0f constant:0.0f];
[view.superview addConstraint:leftConstraint];
}
leftConstraint.constant = constant;
}
- (BOOL)prefersStatusBarHidden
{
return YES;
}
#end
To simplify, take inspiration from the way Covoa text system works. Then a little from Core Text and auto layout.
Each of those views would go into an array.
Each of them would have no content compression and lots of content hugging.
Each line would be an NSStackView with horizontal orientation.
All of those would go in an NSStackView with vertical orientation.
That into a scroll view.
If a view doesn't fit in stack view line 1 you readjust down each stack view line.
It would work.
It might be slow if this gets really big.