Introduction
Loading video thumbnails asynchronously in a UITableView is essential for a smooth scrolling experience. Extracting a thumbnail from a video URL is a heavy operation that involves downloading video metadata over the network, so it must be done off the main thread. This article covers how to generate thumbnails from video URLs using AVAssetImageGenerator, cache them for performance, and display them correctly in a table view.
Generating a Thumbnail from a Video URL
Use AVAssetImageGenerator to extract a frame from a video:
1import AVFoundation
2import UIKit
3
4func generateThumbnail(from url: URL, completion: @escaping (UIImage?) -> Void) {
5 DispatchQueue.global(qos: .userInitiated).async {
6 let asset = AVURLAsset(url: url)
7 let generator = AVAssetImageGenerator(asset: asset)
8 generator.appliesPreferredTrackTransform = true
9 generator.maximumSize = CGSize(width: 320, height: 180)
10
11 let time = CMTime(seconds: 1.0, preferredTimescale: 600)
12
13 do {
14 let cgImage = try generator.copyCGImage(at: time, actualTime: nil)
15 let thumbnail = UIImage(cgImage: cgImage)
16 DispatchQueue.main.async {
17 completion(thumbnail)
18 }
19 } catch {
20 print("Thumbnail generation failed: \(error)")
21 DispatchQueue.main.async {
22 completion(nil)
23 }
24 }
25 }
26}
Async/Await Version (iOS 16+)
1func generateThumbnail(from url: URL) async -> UIImage? {
2 let asset = AVURLAsset(url: url)
3 let generator = AVAssetImageGenerator(asset: asset)
4 generator.appliesPreferredTrackTransform = true
5 generator.maximumSize = CGSize(width: 320, height: 180)
6
7 let time = CMTime(seconds: 1.0, preferredTimescale: 600)
8
9 do {
10 let (cgImage, _) = try await generator.image(at: time)
11 return UIImage(cgImage: cgImage)
12 } catch {
13 print("Thumbnail generation failed: \(error)")
14 return nil
15 }
16}
Adding a Thumbnail Cache
Without caching, scrolling the table view triggers repeated thumbnail generation for the same videos, wasting bandwidth and CPU:
1class ThumbnailCache {
2 static let shared = ThumbnailCache()
3 private let cache = NSCache<NSString, UIImage>()
4
5 init() {
6 cache.countLimit = 100
7 cache.totalCostLimit = 50 * 1024 * 1024 // 50 MB
8 }
9
10 func thumbnail(for url: URL, completion: @escaping (UIImage?) -> Void) {
11 let key = url.absoluteString as NSString
12
13 // Check cache first
14 if let cached = cache.object(forKey: key) {
15 completion(cached)
16 return
17 }
18
19 // Generate and cache
20 generateThumbnail(from: url) { [weak self] image in
21 if let image = image {
22 self?.cache.setObject(image, forKey: key)
23 }
24 completion(image)
25 }
26 }
27
28 func clearCache() {
29 cache.removeAllObjects()
30 }
31}
UITableView Implementation
Data Model
1struct Video {
2 let title: String
3 let url: URL
4}
Table View Cell
1class VideoCell: UITableViewCell {
2 @IBOutlet weak var thumbnailImageView: UIImageView!
3 @IBOutlet weak var titleLabel: UILabel!
4
5 private var currentURL: URL?
6
7 override func prepareForReuse() {
8 super.prepareForReuse()
9 thumbnailImageView.image = nil
10 currentURL = nil
11 }
12
13 func configure(with video: Video) {
14 titleLabel.text = video.title
15 currentURL = video.url
16
17 // Show placeholder
18 thumbnailImageView.image = UIImage(named: "video_placeholder")
19
20 // Load thumbnail asynchronously
21 ThumbnailCache.shared.thumbnail(for: video.url) { [weak self] image in
22 guard let self = self,
23 self.currentURL == video.url else {
24 return // Cell was reused for a different video
25 }
26 self.thumbnailImageView.image = image ?? UIImage(named: "video_placeholder")
27 }
28 }
29}
View Controller
1class VideoListViewController: UITableViewController {
2 var videos: [Video] = [
3 Video(title: "Introduction", url: URL(string: "https://example.com/video1.mp4")!),
4 Video(title: "Advanced Topics", url: URL(string: "https://example.com/video2.mp4")!),
5 // ...
6 ]
7
8 override func tableView(_ tableView: UITableView,
9 numberOfRowsInSection section: Int) -> Int {
10 return videos.count
11 }
12
13 override func tableView(_ tableView: UITableView,
14 cellForRowAt indexPath: IndexPath) -> UITableViewCell {
15 let cell = tableView.dequeueReusableCell(
16 withIdentifier: "VideoCell", for: indexPath
17 ) as! VideoCell
18 cell.configure(with: videos[indexPath.row])
19 return cell
20 }
21}
SwiftUI Version
1import SwiftUI
2import AVFoundation
3
4struct VideoThumbnailView: View {
5 let url: URL
6 @State private var thumbnail: UIImage?
7
8 var body: some View {
9 Group {
10 if let thumbnail = thumbnail {
11 Image(uiImage: thumbnail)
12 .resizable()
13 .aspectRatio(16/9, contentMode: .fill)
14 } else {
15 Rectangle()
16 .fill(Color.gray.opacity(0.3))
17 .aspectRatio(16/9, contentMode: .fill)
18 .overlay(ProgressView())
19 }
20 }
21 .task {
22 thumbnail = await generateThumbnail(from: url)
23 }
24 }
25}
26
27struct VideoListView: View {
28 let videos: [Video]
29
30 var body: some View {
31 List(videos, id: \.url) { video in
32 HStack {
33 VideoThumbnailView(url: video.url)
34 .frame(width: 120, height: 68)
35 .clipShape(RoundedRectangle(cornerRadius: 8))
36 Text(video.title)
37 }
38 }
39 }
40}
Prefetching Thumbnails
Use UITableViewDataSourcePrefetching to start loading thumbnails before cells appear:
1extension VideoListViewController: UITableViewDataSourcePrefetching {
2 func tableView(_ tableView: UITableView,
3 prefetchRowsAt indexPaths: [IndexPath]) {
4 for indexPath in indexPaths {
5 let video = videos[indexPath.row]
6 ThumbnailCache.shared.thumbnail(for: video.url) { _ in
7 // Just warming the cache
8 }
9 }
10 }
11}
Register the prefetch data source in viewDidLoad:
tableView.prefetchDataSource = self
Common Pitfalls
Cell reuse without URL check: When a cell is dequeued and reused, the old thumbnail callback may fire and set the wrong image. Always compare the current URL in the completion handler (as shown in the configure method).
Main thread blocking: AVAssetImageGenerator.copyCGImage(at:) is synchronous and slow for remote URLs. Always call it on a background queue.
No placeholder image: Without a placeholder, the image view shows the previous cell's thumbnail briefly (from cell reuse), creating a "flashing" effect.
Memory pressure: Large thumbnail caches can cause memory warnings. Use NSCache (which auto-evicts under pressure) rather than a plain dictionary.
HLS/streaming URLs: AVAssetImageGenerator may not work with HLS (.m3u8) streams. For HLS content, use the video's poster image URL if available, or load the first segment.
Summary
Use AVAssetImageGenerator to extract video thumbnails on a background thread
Cache thumbnails with NSCache to avoid re-generating on scroll
Always check the current URL in the completion handler to prevent stale images in reused cells
Use placeholder images while thumbnails load
Implement UITableViewDataSourcePrefetching to start loading before cells appear