Vertical Paging Without Limitations

Vertical Paging Without Limitations

The Soapbox app for iOS provides a fun, simple way to capture moments, to share them with the world, and to help promote items and causes. Soapbox emphasizes the ability to monetize a social presence, too. You can follow your friends and discover brilliant creators from all over the world who are sharing the amazing content you love.

While developing the app, though, we encountered a number of technical challenges that we needed to overcome in order for the app to work the way it was envisioned to work. We’ll talk about one in particular in this post.

The Challenge

Soapbox is a content-centric app that’s built around a newsfeed. However, there are certain features of a traditional newsfeed implementation that were not quite in line with the experience we wanted users to have:

  1. A newsfeed as implemented in an app like Facebook offers a continuous scrolling experience, but we wanted users to be able to swipe up or down to snap directly to the next (or previous) post in the feed.
  2. We wanted only one newsfeed post to appear on the screen at a time — even if the post did not fill the whole screen.
  3. We also wanted posts to be of any length, so a post might actually extend beyond a single screen (and you would be able to scroll down to see more content by dragging).
  4. We wanted users to be able to swipe between posts in order to move posts around like cards.

The question, then, was how to achieve these user experience goals.

The Solution

The first idea for a solution to these challenges was to use a UITableView with isPagingEnabled set to true. But that proved not to be a viable option, because UITableView requires pages to be the same size. We surfed the web in search of a ready-to-use solution and found nothing – so, we decided to build our own control, one that would be fully customizable and that would meet all of our needs. That control we’re calling CardScrollView.

Creating CardScrollView

Because we wanted Soapbox to have a scrolling content feed, we decided to use UIScrollView as the basis for our new control. UIScrollView actually implements the underlying scrolling logic and exposes all the APIs needed to control the scrolling process. But what about the content inside the UIScrollView? One thing that had been attractive about UITableView was that it manages memory by itself and does a great job reusing cells. So how could we do that for CardScrollView?

We thought back to a conversation held at the 2010 Apple Worldwide Developers Conference. There, Apple engineers talked about Designing Apps with Scroll Views. A very important part of this talk explained how to reuse views in UIScrollView through the implementation of an Object Pool Pattern. That was it! That was the approach we needed to take for CardScrollView.

Setting Up the Framework

To make CardScrollView reusable, we needed to set it up as a framework. That required the creation of a new Xcode project built upon the Cocoa Touch Framework.

App development
By setting this up first, we would have a ready-to-use framework binary. Then, we began to write some code!

Implementing the Framework

For the CardScrollView control, we needed two classes:

  • A class representing the cell (we call it a card)
  • A scroll view itself (we call it CardScrollView)

Implementing the cell class is pretty straightforward, so here we’ll focus on how we implemented the second of these classes, the CardScrollView.

Building the CardScrollView Class

First of all, we needed to implement the cards’ layout logic and get it all working together. We needed to keep a pool of reusable views, with each view representing a single card. To make it possible to use different views for different kind of data, we introduced a reuse identifier which works the same as reuse identifier in UITableView.1Note: To make control user friendly and easy to use, we tried to keep the naming conventions very close to those that Apple has for UITableView. So, a lot of methods are called in a similar way.

public func dequeueCard(withIdentifier reuseIdentifier: String) -> CardScrollViewCard {
    guard self.reuseIdentifiers[reuseIdentifier] != nil else { fatalError("Identifier \(reuseIdentifier) is not registered") }
    guard let reusedcard = self.reuseCard(reuseIdentifier: reuseIdentifier) else {
        let cardObject = self.reuseIdentifiers[reuseIdentifier]
        switch cardObject {
        case let nib as UINib:
            guard let unwrappedView = nib.instantiate(withOwner: nil, options: nil).first as? CardScrollViewCard else { fatalError("Expected to receive \(String(describing: CardScrollViewCard.self)) but got something else") }
            return unwrappedView
        case let classObject as CardScrollViewCard.Type:
            return classObject.init(reuseIdentifier: reuseIdentifier)
        default:
            fatalError("card reuse identifier is not registered")
        }
    }
    return reusedcard
}

fileprivate func reuseCard(reuseIdentifier: String) -> CardScrollViewCard? {
    guard self.reuseIdentifiers[reuseIdentifier] != nil else { fatalError("Identifier \(reuseIdentifier) is not registered") }
    var resultingcard: CardScrollViewCard? = nil
    let availablecards = self.reusePool.filter { $0.reuseIdentifier == reuseIdentifier }
    if availablecards.count > 0 {
        resultingcard = availablecards.first
        let index = self.reusePool.index { $0 == resultingcard } guard let unwrappedIndex = index else { fatalError("Internal inconsistency error") }
        self.reusePool.remove(at: unwrappedIndex)
    }
    resultingcard?.prepareForReuse(willCollapse: self.collapsecardsWhenHidden)
    return resultingcard
}

In this implementation, reuseIdentifiers is a dictionary that matches a reuse identifier with a representing object and reusePool is defined as an array.
For better understanding of how this works, let’s take a look at the implementation of two methods below:

public func register(class aClass: CardScrollViewCard.Type, forReuseIdentifier reuseIdentifier: String) {
    guard self.reuseIdentifiers[reuseIdentifier] == nil else { fatalError("Reuse Identifier \(reuseIdentifier) is already registered for \(self.reuseIdentifiers[reuseIdentifier])") }
    self.reuseIdentifiers[reuseIdentifier] = aClass
}

public func register(nib: UINib, forReuseIdentifier reuseIdentifier: String) {
    guard self.reuseIdentifiers[reuseIdentifier] == nil else { fatalError("Reuse Identifier \(reuseIdentifier) is already registered for \(self.reuseIdentifiers[reuseIdentifier])") }
    self.reuseIdentifiers[reuseIdentifier] = nib
} 

As you can see, when registering a NIB object or Class Type as a reuse identifier we simply put it into a dictionary for the future use. That’s it!

This implementation assumes that any view in reusePool is available for reuse, and it’s currently being used to display a card.

Gathering and Caching Card Data

Now, let’s take a look at how views are added to the reuse pool and how we manage the visibility state.

To manage the layout of our cards inside scroll view, we need to know their height and location in advance. So, we built a method that collects this information and caches it for future use:


fileprivate func setUpHeightAndOffsetData() {
    var currentOffset: Float = 0.0
    let numberOfCards = self.cardScrollViewDataSource?.numberOfCards() ?? 0
    var cardsDetails: [CardDetails] = []
    for i in 0..<numberOfCards {
        var estimatedCardHeight = self.cardScrollViewDelegate?.cardCollection(cardsCollection: self, estimatedHeightForCardAtIndex: i) ?? 0
        if estimatedCardHeight < self.minimalCardHeight {
            estimatedCardHeight = self.minimalCardHeight
        } 
        cardsDetails.append(CardDetails(startPositionY: currentOffset, height: estimatedCardHeight)) 
        currentOffset += estimatedCardHeight
    }
 
    self.cardsDetails = cardsDetails
    self.contentSize = CGSize(width: 0, height: CGFloat(currentOffset))
}

This code builds an array of CardDetails instances that capture the parameters we will need to perform a proper layout. A CardDetails is a struct that records the starting position of a card, its height, and a pointer to the presenting card if it’s visible. As the code shows, we limit the minimum height of the card to the scroll view height, so our cards will always have a height equal to or greater than scroll view height.

Laying Out the Cards

Having collected all the required data in one array, the process of laying out the cards becomes pretty straightforward. For each card that is visible (or is about to become visible) we calculate it’s Y coordinate and add it as a subview to a scroll view. When we are done laying out cards, the next step is to recycle those cards that are no longer visible:


fileprivate func layoutCards() {
    let currentStartY: Float = Float(self.contentOffset.y)
    let currentEndY: Float = currentStartY + Float(self.bounds.size.height)
    guard var cardIndexToDisplay = self.cardIndex(yOffset: currentStartY, inRange: 0..<self.cardsDetails.count) else {
        //nothing to layout return
    }
    var newVisibleCards: Set = Set() let xOrigin: Float = 0
    var yOrigin: Float = 0 var cardHeight: Float = 0
    repeat {
        guard let card = self.prepareCard(atIndex: cardIndexToDisplay) else { fatalError("Could not get a card") }
        newVisibleCards.insert(cardIndexToDisplay)
        self.cardsDetails[cardIndexToDisplay].cachedard = card
        yOrigin = self.cardsDetails[cardIndexToDisplay].startPositionY
        cardHeight = self.cardsDetails[cardIndexToDisplay].height
        card.frame = CGRect(x: CGFloat(xOrigin), y: CGFloat(yOrigin), width: CGFloat(self.bounds.size.width), height: CGFloat(cardHeight))
        self.addSubview(card)
        cardIndexToDisplay += 1
    } while yOrigin + cardHeight < currentEndY && cardIndexToDisplay < self.cardsDetails.count
    self.recycleNotVisibleCards(withVisibleCardsIndexes: newVisibleCards)
}

There is one important thing to keep in mind about this method: It’s going to be called a lot — every time content is scrolled, every time the device is rotated, or any time a similar action occurs. To achieve the responsiveness we wanted, we implemented a didSet observer for the contentOffset property of scroll view:


override open var contentOffset: CGPoint {
    didSet {
        self.layoutCards()
    }
}

That’s all the work we needed to do to ensure proper card layout and reuse!

Handling the Scrolling

But what about the scrolling logic?

CardScrollView is inherited from UIScrollView. So, to gain control over the scrolling process, we need to set self as a UIScrollViewDelegate and implement the following delegate methods:

  • scrollViewWillBeginDragging – to track the beginning of the dragging process
  • ScrollViewWillEndDragging – to set up the correct target content offset
  • scrollViewDidEndDragging – to adjust the content offset in case a user tries a drag-and-release motion instead of swiping
  • scrollViewDidEndDecelerating – to handle corner cases

The scrollViewWillBeginDragging and scrollViewDidEndDecelerating methods are responsible for tracking the content offset and retaining it as an internal variable. The work of setting up the scrolling offset itself is primarily done by the scrollViewWillEndDragging method.

To put this all in play, the first thing we need to do is determine a scroll direction. That’s easy to discover if we have two distinct offsets — a target content offset (which we call targetContentOffset) and a current offset (which we call lastContentOffset). By subtracting the one from the other we get a delta value, which we call scrollDelta:


let scrollDelta = Float(targetContentOffset.pointee.y - self.lastContentOffset.y)
let scrollDirection = scrollDelta > 0 ? ScrollDirection.down : ScrollDirection.up

This actually provides all the data we need to calculate which cell index will be visible next. If the scrollDelta value is greater than zero, the next cell will be focused; if less than zero, the previous cell will be focused.

Finally, there is one more consideration: If a card height is more than minimum allowed (which is CardScrollView height), then we need to attach to its top or bottom edge, depending on the scroll direction. This is easily done by changing offset by height delta:


if abs(scrollDelta) > self.scrollThreshold {
    switch scrollDirection {
    case .down:
        cardIndexToFocus = secondCardIndex
    case .up:
        cardIndexToFocus = firstCardIndex
        offsetAdjustent = Float(abs(CGFloat(self.cardsDetails[cardIndexToFocus].height) - scrollView.bounds.height))
    }
}
else {
    switch scrollDirection {
    case .down:
        cardIndexToFocus = firstCardIndex
        offsetAdjustent = Float(abs(CGFloat(self.cardsDetails[cardIndexToFocus].height) - scrollView.bounds.height))
    case .up:
        cardIndexToFocus = secondCardIndex
    }
}
targetContentOffset.pointee.y = CGFloat(self.cardsDetails[cardIndexToFocus].startPositionY + offsetAdjustent)

By testing to see whether a given card is longer than the card height, this bit of code ensures that a down swipe gesture actually scrolls down into the next portion of card content rather than jumping to the next card. Once a user reaches the bottom of the card, a down swipe jumps to the next card. Conversely, an up swipe from the bottom of a long card would scroll backwards in the card and before jumping to the previous card.

Handling the Edge Cases

This code will cover 90% of scrolling cases. The remaining 10% are edge cases that require special attention:

  1. User dragged instead of swiping up/down. In this case, there will be no deceleration animation and the code above will not perform its job because the target content offset is the same as last content offset, and the scroll delta will equal 0.
  2. When scrolling content with large cards. In this instance, it’s hard to calculate which cards will be on the screen and how to adjust them.

To handle the first edge case, we need to perform layout calculations when the user finishes dragging (which requires only the use of the scrollViewDidEndDragging method). To handle the second edge case we need to do the same in scrollViewDidEndDecelerating. This approach makes scrolling animations smoother and provides a “live” experience.

Summary

There’s more to the CardScrollView framework than we’ve shown here, but this is the essence of it. What we have not shown is mostly housekeeping stuff. You can find the full source, along with completely documented code and usage examples, on GitHub. The framework supports CocoaPods and Carthage.

Development of the CardScrollView framework is ongoing, so if you have any questions or suggestions, feel free to contact us — or, create a Pull Request on GitHub if you have something cool to introduce!

Got an idea for a great new app? Contact Distillery about turning that idea into a marketable reality!

For More Info

See the articles on…

References   [ + ]

1. Note: To make control user friendly and easy to use, we tried to keep the naming conventions very close to those that Apple has for UITableView. So, a lot of methods are called in a similar way.