Playing Video with AVPlayer in SwiftUI

I’ve been experiencing more and more with SwiftUI and I really wanted to see what we can do with video content. Today I’ll share my findings, showing how to play video using AVFoundation in SwiftUI, including some mistakes to avoid.

The best way to play a media on iOS is using AVFoundation, Apple’s framework to play audio or video on any of its platform. I’ll focus on the video only.

As you might guess, there is no video view ready for us to play with. So the first step will be to create a video player with UIKit and bring it to SwiftUI.

UIKit Player View

If you’re not familiar with AVFoundation, we don’t have a “ready to play” player view to use. Or at least, not the way we want it. AVFoundation comes with a AVPlayerViewController which gives you all-in-on UIViewController where you can pass a media to. Otherwise, it’s at a lower level, using AVPlayerLayer at the layer level.

Since we want to stay “hands-on”, it makes sense to use the latter one.

enum PlayerGravity {
    case aspectFill
    case resize
}

class PlayerView: UIView {
    
    var player: AVPlayer? {
        get {
            return playerLayer.player
        }
        set {
            playerLayer.player = newValue
        }
    }
    
    let gravity: PlayerGravity
    
    init(player: AVPlayer, gravity: PlayerGravity) {
        self.gravity = gravity
        super.init(frame: .zero)
        self.player = player
        self.backgroundColor = .black
        setupLayer()
    }
    
    func setupLayer() {
        switch gravity {
    
        case .aspectFill:
            playerLayer.contentsGravity = .resizeAspectFill
            playerLayer.videoGravity = .resizeAspectFill
            
        case .resize:
            playerLayer.contentsGravity = .resize
            playerLayer.videoGravity = .resize
            
        }
    }
        
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    var playerLayer: AVPlayerLayer {
        return layer as! AVPlayerLayer
    }
    
    // Override UIView property
    override static var layerClass: AnyClass {
        return AVPlayerLayer.self
    }
}

To bring AVPlayerLayer closer to us, I override the layer class of the UIView to give us what we want. The rest is accessors around it. Finally, I introduced a PlayerGravity that will be helpful to quickly resize our player content when using with SwiftUI.

As you can see, the view doesn’t hold any logic, no player controller, not play / pause, it’s only here for rendering.

We can move to bring this view to SwiftUI.

SwiftUI Representation

To expose our PlayerView to SwiftUI, we need to implement a specific protocol: UIViewRepresentable. I’ve created a separate class for this since this would be consume on SwiftUI only.

final class PlayerContainerView: UIViewRepresentable {
    typealias UIViewType = PlayerView
    
    let player: AVPlayer
    let gravity: PlayerGravity
    
    init(player: AVPlayer, gravity: PlayerGravity) {
        self.player = player
        self.gravity = gravity
    }
    
    func makeUIView(context: Context) -> PlayerView {
        return PlayerView(player: player, gravity: gravity)
    }
    
    func updateUIView(_ uiView: PlayerView, context: Context) { }
} 

Very similar to our previous class, this object is only here to create a representation of PlayerView. Once again, no logic, we only pass player along to the view. I’ll come back in the last part why we shouldn’t put logic there.

Our container is ready to provide a view for us, we can dive into the SwiftUI part.

SwiftUI Implementation

struct ContentView: View {
    
    let model: PlayerViewModel
    init() {
        model = PlayerViewModel(fileName: "video")
    }
    
    var body: some View {
        PlayerContainerView(player: model.player, gravity: .center)
    }

The implementation stays really simple on the body part. We create a container view and pass along our settings. What interests us is on the PlayViewModel.

class PlayerViewModel: ObservableObject {

    let player: AVPlayer
    
    init(fileName: String) {
        let url = Bundle.main.url(forResource: fileName, withExtension: "mp4")
        self.player = AVPlayer(playerItem: AVPlayerItem(url: url!))
        self.play()
    }
    
    func play() {
        let currentItem = player.currentItem
        if currentItem?.currentTime() == currentItem?.duration {
            currentItem?.seek(to: .zero, completionHandler: nil)
        }
        
        player.play()
    }
    
    func pause() {
        player.pause()
    }
    
}

Since my asset is part of the app bundle, I preload it when constructing the element, then starting playing it.

That’s it.

When creating ContentView, it will pass the player (that we know the media works) to its subviews. The player state stays with PlayerViewModel. You won’t see it play in Xcode Canvas (which is expected) but if you launch it on a simulator of physical device, you’ll get your video playing.

Wait, that’s it? I thought it would be much harder.

Yes. Pretty neat, right?

We haven’t covered the player controls or state in case you play a remote file, so it’s straightforward. Let me dive a bit more into the controls and the way to propagate actions.

SwiftUI Player Controls

Let’s focus on two actions first, play and pause.

enum PlayerAction {
    case play
    case pause
}

In my very first try of building a video player, I wanted to leave the PlayerView hold the player logic and only exposes actions to it. It looks like that.

class PlayerViewModel: ObservableObject {

    let actionSubject = PassthroughSubject<PlayerAction, Never>()
    var action: AnyPublisher<PlayerAction, Never> {
        return actionSubject.eraseToAnyPublisher()
    }

    @Published var isPlaying: Bool = false {
        didSet {
            if isPlaying {
                actionSubject.send(.play)
            } else {
                actionSubject.send(.pause)
            }
        }
    }
    
    // ...
}

struct ContentView: View {
    
    @ObservedObject var model: PlayerViewModel
    // ...

    var body: Some {
        VStack {
            PlayerContainerView(filename: self.model.filename, gravity: .center, action: $model.action)
            Button(action: {
                self.model.isPlaying.toggle()
            }, label: {
                Image(systemName: self.model.isPlaying ? "pause" : "play")
                    .padding()
            })
        }
        
    }

The idea was fairly simple, toggling the state of PlayerViewModel will trigger a new action, this action would be listened to PlayerView and play or pause accordingly.

Unfortunately, I had many issues with this solution, mostly because from my understanding SwiftUI views should be stateless and not hold data: every switch on the player was actually recreating a PlayerContainerView as well as a PlayerView. It wasn’t holding it’s previous state.

This is probably something I was missing before. It explains why SwiftUI View often observes item changes rather than calling internal function to populate its own data, those functions are probably called at more often that needed.

So, how can I include player controls?

Well, the solution can be simpler.

I expose a boolean in PlayerViewModel, something that the ContentView can switch. Since it holds AVPlayer then it doesn’t require more changes, the cascading views will reflect the changes.

class PlayerViewModel: ObservableObject {

    let player: AVPlayer
    
    init(fileName: String) {
        let url = Bundle.main.url(forResource: fileName, withExtension: "mp4")
        self.player = AVPlayer(playerItem: AVPlayerItem(url: url!))
        play()
    }
    
    @Published var isPlaying: Bool = false {
        didSet {
            if isPlaying {
                play()
            } else {
                pause()
            }
        }
    }
    
    func play() {
        let currentItem = player.currentItem
        if currentItem?.currentTime() == currentItem?.duration {
            currentItem?.seek(to: .zero, completionHandler: nil)
        }
        
        player.play()
    }
    
    func pause() {
        player.pause()
    }
}

I guess it’s still a good news, you don’t need to include Combine framework into the loop. If you care about the state of your player, you’ll have to observe it using KVO. Maybe it’s for the best to avoid stacking complexity.

SwiftUI Player Enhancements

Stepping back for a second from this code, it seems we haven’t made much progress. Sure, it displays a simple player, but did we need SwiftUI for this?

Well, I’m coming to the point.

Now, that we have a view that play videos, we can take advantages of SwiftUI modifiers to enhance the player experience.

For instance, I can create a clip my video and includes a blur effect really easily.

PlayerContainerView(player: model.player, gravity: .center)
    .frame(width: 300, height: 300)
    .blur(radius: 3.0)
    .clipShape(Circle())

I can also include a border with a padding to fake a TV monitor (why not?) and includes an overlay for better background effect.

PlayerContainerView(player: model.player, gravity: .aspectFill)
    .frame(height: 200)
    .overlay(Color.black.opacity(0.1))
    .padding()
    .border(Color.black, width: 2)
    .padding()

video-sample-result

We could imaging an overlay or blur effect based on fast forward / backward action, there are plenty more options to play with.

This code is available on Github as videoSample. Video credits cottonbro.


In conclusion, we saw how to create a custom view and how to expose it to SwiftUI code. It’s also important to understand each layer of logic to avoid complexity when possible like in my first attempt to pair Combine and KVO before realizing it wasn’t necessary.

In my opinion, the most interesting part in this experiment is all the possibilities that SwiftUI can bring to AVPlayer for custom effect and make it a really great experience on Apple devices.

© 2023 Benoit Pasquier. All Rights Reserved
Author's picture

Benoit Pasquier

Software Engineer πŸ‡«πŸ‡·, writing about career development, mobile engineering and self-improvement

ShopBack πŸ’°

Singapore πŸ‡ΈπŸ‡¬