Writing a Tabbed Container View Controller

One of the things I’ve focused on this year is improving the navigation within our app. Making the class menu easier to open, adding a split view controller for iPad, and simplifying the process of setting up navigation during app launch have improved the experience for both our users and developers working in the code. Most recently, I worked on a view controller container that enables switching between the Messages and People screens with a quick swipe rather than requiring the title to be tapped (and then abruptly changing the view). Swiping between the views feels great and works the way users expect it should.

The core pieces of the puzzle are the tab container itself, a scroll view subclass, and a control with buttons for showing the child view controller titles. Grab the sample code here and follow along.

The Tab Container

When taking on a task like this, it can be helpful to start with an empty project, get everything working, then integrate back to the main app. This enables fast iteration through reduced compilation times and avoids inadvertently relying on any app-specific behavior that could affect layout or appearance.

Taking inspiration from UITabBarController’s interface, RDTopTabBarController defines the method setViewControllers(_:animated:). It first removes any existing child view controllers, then adds the new ones as children and adds their views to the scroll view. Apple’s container view controller guide provides clear instructions for adding and removing child view controllers and has tons of great information. RDMainViewController creates an array of three table view controllers to drop into the new tab bar controller. Running the code shows only one of the tables – now they need to be layed out properly in the scroll view.

The Scroll View

The scroll view’s responsibility is to lay out the child view controllers’ views side by side such that they can be paged through by swiping. To do this, the subclass sets isPagingEnabled in its initializer and overrides layoutSubviews to size the views to the same size as its bounds, then places them in order from left to right. The scroll view also updates its contentSize in layoutSubviews so that the size of its scrollable area stays updated. RDTabScrollView has a pagedViews property used just to keep track of the child view controllers’ views, rather than rely on the subviews property because the scroll view also contains scroll indicators and other custom views. At this point, swiping left and right between the table view controllers works - all that’s needed is a control for displaying titles and switching views.

The Tab Control

The tab control lives at the top of the tab view controller, with a button for each child view controller and an indicator view that slides left and right under the title of the view controller currently being shown. Its setTitles(_:) method creates buttons with the passed-in titles. The first piece of functionality to implement is tapping a button to scroll to the corresponding view.

RDTabControl tells its delegate when a button is pressed using the method tabControl(_:didSelectButtonAt:). RDTopTabBarController uses this method to animate scrolling to the corresponding page. In the implementation, the child view controller is retrieved using the index, then the frame of its view is used when calling scrollRectToVisible(_:animated:).

Moving the indicator view as the scroll view scrolls requires information to flow in the opposite direction, from the scroll view to the tab control. The scroll view delegate method scrollViewDidScroll(_:) is used to update the indicator any time the scroll view moves. The x-position of the indicator in the tab control is proportional to the content offset of the scroll view divided by the width of the scroll view content: scrollView.bounds.width * (scrollView.contentOffset.x / scrollView.contentSize.width). When the indicator moves, the button of the most visible view is darkened. Using the computed variable mostVisibleIndex on RDTabScrollView, which finds the view with the origin closest to the content offset, the state of the corresponding button is updated.

In order to make the display of the tab control more flexible, it is embedded in a UIStackView, which makes it easy to show or hide the buttons while taking care of all the resizing. The stack view can also be used to show a view underneath the tab control, which the Remind app uses for banners and Internet-connectivity status bars.

The Little Details

At this point, when the view appears, the table views appear as if they are scrolled up slightly because the tab control covers the top row. Although the content inset of each of the child view controllers is set in viewDidLayoutSubviews, allowing the tables to be scrolled up to see their content, this doesn’t reposition the content down. To correct this, the content offset of each child view controller’s view is set when adding them as children.

Another much more aggravating problem that needed to be solved was the behavior of the scroll view and tab control when the device was rotated. The content offset remained the same throughout the transition, leaving the scroll view between pages. It didn’t look good:

What’s happening is that before the rotation, each page is 320 points wide and contentOffset.x is 640 points. After the rotation, the horizontal offset needs to be 1136 points to line up with the third page, because each page width grows to 568 points. Initially, I used viewWillTransition(size:coordinator:) to do some messy layout operations, but after further investigation, devised a simpler solution. Two scroll view delegate methods are used to keep the scroll view’s currentPage property in sync with the visible view. Whenever the scroll view finishes dragging or a scroll animation completes because of a button press the delegate methods scrollViewDidEndDecelerating(_:) and scrollViewDidEndScrollingAnimation(_:) update the page. The currentPage property is used in layoutSubviews to set the content offset to line up at the edge of the visible page. Only when the width of the scroll view changes is the content offset set, otherwise scrolling wouldn’t work because layoutSubviews is called whenever scrolling begins. Much better:

Remind’s app has an drawer on the left side of the screen that can be swiped open. In order to allow that gesture recognizer to begin, the scroll view’s bouncing needs to be disabled, otherwise it will intercept the pan gesture, never allowing it to get to the drawer. Instead of disabling all bouncing with UIScrollView’s bounces property, RDTabScrollView overrides gestureRecognizerShouldBegin(_:) and uses its allowedBounceEdges property to disable the scroll gesture when at the edge, causing the gesture to be passed up to the drawer container. This maintains the bounce on the opposite side of the screen.

Finally, adding the tab controller to a navigation controller is an important use case for handling selection of rows, then pushing a detail view controller. The navigation bar and the tab bar buttons need to appear like a single view. Apple provides a great sample project with examples of various navigation bar customizations here. Setting the navigation bar’s isTranslucent to false, its shadow image to a transparent pixel, and its background image to a solid white pixel results in a seamless look.

Conclusion

Writing this container view controller was, for the most part, straightforward. However, as with any generalized component, getting the details right while maintaining a simple interface and implementation can be the most time-consuming part of the endeavor. Hopefully by documenting the issues I encountered, I’ll save others some time and provide a glimpse into the development process. Thanks for reading!