Build a Tiktok-like Feed
This guide shows you how to create a smooth scrolling feed known from the popular short form video sharing App TikTok.
Smooth Scrolling Feed
To build the perfect viewing experience for a feed full of reactive videos, you would want to avoid blocking any expensive operation on your main thread.
This mostly applies to UIs that implement infinite scrolls like the one you know from TikTok. This feed of neverending videos additionally contains images, animations and text and needs to stay responsive to touches at any time. Without proper thread handling scrolling through a UI like that can result in massive frame drops especially for older devices.
In accordance to this we are making use of the open source framework Texture. Built by Facebook, Textures uses CALayer
abstractions that are not blocking the main thread for UI layouts.
From texturegroup.org:
Texture’s basic unit is the node.
ASDisplayNode
is an abstraction over UIView, which in turn is an abstraction overCALayer
. Unlike views, which can only be used on the main thread, nodes are thread-safe: you can instantiate and configure entire hierarchies of them in parallel on background threads.
Another important part is the preloading/precaching of videos that are about to play if the user scrolls throgh the feed really quick.
For this reason, we are providing VKPlayersManager
. VKPlayersManager is a helper class responsible for fetching videos from our backend. It is also able to prepare them to be played instantly by initializing a video player view already.
Table Concept
Texture comes with a Table View (ASTableNode
) with paging functionality that makes it possible to re-create the vertical full screen video view from TikTok.
As you can see, ASTableNode
is able to preload the UI as shown in the figure in green. In addition to that, our VKPlayersManager
is additionally preparing a player instance to preload the corresponding video to show. The combination of Texture and VideoKit enables a non blocking instant display of videos in a fast scrolling feed:
Read more on how Texture and it's Preloading through Table Nodes work here and here.
Get Started
Refer to our Getting Started Guide on how to install VideoKit into your project.
Import VideoKitPlayer and VideoKitcCore in your ViewController
import VideoKitPlayer
import VideoKitCore
import AsyncDisplayKit
Initialize Session with our VideoKit Backend
Add the following code to your AppDelegates didFinishLaunchingWithOptions method.
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
VKSession.current.start(
apiToken: "your-api-token-from:dashboard.video.io",
identity: UUID().uuidString) { (sessionState, sessionData, error) in
if let error = error {
print("Session error.")
print(error.localizedDescription)
return
}
print("Session initialization successful.")
}
return true
}
Make sure that you enter the corresponding api token for your App taken from dashboard.video.io.
Video Data Source
Create a new data source component that is responsible for retrieveing subsets of videos from video.io.
//
// AllVideosFeedDataSource.swift
// VideoKitTikTokFeed
//
// Created by Dennis Stücken on 11/12/20.
//
import Foundation
import VideoKitCore
protocol FeedDataSource {
func loadNextVideos(currentPage: Int, completion: @escaping ([VKVideo]) -> Void)
func hasMoreVideos(currentPage: Int) -> Bool
}
extension FeedDataSource {
func hasMoreVideos(currentPage: Int) -> Bool {
return true
}
}
class AllVideosFeedDataSource: FeedDataSource {
var hasMoreVideos: Bool = true
func loadNextVideos(currentPage: Int, completion: @escaping ([VKVideo]) -> Void) {
_ = VKVideoAPI.shared.videos(byTags: [], metadata: [:], page: currentPage, perPage: 10) { [weak self] (response, error) in
guard self != nil else { return }
if let error = error {
print(error.localizedDescription)
} else if let response = response as? VKVideosResponse {
if response.totalCount == 0 {
self?.hasMoreVideos = false
}
completion(response.videos)
}
}
}
func hasMoreVideos(currentPage: Int) -> Bool {
return hasMoreVideos
}
}
View Controller
Add the following code to your main view controller.
//
// ViewController.swift
// VideoKitTikTokFeed
//
// Created by Dennis Stücken on 11/11/20.
//
import UIKit
import VideoKitPlayer
import VideoKitCore
import AsyncDisplayKit
class ViewController: UIViewController {
var currentPage = 1
var shouldPlay = true
var tableNode: ASTableNode!
var currentActiveVideoNode: VideoNode?
var videoDataSource: FeedDataSource = AllVideosFeedDataSource()
var playlist = VKCustomPlaylist(videos: [])
var playersManager = VKPlayersManager(prerenderDistance: 3, preloadDistance: 10)
override func viewDidLoad() {
super.viewDidLoad()
self.view.backgroundColor = .black
self.tableNode = ASTableNode(style: .plain)
self.tableNode.delegate = self
self.tableNode.automaticallyAdjustsContentOffset = false
self.tableNode.leadingScreensForBatching = 2.0;
self.tableNode.allowsSelection = false
self.tableNode.insetsLayoutMarginsFromSafeArea = true
self.tableNode.view.contentInsetAdjustmentBehavior = .never
// Add tablenodes view as a subview to current view
self.view.addSubview(self.tableNode.view)
// Table node styling
self.tableNode.view.backgroundColor = .black
self.tableNode.view.separatorStyle = .none
self.tableNode.view.isPagingEnabled = true
self.tableNode.view.showsVerticalScrollIndicator = false
self.tableNode.contentOffset = .zero
self.tableNode.contentInset = .zero
// Make table node view full screen
self.tableNode.view.translatesAutoresizingMaskIntoConstraints = false
self.tableNode.view.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true
self.tableNode.view.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true
self.tableNode.view.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
self.tableNode.view.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 0).isActive = true
// Setup delegates
self.playersManager.delegate = self
// Wait until VideoKit's session is initialized, then set datasource and load videos
NotificationCenter.default.addObserver(self, selector: #selector(self.sessionStateChanged(_:)), name: .VKAccountStateChanged, object: nil)
// Or set it now in case session is already connected
if VKSession.current.state == .connected {
tableNode.dataSource = self
}
}
@objc func sessionStateChanged(_ notification: NSNotification? = nil) {
DispatchQueue.main.async {
if VKSession.current.state == .connected {
self.tableNode.dataSource = self
}
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// Play current active video
if shouldPlay, let currentActiveVideoNode = currentActiveVideoNode {
currentActiveVideoNode.play()
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
shouldPlay = currentActiveVideoNode?.isPlaying() ?? false
tableNode.visibleNodes.forEach({ ($0 as? VideoNode)?.pause() })
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
}
}
extension ViewController {
func retrieveNextPageWithCompletion(block: @escaping ([VKVideo]) -> Void) {
videoDataSource.loadNextVideos(currentPage: currentPage) { (videos) in
if videos.count > 0 {
self.currentPage += 1
DispatchQueue.main.async {
block(videos)
}
}
}
}
func insertNewRowsInTableNode(videos: [VKVideo]) {
guard videos.count > 0 else {
return
}
self.tableNode.performBatchUpdates({
let indexPaths = (0..<videos.count).map { index in
IndexPath(row: index, section: 0)
}
playlist.add(videos: videos)
playersManager.setPlaylist(self.playlist)
if indexPaths.count > 0 {
self.tableNode.insertRows(at: indexPaths, with: .none)
}
})
}
}
extension ViewController: PlayerNodeDelegate {
func requestPlayer(forVideo video: VKVideo, completion: @escaping VKPlayersManager.PlayerRequestCompletion) {
playersManager.getPlayerFor(videoId: video.videoID, completion: completion)
}
func releasePlayer(forVideo video: VKVideo) {
playersManager.releasePlayerFor(id: video.videoID)
}
}
extension ViewController: ASTableDataSource {
func numberOfSections(in tableNode: ASTableNode) -> Int {
return 1
}
func tableNode(_ tableNode: ASTableNode, numberOfRowsInSection section: Int) -> Int {
return playlist.count
}
func tableNode(_ tableNode: ASTableNode, nodeBlockForRowAt indexPath: IndexPath) -> ASCellNodeBlock {
guard self.playlist.count > indexPath.row else { return { ASCellNode() } }
let video = self.playlist.videoAt(indexPath.row)!
return {
print("Video: \(video.videoID)")
let node = VideoNode(with: self.playlist.videoAt(indexPath.row)!)
node.delegate = self
node.style.preferredSize = tableNode.calculatedSize
return node
}
}
}
extension ViewController: ASTableDelegate {
func shouldBatchFetch(for tableNode: ASTableNode) -> Bool {
if !videoDataSource.hasMoreVideos(currentPage: currentPage) {
return false
}
return true
}
func tableNode(_ tableNode: ASTableNode, willBeginBatchFetchWith context: ASBatchContext) {
retrieveNextPageWithCompletion { (videos) in
self.insertNewRowsInTableNode(videos: videos)
context.completeBatchFetching(true)
}
}
func tableNode(_ tableNode: ASTableNode, willDisplayRowWith node: ASCellNode) {
if let vNode = node as? VideoNode, let indexPath = vNode.indexPath {
playersManager.setPlaylistIndex(indexPath.row)
vNode.play()
currentActiveVideoNode = vNode
}
}
func tableNode(_ tableNode: ASTableNode, didEndDisplayingRowWith node: ASCellNode) {
if let vNode = node as? VideoNode {
vNode.pause()
}
}
}
extension ViewController: VKPlayersManagerProtocol {
public func vkPlayersManagerNewPlayerCreated(_ manager: VKPlayersManager, _ player: VKPlayerViewController) {
// Setup video player
player.aspectMode = .resizeAspectFill
player.showControls = false
player.showSpinner = true
player.showErrorMessages = false
player.loop = true
}
}
VideoNode
Create a new class called VideoNode. VideoNode
is going to represent a table node playing the video for each post inside of the feed.
import AsyncDisplayKit
import VideoKitCore
protocol VideoNodeDelegate {
func requestPlayer(forVideo video: VKVideoObject, completion: @escaping (VKPlayerViewController) -> Void)
func releasePlayer(forVideo video: VKVideoObject)
}
class VideoNode: ASCellNode {
var video: VKVideoObject
var delegate: VideoNodeDelegate? {
didSet {
playerNode.delegate = delegate
}
}
var playerNode: PlayerNode
init(with video: VKVideoObject) {
self.video = video
self.playerNode = PlayerNode(video: video)
super.init()
self.addSubnode(self.playerNode)
}
func getThumbnailURL(video: VKVideoObject) -> URL? {
return video.thumbnailImageURL
}
func isPlaying() -> Bool {
return playerNode.isPlaying()
}
func play() {
playerNode.play()
}
func pause() {
playerNode.pause()
}
func mute() {
playerNode.mute()
}
func unmute() {
playerNode.mute()
}
@objc func overlayTapped() {
self.postOverlayView?.togglePlayBtn(self.playerNode.isPlaying())
self.playerNode.togglePlayback()
}
}
Player Node
Our player node is in charge of playing the video in each table view cell using VKPlayerViewController
from VideoKit/Player.
//
// PlayerNode.swift
// VideoKitTikTokFeed
//
// Created by Dennis Stücken on 11/13/20.
//
import AsyncDisplayKit
import VideoKitPlayer
import VideoKitCore
protocol PlayerNodeDelegate {
func requestPlayer(forVideo video: VKVideo, completion: @escaping VKPlayersManager.PlayerRequestCompletion)
func releasePlayer(forVideo video: VKVideo)
}
class PlayerNode: ASDisplayNode {
var delegate: PlayerNodeDelegate?
var video: VKVideo
var player: VKPlayerViewController?
var shouldPlay: Bool = false
init(video: VKVideo) {
self.video = video
}
override func didEnterVisibleState() {
requestPlayer()
}
override func didEnterDisplayState() {
requestPlayer()
}
override func didEnterPreloadState() {
releasePlayer()
}
func requestPlayer() {
guard player == nil else { return }
delegate?.requestPlayer(forVideo: self.video) { [weak self] (player, error) in
DispatchQueue.main.async { [weak self] in
guard let `self` = self else { return }
guard self.player == nil else { return }
self.player = player
self.player!.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
self.player!.view.clipsToBounds = true
self.view.addSubview(self.player!.view)
if (self.shouldPlay) {
self.play()
}
}
}
}
func releasePlayer() {
guard self.player != nil else { return }
DispatchQueue.main.async { [weak self] in
guard let `self` = self else { return }
guard let player = self.player else { return }
player.pause()
player.removeFromParent()
self.player = nil
self.delegate?.releasePlayer(forVideo: self.video)
}
}
func isPlaying() -> Bool {
return player?.playState == .playing
}
func play() {
if let player = self.player {
player.play()
} else {
shouldPlay = true
}
}
func pause() {
player?.pause()
shouldPlay = false
}
func togglePlayback() {
guard let player = self.player else { return }
player.playState == .playing ? pause() : play()
}
func mute() {
player?.muted = true
}
func unmute() {
player?.muted = false
}
}
Source Code
If you prefer to take a look at the working project, here is the full project source code:
Download SourceGithub