Vionote Lab

iOS Developers

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

Share:
Comparison of TabView vs UICollectionView performance

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 PHAssets (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

About us

Vionote Lab avatar

Vionote Lab

The Vionote Lab builds tools that make media documentation smarter and more efficient, helping professionals manage their visual data with contextual annotations.