Build TikTok: Create a smooth scrolling video feed in Swift on iOS

With these magic tricks you can build a world-class iOS App

How do they get their scrolling to feel so good??

Most apps target 60 frames per second to keep the scroll feeling smooth, which is equivalent to only 16.67ms per frame!

Playing audiovisual content within this time constraint is extremely challenging. A simple solution using just AVKit and UICollectionView won’t cut it, as there isn’t enough time to download the video by the time the user has scrolled to it. A naïve solution will also often hijack the main thread to do data operations, leading to even frozen UI.

Suggested Libraries

Luckily, you won’t need to write all this performant code from scratch. There are three key libraries that I use to simplify the task of prefetching and intelligent cell reuse.

IGListKit

Kingfisher

import Kingfisher

let url = URL(string: "https://example.com/image.png")
imageView.kf.setImage(with: url)

CachingPlayerItem

The most difficult part of prefetching is the downloading and storing of video data. AVFoundation really only allows two modes: playing from an asset you already have on disk, or streaming the asset from the server. The challenge starts when you want to do both: How do you start downloading the asset, and when the user scrolls to it, start streaming from the data you already have?

Implementing Smooth Scrolling

Now that you are familiar with the building block common libraries for smooth scrolling, let’s combine them all and get some data loaded in!

Using Working Ranges to Prefetch

The simple idea is to make sure by the time the user has scrolled to a cell, all the content has already been downloaded. To do so, we’ll hook into the working range delegate that IGListKit surfaces. The working range is an area near the viewport of content that is about to appear on the screen. By defining the “working range”, a certain number of cells ahead of the current view port will get callbacks that they are about to show up on screen.

Using working ranges allows for prefetching media
// MARK: - ListWorkingRangeDelegate    func listAdapter(_ listAdapter: ListAdapter, sectionControllerWillEnterWorkingRange sectionController: ListSectionController) {  // Prefetch HERE}        func listAdapter(_ listAdapter: ListAdapter, sectionControllerDidExitWorkingRange sectionController: ListSectionController) {  // Clean up here            }

Prefetch Images

Whether your feed has a mix of photos and videos or is just videos, you’ll always need image prefetching for video thumbnails. I usually kickoff video thumbnail prefetching at the same time as video prefetching.

func prefetch(urlToPrefetch: URL) {
let prefetcher = ImagePrefetcher(urls: [urlToPrefetch]) {
skippedResources, failedResources, completedResources in
}
prefetcher.start()
}

Prefetch Videos

Prefetching videos, and then saving them to a cache, is more challenging because AVAsset has no way to save directly to disk. A video app usually starts by creating a AVURLAsset and then playing it inside of an AVPlayerLayer . This works well to play a single video, but runs into issues because it can’t be stored. This will necessitate loading before playing, which is what we want to avoid.

Error Domain=AVFoundationErrorDomain Code=-11838 "Operation Stopped" UserInfo={NSLocalizedFailureReason=The operation is not supported for this media.
let playerItem = CachingPlayerItem(url: videoURL)
player = AVPlayer(playerItem: playerItem)
player?.automaticallyWaitsToMinimizeStalling = false

Building a Video Cache

We’ll be storing the data that our CachingPlayerItem has prefetched. For this, a simple in-memory singleton cache wrapping NSCache should suffice. For longer term storage you can write through to disk, but I find that the latency increase isn’t worth it.

public class YourAppMediaCache: NSObject {
static let sharedInstance = YourAppMediaCache()
let memCache = NSCache<NSString, NSData>()
public func cacheItem(_ mediaItem: Data, forKey key: String) {
memCache.setObject(mediaItem as NSData, forKey: key as NSString)
}

public func getItem(forKey key: String) -> Data? {
return memCache.object(forKey: key as NSString) as Data?
}
}
if let videoData = YourAppMediaCache.sharedInstance.getItem(forKey: key) {
playerItem = CachingPlayerItem(data: videoData as Data, id: key, mimeType: "video/mp4", fileExtension: "mp4")
} else {
playerItem = CachingPlayerItem(url: URL(string: key)!, id: key)
}
func prefetch(urlToPrefetch: URL) {
// Previous image thumbnail caching logic here

let key = urlToPrefetch.absoluteString
if YourAppMediaCache.sharedInstance.getItem(forKey: key) == nil {
let playerItem = CachingPlayerItem(url: urlToPrefetch)
playerItem.download()
}

Conclusion

Every good iOS app has a good foundation of data management. If you take the time now to improve the scrolling performance of your app using libraries like IGListKit, you’ll find that the app is able to scale and stay performant as you grow. Good luck!

Lessons learned from building iOS apps at scale. Twitter: @nickconfrey