To experiment with autolayout and uiscrollview's I have been using this example
which I have edited to include 2 views in the scroll view, I have setup the autolayout constraints to position the views horizontally adjacent with their size set to fill the scroll view frame.
UIView *beeView = [[[NSBundle mainBundle] loadNibNamed:#"BeeView" owner:nil options:nil] firstObject];
beeView.translatesAutoresizingMaskIntoConstraints = NO;
[self.scrollView addSubview:beeView];
UIView *beeView2 = [[[NSBundle mainBundle] loadNibNamed:#"BeeView" owner:nil options:nil] firstObject];
beeView2.backgroundColor= [UIColor orangeColor];
beeView2.translatesAutoresizingMaskIntoConstraints = NO;
[self.scrollView addSubview:beeView2];
NSDictionary *views = #{#"beeView":beeView,#"beeView2":beeView2, #"scrollView":self.scrollView};
NSDictionary *metrics = #{#"height" : #200};
[self.scrollView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:#"H:|[beeView(==scrollView)][beeView2(==beeView)]|" options:NSLayoutFormatAlignAllTop | NSLayoutFormatAlignAllBottom metrics:metrics views:views]];
[self.scrollView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:#"V:|[beeView(==scrollView)]|" options:kNilOptions metrics:metrics views:views]];
which nicely produces what I intended.
However, if the scroll view's contentOffset is nonzero and the device is rotated from portrait to landscape, the content offset of the scroll view is automatically set to 32px. (see screenshot)
I have tried saving contentOffset and setting it to this saved value when scrollViewDidEndDecelerating: is called which works but is ugly as the scroll view scrolls to a 32px offset and then back to where I want it to be.
How do I control the scroll view's contentOffset? Are the autolayout constraints wrong? Are there extra constraints I can add to control the contentOffset when resizing the view?
Where the 32px comes from? Is it related to your left and right scrollView margin?
Does it keep the wrong offset every time you change page ? If that the case, you should look at your scrollView's contentInsets values.
Otherwise, what I do to manage rotation on scrollView with paging is observing the scrollView's contentSize:
First, when you load the view, add the observer:
[self.scrollView addObserver:self forKeyPath:NSStringFromSelector(#selector(contentSize)) options:0 context:nil];
Then, when the contentSize value change, adjust the contentOffset:
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
if (object == self.scrollView && [keyPath isEqualToString:NSStringFromSelector(#selector(contentSize))]) {
//Note that you should track your page index
self.scrollView.contentOffset = CGPointMake(self.pageIndex * self.scrollView.bounds.size.width, self.scrollView.contentOffset.y);
} else {
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
}
Finally, remove the observer when you unload the scrollView:
[self.scrollView removeObserver:self forKeyPath:NSStringFromSelector(#selector(contentSize)) context:nil];
After referring back to a few previous posts: one & two. It seems you might find your solution following one of these steps:
Programmatically : If your UIScrollViews' parent VC is not directly on the Nav Stack -
~Edit~
// Without a Navigation Controller
self.automaticallyAdjustsScrollViewInsets = NO;
// With a Navigation Controller
self.parentViewController.automaticallyAdjustsScrollViewInsets = NO;
self.automaticallyAdjustsScrollViewInsets = NO;
Interface Builder : Attributes Inspector -> Uncheck Adjust Scroll View Insets in the Layout Properties.
IF Still Unsuccessful : Within the presented View Controller's ViewWillLayoutSubviews Method try setting each of the following UIScrollView properties:
self.scrollView.contentOffset = CGPointZero;
self.scrollView.contentInset = UIEdgeInsetsZero;
I am guessing a combination of option 1 and 2 will work, depending on how your Navigation Stack is structured.
For iOS 11.0 and greater, the following method solves the issue for me:
if (#available(iOS 11.0, *))
{
self.myScrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
}
I know this is an old question, but in case it is of use to anyone - I have created a subclass of UIScrollView called LMPageView that automatically applies the necessary layout constraints and adjusts the content offset on rotation. The class is available as part of the MarkupKit project on GitHub. Example usage (Swift):
// Add 3 page views
pageView.addPage(view1)
pageView.addPage(view2)
pageView.addPage(view3)
// Show the 3rd page
pageView.currentPage = 2
An additional example that uses markup to initialize a page view can be found here:
PageViewController.swift
PageViewController.xml
Related
Challenge time!
Imagine we have 2 content views:
UIView with dynamically height content (expandable UITextView) = RED
UIView as a footer = BLUE
This content is inside a UIScrollView = GEEN
How should I structure and handle the constraints with auto-layout to archive all the following cases?
I am thinking next basic structure to start with:
- UIScrollView (with always bounce vertically)
- UIView - Container
- UIView - DynamicHeightContent
- UIView - Sticky Footer
Keyboard handling should be done by code watching notifications UIKeyboardWillShowNotification and UIKeyboardWillHideNotification. We can chose to set the keyboard's end frame height to Container UIView bottom pin constraint or to the UIScrollView bottom contentInset.
Now, the tricky part is the sticky footer.
How we make sure the sticky footer UIView stays at the bottom if there is more screen available than the whole Container View?
How do we know the available screen space when the keyboard is shown/hidden? we'll surely need it.
Is is it right this structure I purpose?
Thank you.
When the text content of the UITextView is relatively short, the content view's subviews (i.e., the text view and footer) will not be able to dictate the size of their content view through constraints. That's because when the text content is short, the content view's size will need to be determined by the scroll view's size.
Update: The latter paragraph is untrue. You could install a fixed-height constraint either on the content view itself or somewhere in the content view's view hierarchy. The fixed-height constraint's constant could be set in code to reflect the height of the scroll view. The latter paragraph also reflects a fallacy in thinking. In a pure Auto Layout approach, the content view's subviews don't need to dictate the scroll view's contentSize; instead, it's the content view itself that ultimately must dictate the contentSize.
Regardless, I decided to go with Apple's so-called "mixed approach" for using Auto Layout with UIScrollView (see Apple's Technical Note: https://developer.apple.com/library/ios/technotes/tn2154/_index.html)
Some iOS technical writers, like Erica Sadun, prefer using the mixed approach in pretty much all situations ("iOS Auto Layout Demystified", 2nd Ed.).
In the mixed approach, the content view's frame and the scroll view's content size are explicitly set in code.
Here's the GitHub repo I created for this challenge: https://github.com/bilobatum/StickyFooterAutoLayoutChallenge. It's a working solution complete with animation of layout changes. It works on different sized devices. For simplicity, I disabled rotation to landscape.
For those who don't want to download and run the GitHub project, I have included some highlights below (for the complete implementation, you'll have to look at the GitHub project):
The content view is orange, the text view is gray, and the sticky footer is blue. The text is visible behind the status bar while scrolling. I don't actually like that, but it's fine for a demo.
The only view instantiated in storyboard is the scroll view, which is full-screen (i.e., underlaps status bar).
For testing purposes, I attached a double tap gesture recognizer to the blue footer for the purpose of dismissing the keyboard.
- (void)viewDidLoad
{
[super viewDidLoad];
self.scrollView.alwaysBounceVertical = YES;
[self.scrollView addSubview:self.contentView];
[self.contentView addSubview:self.textView];
[self.contentView addSubview:self.stickyFooterView];
[self configureConstraintsForContentViewSubviews];
// Apple's mixed (a.k.a. hybrid) approach to laying out a scroll view with Auto Layout: explicitly set content view's frame and scroll view's contentSize (see Apple's Technical Note TN2154: https://developer.apple.com/library/ios/technotes/tn2154/_index.html)
CGFloat textViewHeight = [self calculateHeightForTextViewWithString:self.textView.text];
CGFloat contentViewHeight = [self calculateHeightForContentViewWithTextViewHeight:textViewHeight];
// scroll view is fullscreen in storyboard; i.e., it's final on-screen geometries will be the same as the view controller's main view; unfortunately, the scroll view's final on-screen geometries are not available in viewDidLoad
CGSize scrollViewSize = self.view.bounds.size;
if (contentViewHeight < scrollViewSize.height) {
self.contentView.frame = CGRectMake(0, 0, scrollViewSize.width, scrollViewSize.height);
} else {
self.contentView.frame = CGRectMake(0, 0, scrollViewSize.width, contentViewHeight);
}
self.scrollView.contentSize = self.contentView.bounds.size;
}
- (void)configureConstraintsForContentViewSubviews
{
assert(_textView && _stickyFooterView); // for debugging
// note: there is no constraint between the subviews along the vertical axis; the amount of vertical space between the subviews is determined by the content view's height
NSString *format = #"H:|-(space)-[textView]-(space)-|";
[self.contentView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:format options:0 metrics:#{#"space": #(SIDE_MARGIN)} views:#{#"textView": _textView}]];
format = #"H:|-(space)-[footer]-(space)-|";
[self.contentView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:format options:0 metrics:#{#"space": #(SIDE_MARGIN)} views:#{#"footer": _stickyFooterView}]];
format = #"V:|-(space)-[textView]";
[self.contentView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:format options:0 metrics:#{#"space": #(TOP_MARGIN)} views:#{#"textView": _textView}]];
format = #"V:[footer(height)]-(space)-|";
[self.contentView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:format options:0 metrics:#{#"space": #(BOTTOM_MARGIN), #"height": #(FOOTER_HEIGHT)} views:#{#"footer": _stickyFooterView}]];
// a UITextView does not have an intrinsic content size; will need to install an explicit height constraint based on the size of the text; when the text is modified, this height constraint's constant will need to be updated
CGFloat textViewHeight = [self calculateHeightForTextViewWithString:self.textView.text];
self.textViewHeightConstraint = [NSLayoutConstraint constraintWithItem:self.textView attribute:NSLayoutAttributeHeight relatedBy:NSLayoutRelationEqual toItem:nil attribute:0 multiplier:1.0f constant:textViewHeight];
[self.textView addConstraint:self.textViewHeightConstraint];
}
- (void)keyboardUp:(NSNotification *)notification
{
// when the keyboard appears, extraneous vertical space between the subviews is eliminated–if necessary; i.e., vertical space between the subviews is reduced to the minimum if this space is not already at the minimum
NSDictionary *info = [notification userInfo];
CGRect keyboardRect = [[info objectForKey:UIKeyboardFrameEndUserInfoKey] CGRectValue];
keyboardRect = [self.view convertRect:keyboardRect fromView:nil];
double duration = [[info objectForKey:UIKeyboardAnimationDurationUserInfoKey] doubleValue];
CGFloat contentViewHeight = [self calculateHeightForContentViewWithTextViewHeight:self.textView.bounds.size.height];
CGSize scrollViewSize = self.scrollView.bounds.size;
[UIView animateWithDuration:duration animations:^{
self.contentView.frame = CGRectMake(0, 0, scrollViewSize.width, contentViewHeight);
self.scrollView.contentSize = self.contentView.bounds.size;
UIEdgeInsets insets = UIEdgeInsetsMake(0, 0, keyboardRect.size.height, 0);
self.scrollView.contentInset = insets;
self.scrollView.scrollIndicatorInsets = insets;
[self.view layoutIfNeeded];
} completion:^(BOOL finished) {
[self scrollToCaret];
}];
}
Although the Auto Layout component of this demo app took some time, I spent almost as much time on scrolling issues related to a UITextView being nested inside of a UIScrollView.
Instead of using a UIScrollView you would very likely be better off with a UITableView. It also might be better to not using auto-layout. At least, I've found it better to not use it for these sorts of manipulations.
Look into the following:
UITextView textViewDidChange
Change the size of the text view using sizeThatFits (limiting width and using FLT_MAX for height). Change the frame, not the contentSize.
Call UITableView beginUpdates/endUpdates to update the table view
Scroll to the cursor
UIKeyboardWillShowNotification notification
On NSNotification that comes through, you can call userInfo (a Dictionary), and the key UIKeyboardFrameBeginUserInfoKey. Reduce the frame of the table view based on the height of the size of the keyboard.
Scroll to cursor again (since the layouts will have all changed)
UIKeyboardWillHideNotification notification
The same as the show notification, just opposite (increasing the table view height)
To have the footer view stick to the bottom, you could add an intermediate cell to the table view, and have it change size depending on the size of the text and whether the keyboard is visible.
The above will definitely require some extra manipulation on your part - I don't fully understand all of your cases, but it should definitely get you started.
If I understand whole task, my solution is put "red" and "blue" views to one container view, and in the moment when you know size of dynamic content (red) you can calculate size of container and set scrollView content size.
Later, on keyboard events you can adjust white space between content and footer views
Ok, so I've created a UIView in interface builder. I'm using AutoLayout and I've got one subview of this view pinned to all four sides.
Here's what I don't understand. When I load this NIB file using loadNibNamed. I then get a reference to the view. I set the frame for this view. And yet, when I access the subview (using [containerView viewWithTag:1]) it's frame hasn't been automatically resized. What gives? If you change the frame for a parent view, why wouldn't the subview frame change as well?
It doesn't make any sense.
Why can't you just load a UIView, set it's frame and have all the subviews adjust as appropriate (ESPECIALLY since I'm using AutoLayout!)?
EDIT: To be clear, all I want to do is be able to define a UIView hierarchy in IB with appropriate AutoLayout constraints and then be able to load and display that view on the screen sometimes at different sizes? Why is this so hard?
UIKit doesn't update subview geometry immediately when you change a view's geometry. It batches up the updates for efficiency.
After running your event handler, UIKit checks whether any views in the on-screen window hierarchy need to be laid out. If it finds any, it lays them out by solving your layout constraints (if you have any) and then sending layoutSubviews.
If you want to solve the constraints and lay out a view's subviews immediately, simply send layoutIfNeeded to the view:
someView.frame = CGRectMake(0, 0, 200, 300);
[someView layoutIfNeeded];
// The frames of someView.subviews are now up-to-date.
I too had the same problem, I was creating a tutorial view where in which I wanted to add multiple UIViews to a scrollview. While I was trying to get the frame from xib, it gave always 320 and because of that the offset for the pages were wrong and my views looked crappy in iPhone6 and 6plus.
I then used pure autolayout approach, ie instead of using the frame, I added constraints through VFL so that subviews fit exactly to the superview. Below is the snapshot of code where I create around 20 UIViews from Xib and add properly to scrollview
Full code here ScrollViewAutolayout
Method to layout the childviews in the scrollview.
#param nil
#result layout the child views
*/
-(void)layoutViews
{
NSMutableString *horizontalString = [NSMutableString string];
// Keep the start of the horizontal constraint
[horizontalString appendString:#"H:|"];
for (int i=0; i<viewsArray.count; i++) {
// Here I am providing the index of the array as the view name key in the dictionary
[viewsDict setObject:viewsArray[i] forKey:[NSString stringWithFormat:#"v%d",i]];
// Since we are having only one view vertically, then we need to add the constraint now itself. Since we need to have fullscreen, we are giving height equal to the superview.
NSString *verticalString = [NSString stringWithFormat:#"V:|[%#(==parent)]|", [NSString stringWithFormat:#"v%d",i]];
// add the constraint
[contentScrollView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:verticalString options:0 metrics:nil views:viewsDict]];
// Since we need to horizontally arrange, we construct a string, with all the views in array looped and here also we have fullwidth of superview.
[horizontalString appendString:[NSString stringWithFormat:#"[%#(==parent)]", [NSString stringWithFormat:#"v%d",i]]];
}
// Close the string with the parent
[horizontalString appendString:#"|"];
// apply the constraint
[contentScrollView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:horizontalString options:0 metrics:nil views:viewsDict]];
}
Unfortunately the accepted answer by Rob didn't work for me. This is what worked:
- (id)initWithFrame:(CGRect)frame
{
self = [super initWithFrame:frame];
if (self) {
NSArray *views = [[NSBundle mainBundle] loadNibNamed:#"myXib" owner:self options:nil];
[self addSubview:views[0]];
self.subviews[0].frame = CGRectMake(0, 0, self.frame.size.width, self.frame.size.height); //ADDED THIS FOR PROPER SIZE
}
return self;
}
I have this view that used to have autoresizingMask = UIViewAutoresizingFlexibleHeight
When the status bar would animate its height (like when hanging up a phone call), the view's height would animate and increase.
But with auto layout I'm replacing this autoresizingMask with constraints:
UIView *orangeView = [[UIView alloc] initWithFrame:CGRectZero];
orangeView.translatesAutoresizingMaskIntoConstraints = NO;
orangeView.backgroundColor = [UIColor orangeColor];
[self.view addSubview:orangeView];
[self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:#"H:|[orangeView]|" options:0 metrics:nil views:NSDictionaryOfVariableBindings(orangeView)]];
[self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:#"V:|-(40)-[orangeView]-(190)-|" options:0 metrics:nil views:NSDictionaryOfVariableBindings(orangeView)]];
But now, the change in my layout is not animated with the status bar, it's just changed without any animations.
Now I know that I should call -layoutIfNeeded in an animation block when using constraints-based layout. But here I'm not the one creating the animation block! So is there a way to animate the change?
Does it mean I have to found a place in my code that would be executed during this animation block I didn't initiate? I tried to set [self.view layoutIfNeeded] in my controller when the UIApplicationWillChangeStatusBarFrameNotificationis fired, but it doesn't work.
Make sure you add your constraints in the updateConstraints method.
Here's what the docs say:
Custom views that set up constraints themselves should do so by overriding this method. When your custom view notes that a change has been made to the view that invalidates one of its constraints, it should immediately remove that constraint, and then call setNeedsUpdateConstraints to note that constraints need to be updated.
I have a fairly simple view configuration:
A UIViewController, with a child UIScrollView and a UIImageView in this UIScrollView.
I set the UIImageView with a height sufficient to break out of the visible area (ie. higher to 1024pt), and set the Bottom space to superview constraint of my UIImageView to a fixed positive value (20 for example).
The whole setup works as expected, the image scrolls nicely in its parent.
Except when the view is scrolled (the effect is more visible if you scrolled to the bottom of the view), then disappear, and appear again (you switched to another view and came back) the scrolling value is restored, but the content of the scroll view is moved to the outside top part of its parent view.
This is not simple to explain, I'll try to draw it:
If you want to test/view the source (or the storyboard, I did not edit a single line of code). I put a little demo on my github: https://github.com/guillaume-algis/iOSAutoLayoutScrollView
I did read the iOS 6 changelog and the explanation on this particular topic, and think this is the correct implementation of the second option (pure auto layout), but in this case why is the UIScrollView behaving so erratically ? Am I missing something ?
EDIT: This is the exact same issue as #12580434 uiscrollview-autolayout-issue. The answers are just workarounds, as anyone found a proper way to fix this or is this a iOS bug ?
EDIT 2: I found another workaround, which keep the scroll position in the same state the user left it (this is an improvement over 12580434's accepted answer):
#interface GAViewController ()
#property CGPoint tempContentOffset;
#end
#implementation GAViewController
-(void)viewWillAppear:(BOOL)animated
{
[super viewWillAppear:animated];
self.tempContentOffset = self.mainScrollView.contentOffset;
self.scrollView.contentOffset = CGPointZero;
}
-(void)viewDidAppear:(BOOL)animated
{
[super viewDidAppear:animated];
self.scrollView.contentOffset = self.tempContentOffset;
}
This basically save the offset in viewWillAppear, reset it to the origin, and then restore the value in viewDidAppear. The problem seems to occur between these two calls, but I can't find its origin.
Yeah, something strange happened with UIScrollView in pure autolayout environment. Re-reading the iOS SDK 6.0 release notes for the twentieth time I found that:
Note that you can make a subview of the scroll view appear to float (not scroll) over the other scrolling content by creating constraints between the view and a view outside the scroll view’s subtree, such as the scroll view’s superview.
Solution
Connect your subview to the outer view. In another words, to the view in which scrollview is embedded.
As IB does not allow us set up constraints between the imageView and a view outside the scroll view’s subtree, such as the scroll view’s superview then I've done it in code.
- (void)viewDidLoad {
[super viewDidLoad];
// Do any additional setup after loading the view, typically from a nib.
[self.view removeConstraints:[self.view constraints]];
[self.scrollView removeConstraints:[self.scrollView constraints]];
[self.imageView removeConstraints:[self.imageView constraints]];
[self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:#"|[_scrollView]|" options:0 metrics:nil views:NSDictionaryOfVariableBindings(_scrollView)]];
[self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:#"V:|[_scrollView]|" options:0 metrics:nil views:NSDictionaryOfVariableBindings(_scrollView)]];
[self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:#"|[_imageView(700)]|" options:0 metrics:nil views:NSDictionaryOfVariableBindings(_imageView)]];
[self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:#"V:|[_imageView(1500)]|" options:0 metrics:nil views:NSDictionaryOfVariableBindings(_imageView)]];
}
And vau! It works!
The edit didn't work for me. But this worked:
-(void)viewWillDisappear:(BOOL)animated
{
[super viewWillDisappear:animated];
self.tempContentOffset = self.scrollView.contentOffset;
self.scrollView.contentOffset = CGPointZero;
}
- (void)viewDidLayoutSubviews {
[super viewDidLayoutSubviews];
self.scrollView.contentOffset = self.tempContentOffset;
}
For me I went to the IB clicked my view controller that contains the scroll view. Then I went to Attribute Inspector -> View Controller -> Extend Edges -> Uncheck "Under Top Bars" and "Under Bottom Bars".
Simple solution found, Just put
[self setAutomaticallyAdjustsScrollViewInsets:NO];
in your ViewControllers viewDidLoad method
- (void)viewDidLoad {
[super viewDidLoad];
// Do any additional setup after loading the view from its nib.
[self setAutomaticallyAdjustsScrollViewInsets:NO];
}
I had a similar problem using a UIScrollView in a UIViewController with a top extra space that was not visible in Storyboard. The solution for me was to uncheck the "Adjust Scroll View Insets" on the ViewController storyboard properties : see answer Extra Space (inset) in Scroll View at run time
Add a global property contentOffset and save the current contentOffset in viewDidDisappear.
Once you return the method viewDidLayoutSubviews will be called and you can set your original contentOffset.
- (void)viewDidLayoutSubviews
{
[super viewDidLayoutSubviews];
[self.scrollView setContentOffset:self.contentOffset animated:FALSE];
}
- (void)viewDidDisappear:(BOOL)animated
{
[super viewDidDisappear:animated];
self.contentOffset = self.scrollView.contentOffset;
[self.scrollView setContentOffset:CGPointMake(0, 0) animated:FALSE];
}
Looks like the problem solved with the dispatch_async during the viewWillAppear:
- (void)viewWillAppear:(BOOL)animated
{
[super viewWillAppear:animated];
CGPoint originalContentOffset = self.scrollView.contentOffset;
self.scrollView.contentOffset = CGPointZero;
dispatch_async(dispatch_get_main_queue(), ^{
self.scrollView.contentOffset = originalContentOffset;
});
}
Zooming with UIScrollView using a strictly autolayout environment does not seem to work.
This is especially frustrating because the iOS 6 release notes certainly lead me to believe it should when the wrote about a "Pure Auto Layout approach" here http://developer.apple.com/library/ios/#releasenotes/General/RN-iOSSDK-6_0/_index.html
I looked the the WWDC 2012 slides for sessions 202, 228, and 232 and didn't see an answer for this.
The only question I've seen on the internet specifically for this issue is UIScrollView zooming with Auto Layout, but it doesn't provide code of the problem and there is no answer.
This user https://stackoverflow.com/users/341994/matt has given many great responses to UIScrollView autolayout questions and even linked to code on git hub, but I haven't been able to find anything that answers this issue there.
I have attempted to boil this issue down to the absolute minimum to make it clear.
I created a new single view application with a storyboard, and made no changes in the interface builder.
I added a large picture file to the project "pic.jpg".
SVFViewController.h
#import <UIKit/UIKit.h>
#interface SVFViewController : UIViewController <UIScrollViewDelegate>
#property (nonatomic) UIImageView *imageViewPointer;
#end
SVFViewController.m
#import "SVFViewController.h"
#interface SVFViewController ()
#end
#implementation SVFViewController
- (void)viewDidLoad
{
[super viewDidLoad];
// Do any additional setup after loading the view, typically from a nib.
UIScrollView *scrollView = [[UIScrollView alloc] init];
UIImageView *imageView = [[UIImageView alloc] init];
[imageView setImage:[UIImage imageNamed:#"pic.jpg"]];
[self.view addSubview:scrollView];
[scrollView addSubview:imageView];
scrollView.translatesAutoresizingMaskIntoConstraints = NO;
imageView.translatesAutoresizingMaskIntoConstraints = NO;
self.imageViewPointer = imageView;
scrollView.maximumZoomScale = 2;
scrollView.minimumZoomScale = .5;
scrollView.delegate = self;
NSDictionary *viewsDictionary = NSDictionaryOfVariableBindings(scrollView,imageView);
NSLog(#"Current views dictionary: %#", viewsDictionary);
[self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:#"H:|[scrollView]|" options:0 metrics: 0 views:viewsDictionary]];
[self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:#"V:|[scrollView]|" options:0 metrics: 0 views:viewsDictionary]];
[scrollView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:#"H:|[imageView]|" options:0 metrics: 0 views:viewsDictionary]];
[scrollView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:#"V:|[imageView]|" options:0 metrics: 0 views:viewsDictionary]];
}
-(UIView*)viewForZoomingInScrollView:(UIScrollView *)scrollView{
return self.imageViewPointer;
}
#end
Notice I made a particular effort to make this as much like the sample code provided in the iOS 6 release notes, just doing the bare minimum to implement zooming.
So, the problem?
When you run this application and pan around in the scroll view, everything is good. But when you zoom the problem is obvious, the image flickers back and forth, and the placement of the image within the scroll view gets more wrong with every zoom.
It looks like there is battle going on for the content offset of the imageView, it seems it is being set to different values by two different things with every "zoom". (an NSLog of the content offset property of the imageView appears to confirm this).
What am I doing wrong here? Does anyone know how to property implement zooming within a UIScrollView in an purely autolayout environment. Is there an example of this anywhere out there?
Please help.
Once again, re-reading the iOS SDK 6.0 release notes I found that:
Note that you can make a subview of the scroll view appear to float (not scroll) over the other scrolling content by creating constraints between the view and a view outside the scroll view’s subtree, such as the scroll view’s superview.
Solution
Connect your subview to the outer view. In another words, to the view in which scrollview is embedded.
And applying constraints in following way I've got it work:
[self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:#"H:|[scrollView]|" options:0 metrics: 0 views:viewsDictionary]];
[self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:#"V:|[scrollView]|" options:0 metrics: 0 views:viewsDictionary]];
[self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:#"H:|[imageView(width)]" options:0 metrics:#{#"width":#(self.imageViewPointer.image.size.width)} views:viewsDictionary]];
[self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:#"V:|[imageView(height)]" options:0 metrics:#{#"height":#(self.imageViewPointer.image.size.height)} views:viewsDictionary]];
The issues that occurs is the changing of location of the imageview during the zoom process. The origin location of the imageview will change to be a negative value during the zoom. I believe this is why the jerky movement occurs. As well, after the zoom is complete the imageView is still in the wrong location meaning that scrolls will appear to be offset.
If you implement -(void) scrollViewDidZoom:(UIScrollView *)scrollView and log the frame of the UIImageView during this time you can see its origin changing.
I ended up making things work out by implementing a strategy like this
And in addition changing the frame of the contentView while zooming
-(void) scrollViewDidZoom:(UIScrollView *)scrollView {
CGRect cFrame = self.contentView.frame;
cFrame.origin = CGPointZero;
self.contentView.frame = cFrame;
}
These solutions all kinda work. Here is what I did, no hacks or subclasses required, with this setup:
[view]
[scrollView]
[container]
[imageView]
[imageView2]
In IB, hook up top, leading, bottom and trailing of scrollView to view.
Hook up top, leading, bottom and trailing of container to scrollView.
Hook up center-x and center-y of container to center-x and center-y of scrollView and mark it as remove on build time. This is only needed to silence the warnings in IB.
Implement viewForZoomingInScrollView: in the view controller, which should be scrollView's delegate, and from that method return container.
When setting the imageView's image, determine minimum zoom scale (as right now it will be displayed at the native size) and set it:
CGSize mySize = self.view.bounds.size;
CGSize imgSize = _imageView.image.size;
CGFloat scale = fminf(mySize.width / imgSize.width,
mySize.height / imgSize.height);
_scrollView.minimumZoomScale = scale;
_scrollView.zoomScale = scale;
_scrollView.maximumZoomScale = 4 * scale;
This works for me, upon setting the image zooms the scroll view to show the entire image and allows to zoom in to 4x the initial size.
Let say you have in storyboard "UIImageView" inside "UIScrollView" inside "UIView".
Link all constraints in "UIScrollView" with the view controller + the two constraints in UIView (Horizontal Space - Scroll View - View & Horizontal Space - Scroll View - View).
set the view controller AS delegate for the "UIScrollView".
Then implement this code:
#interface VC () <UIScrollViewDelegate>
#property (weak, nonatomic) IBOutlet UIScrollView *scrollView;
#property (weak, nonatomic) IBOutlet UIImageView *imageView;
#property (strong, nonatomic) IBOutletCollection(NSLayoutConstraint) NSArray* constraints;
#end
#implementation FamilyTreeImageVC
- (void)viewDidLoad {
[super viewDidLoad];
[self.scrollView removeConstraints:self.constraints];
}
- (UIView*)viewForZoomingInScrollView:(UIScrollView *)scrollView {
return self.imageView;
}
-(void) scrollViewDidZoom:(UIScrollView *)scrollView {
CGRect cFrame = self.imageView.frame;
cFrame.origin = CGPointZero;
self.imageView.frame = cFrame;
}
I had the same problem when trying to implement zoom from a storyboarded project using only scrollView.
I fixed it by adding a separate pinch gesture recogniser. I just dragged it from the toolbox onto my scene. Then I connected it to an action I called "doPinch" that implements the zoom. I connected it to an outlet I called "pinchRecognizer" so that I could access its scale property. This seems to override the built in zoom of the scrollView and the jumpiness disappears. Maybe it does not make the same mistake with origins, or handles that more gracefully. It is very little work on top of the layout in IB.
As soon as you introduce the pinch gesture recogniser to the scene you do need both the action and viewForZoomingInScrollView methods. Miss out either and the zooming stops working.
The code in my view controller is this:
- (UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView
{
return self.zoomableImage;
}
- (IBAction)doPinch:(id)sender
{
NSLog(#"In the pinch action now with scale: %f", self.pinchRecognizer.scale);
[scrollView setZoomScale:self.pinchRecognizer.scale animated:NO];
}
This very basic implementation does have a side effect: when you come back to a zoomed image and zoom some more the value of scale is 1.0f so it jumps back to the original scale.
You can sort this out by introducing a property "currentScale" to track the scale and set the pinch gesture recogniser scale when you start zooming again. You need to use the state property of the gesture recogniser:
- (IBAction)doPinch:(id)sender
{
NSLog(#"In the pinch action now with scale: %f", self.pinchRecognizer.scale);
NSLog(#"Gesture recognizer state is: %d", self.pinchRecognizer.state);
switch (self.pinchRecognizer.state)
{
case 1:
NSLog(#"Zoom begins, with current scale set to: %f", self.currentScale);
[self.pinchRecognizer setScale:self.currentScale];
break;
case 3:
NSLog(#"Zoom ends, with pinch recognizer scale set to: %f", self.pinchRecognizer.scale);
self.currentScale = self.pinchRecognizer.scale;
default:
break;
}
[scrollView setZoomScale:self.pinchRecognizer.scale animated:NO];
}
So this is what I managed to work out.
Here's the original with my changes:
#interface ScrollViewZoomTestViewController () <UIScrollViewDelegate>
#property (nonatomic, strong) UIImageView* imageViewPointer;
// These properties are new
#property (nonatomic, strong) NSMutableArray* imageViewConstraints;
#property (nonatomic) BOOL imageViewConstraintsNeedUpdating;
#property (nonatomic, strong) UIScrollView* scrollViewPointer;
#end
#implementation ScrollViewZoomTestViewController
- (void)viewDidLoad
{
[super viewDidLoad];
UIScrollView *scrollView = [[UIScrollView alloc] init];
UIImageView *imageView = [[UIImageView alloc] init];
[imageView setImage:[UIImage imageNamed:#"pic.jpg"]];
[self.view addSubview:scrollView];
[scrollView addSubview:imageView];
scrollView.translatesAutoresizingMaskIntoConstraints = NO;
imageView.translatesAutoresizingMaskIntoConstraints = NO;
self.imageViewPointer = imageView;
// New
self.scrollViewPointer = scrollView;
scrollView.maximumZoomScale = 2;
scrollView.minimumZoomScale = .5;
scrollView.delegate = self;
NSDictionary *viewsDictionary = NSDictionaryOfVariableBindings(scrollView, imageView);
NSLog(#"Current views dictionary: %#", viewsDictionary);
[self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:#"H:|[scrollView]|" options:0 metrics: 0 views:viewsDictionary]];
[self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:#"V:|[scrollView]|" options:0 metrics: 0 views:viewsDictionary]];
// Saving the image view width & height constraints
self.imageViewConstraints = [[NSMutableArray alloc] init];
// Constrain the image view to be the same width & height of the scroll view
[_imageViewConstraints addObjectsFromArray:[NSLayoutConstraint constraintsWithVisualFormat:#"H:|[imageView(scrollView)]|" options:0 metrics: 0 views:viewsDictionary]];
[_imageViewConstraints addObjectsFromArray:[NSLayoutConstraint constraintsWithVisualFormat:#"V:|[imageView(scrollView)]|" options:0 metrics: 0 views:viewsDictionary]];
// Add the image view constraints to the VIEW, not the scroll view
[self.view addConstraints:_imageViewConstraints];
// Flag
self.imageViewConstraintsNeedUpdating = YES;
}
So to recap here, I'm adding all of the constraints to self.view, saving the constraints set on the UIImageView in a NSMutableArray property, and setting a flag that the UIImageView constraints need updating.
These initial constraints on UIImageView work to set it up to start with. It will be the same width & height as the UIScrollView. However, this WON'T allow us to zoom the image view. It will keep it the same width / height as the scroll view. Not what we want. That's why I'm saving the constraints and setting the flag. We'll take care of that in a minute.
Now, set the view for zooming:
- (UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView
{
return self.imageViewPointer;
}
Ok, so now we need to actually allow us to zoom. I'm removing the initial UIImageView constraints and adding some new ones, this time constraining to the UIScrollView's contentSize width & height:
- (void)scrollViewWillBeginZooming:(UIScrollView *)scrollView withView:(UIView *)view
{
if(_imageViewConstraintsNeedUpdating)
{
// Remove the previous image view constraints
[self.view removeConstraints:_imageViewConstraints];
// Replace them with new ones, this time constraining against the `width` & `height` of the scroll view's content, not the scroll view itself
NSDictionary *viewsDictionary = NSDictionaryOfVariableBindings(_scrollViewPointer, _imageViewPointer);
[self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:#"H:|[_imageViewPointer(width)]|" options:0 metrics:#{#"width" : #(_scrollViewPointer.contentSize.width)} views:viewsDictionary]];
[self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:#"V:|[_imageViewPointer(height)]|" options:0 metrics:#{#"height" : #(_scrollViewPointer.contentSize.height)} views:viewsDictionary]];
self.imageViewConstraintsNeedUpdating = NO;
}
}
#end
We can't set the constraints up like this in -viewDidLoad because the image hasn't been rendered into the UIImageView yet, so UIScrollView's contentSize will be {0,0}.
This seems pretty hacky to me, but it does work, it does use pure Auto Layout, and I can't find a better way to do it. Seems to me like Apple needs to provide a better way to zoom content in a UIScrollView AND use Auto Layout constraints.