Challenge time!
Imagine we have 2 content views:
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.
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
.