Scroll Views in Xcode via AutoLayout

10 May 2017

This post is about how to get scroll views working for iOS apps.


In my last post I talked about learning using Xamarin to write Android apps in C#. Now it’s time to switch gears and talk about iOS development in Xcode!

I’m trying to rewrite my The Rise NYC app for the iPhone, with the hopes of being able to replicate what I have done in Xamarin later. I would ideally like have all my mobile projects happening in a single framework just to streamline the app development process. But before I can do that, I need to know how to navigate the Cocoa Touch API and successfully write an iOS app the Apple way.

Storyboards and AutoLayout

Developing in Xcode is a totally different experience from using Android Studio. For Android, layouts are created either using a graphical layout editor or via XML. One can expect that when working with XML, whatever you do is directly reflected in the layout editor. It is pretty simple and straightforward. You connect Activities/Fragments with layouts via code, where you can also set listeners on buttons to transition between views and dynamically update the screen via the UI thread. In Xcode, however, you’re working with what’s called storyboards. They allow you to build views and control the flow of your app’s graphical component without touching code at all, though all views and buttons can be directly linked to code using what are called outlets. While these storyboards have a representation in a markup language (which you can see by control-clicking and opening them as source code), they are not really meant to be edited in this way. It is primarily a graphical approach.

A source of both headache and relief for me (and a lot of developers it seems) has been the advent of AutoLayout, a feature of storyboards that avoids manual resizing of views for different platforms by assigning relative constraints that adjust with changes in screen size. This all seems nice, but it isn’t always easy to understand what constraints are necessary to get the view you want. It can also take a while to work out conflicting constraints, or figure out which constraints to specify when the interface builder (IB) complains some layout component is ambiguous.

In the next section I explain what I wanted to achieve in my storyboard with the help of AutoLayout. The hardest part? Getting a UIScrollView to…well, scroll.

Workout Pages

I wanted to do something in my storyboard that I thought was simple. I wanted a root view controller that had one tab to group workouts by day and another to group them by (New York City) borough. When the first tab is selected, a navigation bar displays the days of the week on top of the screen. When a day of the week is selected, the workouts happening that day get their own pages. Each page has a title, an image, the location, the day of the week and time, and the description stacked vertically. I wanted to be able to scroll vertically to read the description. To move to the next page under that day, I would have horizontal page swiping enabled. The same applies for the borough tab, but workouts are grouped by borough rather than day of the week.

So I settled on this: for organizing the workouts by days of the week, I would have a view controller called a DayViewController whose main view contains five overlapping container subviews. Each container is tied to a day of the week, and only the active container is being displayed while the rest are hidden. Each container embeds a UIPageViewController that cycles through its workout pages, each of which is stored on a subclass of UIViewController which I called LocPageViewController. The page for each workout is generated dynamically; the fields are inserted from a database using SQLite, so the content of each page varies in height, depending on primarily on the length of the description. For this reason, I inserted a UIScrollView as a subview of the main view. Within the UIScrollView was a subview containing all the fields I wanted, stacked vertically.

Lastly, the DayViewController was embedded in a UINavigationController with each day of the week in the navigation bar linking to one of the container views; and the navigation controller was embedded in a UITabBarController, which had one tab linking to the workouts grouped by days of the week, and the other linking to the workouts grouped by borough.

The LocPageViewController was a headache to get right. I just couldn’t get it to scroll! I spent a good weekend looking up how to properly get a UIScrollView working in Xcode and it was a fruitless search. Then I got it to scroll a little, but not enough to read the full description. But I finally figured it out and want to share my knowledge.

Note: I’m running Xcode 8.3.2 and iOS 10.3

Scroll Views Explained

Here’s what’s up with scroll views. As you can read elsewhere, a scroll view must only have one child view that contains all the content to scroll through. If you look at the hierarchy from my interface builder, the child view is called Container (not related to the view containers mentioned earlier) and it contains all the subviews that make up the page. These subviews are constrained by keeping them centered and fixing their distance from the leading and trailing edges of the container. In addition, the vertical distance between the stacked views is constrained; the top edge of the title is pinned near the top edge of the container; and the bottom edge of the description is pinned near the bottom edge of the container.

The scroll view is pinned to all four boundaries of the main view of the controller. The container is fixed to have the same width as the main view, and its top, leading, and trailing edges are pinned to the the scroll view. What we do not want to do, however, is pin the bottom of the container to anything. Instead, we will constrain the distance between bottom of the title and the bottom of the scroll view. As it turns out, the key to successful scrolling is to adjust this constraint to the height of our content after it is generated. Because the container is not pinned at the bottom, I believe that it simply adjusts to the height of the description field.

Past solutions to problems with scrolling have talked about manually adjusting the content size of the scroll view. The key was to extend the scrollview’s contents outside the main view in order to allow scrolling beyond its bounds, while keeping the frame inside the view. For dynamic content, we build a rectangle around the views that have been generated, and then set the content height of the scrollview to the height of this rectangle:

@IBOutlet weak var scrollView: UIScrollView! // outlet to scrollView

// create empty rectangle
var contentRect = CGRect(x:0,y:0,width:0,height:0)

// build content rectangle as a union
for subview in scrollView.subviews[0].subviews{
    contentRect = contentRect.union(subview.frame)
}

// lastly, set height of scrollView to the height of this rectangle
scrollView.contentSize.height = contentRect.size.height

However, this doesn’t work! At least not anymore. Adjusting contentSize did not fix my problem. Instead, we will adjust the value of our constraint, as mentioned earlier. This will also give us a little bit of a buffer between the bottom of the description and the bottom of the scrolling area, as rectangle we have generated includes the title itself.

Adding the constraint as an outlet

To connect the constraint from the storyboard to code, we must control-drag it from the interface builder into the code for the UIViewController subclass that holds the workout page. It will be an outlet of type NSLayoutConstraint. An object of this type has the property called constant, which represents the value we prescribed to the constraint and want to change to reflect the content of the page.

This is how I implemented this in my subclass:

// scrollView
@IBOutlet weak var scrollView: UIScrollView!

// outlet to contraint, controls scroll area
@IBOutlet weak var scrollContent: NSLayoutConstraint!

// ...
override func viewDidAppear(_ animated: Bool){
    super.viewDidAppear(animated)
    adjustContainerSize() // adjust scrollview to contents
}

// adjust container size to views
func adjustContainerSize(){
    var contentRect = CGRect(x:0,y:0,width:0,height:0)

    for subview in scrollView.subviews[0].subviews{
        contentRect = contentRect.union(subview.frame)
    }

    // let distance between pageTitle and bottom of scrollview be the height of contentRect
    // THIS IS THE KEY TO SCROLLING!
    scrollContent.constant = contentRect.size.height
}

And this will do it!

viewDidLoad versus viewDidAppear

Another extremely important point: when changing the value of the constraint, we must override the viewDidAppear function of the UIViewController subclass, rather than viewDidLoad. If the latter is done, then whatever changes we have made will be discarded. The viewDidAppear function is called just before the view is ready to be made visible onscreen. I’ve attached a nice graphic of the life cycle for a ViewController, found on this stackoverflow link.

Summary

Here are some key points for making scroll views work using AutoLayout:

  • The view controller should have one main UIView, then a UIScrollView as a subview, then a UIView as a subview of the UIScrollView (i.e. the container) to hold the main contents to be scrolled over.
  • The UIScrollView should be pinned to the main UIView.
  • The container should be pinned to the UIScrollView on the trailing, leading, and top edges, but not at the bottom.
  • A distance constraint between the top element of the container (the title in my case) and the bottom edge of the UIScrollView should be created, and the value should be changed to the height of the content via an outlet. You can adjust the value if you would like to leave more/less padding at the bottom.

I hope this works for you!

Leave a Comment