UIScrollView with sticky footer UIView and dynamic height content
Asked Answered
C

3

10

Challenge time!

Imagine we have 2 content views:

  1. UIView with dynamically height content (expandable UITextView) = RED
  2. 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.

Case recreation

Contrarious answered 11/2, 2014 at 4:4 Comment(1)
Late 2014 update: It's sad it's so hard to do this in iOS. In Android you can get this setup automatically without a single loc.Contrarious
C
5

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):

enter image description here enter image description here

enter image description here enter image description here

enter image description here

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.

Chiasmus answered 14/2, 2014 at 3:15 Comment(3)
Wow, Thanks @bilobatum! I agree without code it's impossible to solve this challenge, nevertheless I did found a way to force autolayout calculate and expand the content's view when there's more screen available. The trick I think was adding a vertical align constraint when you know there's more screen. Thanks again! I will post my solution in a repo soon too.Contrarious
@GastonM You made me rethink my assumptions about the possibilities of using a pure Auto Layout approach. I updated my answer. I'm eager to see your solution.Chiasmus
I am having the same issue with iOS 6.In case of iOS 7 my footer view sticks to bottom perfectly on scroll view but not the case with iOS 6.Please help.Digestant
T
0

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.

Teddy answered 12/2, 2014 at 17:36 Comment(0)
D
0

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

Disembodied answered 12/2, 2014 at 18:8 Comment(5)
But the space between (>=25) should be independent of the keyboard. It should expand or contract depending if it could do it or not (there's more screen height available).Contrarious
Looks I steel missing something. Main screen size always known value, keyboard frame too, so all you need is calculate white spice in moment when you know dynamic content size, to do this call setNeedLayout for Container view and in layoutSubviews adjust frames of all views. In view controller in viewDidLayoutSubviews set scrollView content size to container.bounds.Disembodied
Ok, maybe I wasn't clear enough. I don't want to calculate it by myself. I want to set the proper constraints and let autolayout calculate it. isn't this what it's for?Contrarious
Oh, looks I overlooked main thing of question:)Disembodied
No problem! Finally, I can say I did dit! Using autolayout and constraints. I have to say it IS possible, but it's really complex. I'll wait some time to encourage other people post their ideas before I post how I did it. Thanks!Contrarious

© 2022 - 2024 — McMap. All rights reserved.