
iOS Developers
From TabView to UICollectionView: Building a Smooth, Memory-Efficient Media Viewer for iOS

When we first built the media viewer for our app Vionote, we reached for the obvious SwiftUI choice: TabView
with .pageTabViewStyle()
for horizontal swiping through photos and videos. It seemed simple, elegant, and matched what we wanted visually. But we quickly ran into serious performance issues — especially when scrolling quickly through a large album.
In this blog post, we want to share how we migrated from a SwiftUI-only TabView
solution to a custom UICollectionView
-based implementation using UIViewControllerRepresentable
, which gave us performance, control, and a native-feeling experience, just like Apple Photos.
The Problem with TabView
TabView
in SwiftUI is great for a few pages, but when you need to:
- Browse hundreds of high-resolution
PHAsset
s (photo/video assets) - Support pinch-to-zoom and drag gestures
- Scroll rapidly without lag
- Load media efficiently
It quickly becomes problematic.
We ran into:
- High memory usage: Images and videos would get preloaded into memory without proper release.
- Gesture conflicts: Dragging a zoomed-in image interfered with the horizontal swipe.
- Lack of fine-grained control: No lifecycle hooks like
viewWillAppear
, difficult to manage cache.
The Solution: UICollectionView Inside SwiftUI
To overcome these limitations, we rewrote the viewer using UICollectionView
, embedded inside SwiftUI using UIViewControllerRepresentable
. Here's how we approached it.
Key Benefits:
- Smooth paging, even when swiping fast
- Cell reuse helps keep memory low
- Full gesture control (zoom, pan, tap)
- Seamless integration with
PHAsset
loading
Step-by-Step Implementation
Here's a simplified version of our implementation:
1. The SwiftUI Wrapper
struct MediaCollectionViewWrapper: UIViewControllerRepresentable {
let assets: [PHAsset]
@Binding var currentIndex: Int
func makeUIViewController(context: Context) -> MediaCollectionViewController {
let vc = MediaCollectionViewController(assets: assets, startIndex: currentIndex)
vc.onPageChange = { index in
currentIndex = index
}
return vc
}
func updateUIViewController(_ uiViewController: MediaCollectionViewController, context: Context) {}
}
2. The UICollectionViewController
class MediaCollectionViewController: UIViewController, UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
var assets: [PHAsset]
var collectionView: UICollectionView!
var onPageChange: ((Int) -> Void)?
init(assets: [PHAsset], startIndex: Int) {
self.assets = assets
super.init(nibName: nil, bundle: nil)
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = .horizontal
layout.minimumLineSpacing = 0
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.isPagingEnabled = true
collectionView.showsHorizontalScrollIndicator = false
collectionView.register(MediaCell.self, forCellWithReuseIdentifier: "MediaCell")
collectionView.dataSource = self
collectionView.delegate = self
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
collectionView.frame = view.bounds
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(collectionView)
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
assets.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "MediaCell", for: indexPath) as! MediaCell
cell.configure(with: assets[indexPath.item])
return cell
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
collectionView.frame.size
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
let page = Int(scrollView.contentOffset.x / scrollView.frame.size.width)
onPageChange?(page)
}
}
3. The Media Cell
class MediaCell: UICollectionViewCell {
private var imageView = UIImageView()
private var playerLayer: AVPlayerLayer?
override init(frame: CGRect) {
super.init(frame: frame)
contentView.backgroundColor = .black
imageView.contentMode = .scaleAspectFit
imageView.frame = contentView.bounds
imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
contentView.addSubview(imageView)
}
required init?(coder: NSCoder) { fatalError() }
override func prepareForReuse() {
super.prepareForReuse()
imageView.image = nil
playerLayer?.removeFromSuperlayer()
playerLayer = nil
}
func configure(with asset: PHAsset) {
if asset.mediaType == .image {
PHImageManager.default().requestImage(for: asset, targetSize: bounds.size, contentMode: .aspectFit, options: nil) { image, _ in
self.imageView.image = image
}
} else if asset.mediaType == .video {
PHImageManager.default().requestAVAsset(forVideo: asset, options: nil) { avAsset, _, _ in
if let urlAsset = avAsset as? AVURLAsset {
DispatchQueue.main.async {
let player = AVPlayer(url: urlAsset.url)
let layer = AVPlayerLayer(player: player)
layer.frame = self.contentView.bounds
layer.videoGravity = .resizeAspect
self.contentView.layer.addSublayer(layer)
self.playerLayer = layer
player.play()
}
}
}
}
}
}
Final Thoughts
Migrating from TabView
to UICollectionView
may seem like a big shift, but for apps that deal with rich media like photos and videos, it's often necessary.
In our case, the improvement in:
- Performance
- Gesture handling
- Memory usage
- UX responsiveness
...was more than worth it.
If you're hitting similar walls in your SwiftUI project — especially with TabView
+ PHAsset
— we hope this guide gives you a smooth path forward.
Feel free to try Vionote and let us know how it performs!
Built by engineers, for engineers.
— The Vionote Lab