How to use AVPlayerViewController in SwiftUI using UIViewRepresentable

Introduction

Ever since its introduction in 2019, SwiftUI has been a hit amongst iOS/macOS/tvOS/watchOS developers since it has made UI creation so much easier than UIKit. Not only can you make advanced layouts more easily now with SwiftUI, but it allows you to use the same set of tools and APIs to create UIs for all Apple platforms. As time goes on, Apple’s plan is to use SwiftUI in many places as possible.

With Xcode 14 and iOS 16 having been launched to the public recently, support for SwiftUI has grown, even more, allowing you to rely less on UIKit for your apps.

Problem: We still cannot get rid of UIKit

That being said, we still do not have everything that we need in SwiftUI, since many niche frameworks only have full support for UIKit as of today.

Two such niche frameworks among them are AVKit and AVFoundation by Apple, which allows iOS/macOS/tvOS developers to create awesome video experiences on their apps. Apps like Netflix, Amazon Prime Video, Hulu, and Hotstar, all make use of these frameworks too, as it is the only way for developers to play videos in their apps.

However, even with SwiftUI 4 in iOS 16, we still don’t have a great out-of-the-box video player from these frameworks that can be used directly in a SwiftUI project. Though AVKit does have a simple VideoPlayer view for SwiftUI, it is still less powerful than the UIKit counterpart AVPlayerViewController which is still being used in most of the OTT apps mentioned above. As an example, VideoPlayer currently only supports the embedded mode and not PiP and full-screen modes.

A solution to the problem: UIViewRepresentable

Fortunately, SwiftUI does allow us to use a UIKit view in it through UIViewRepresentable, which is a wrapper that integrates that UIKit view in the SwiftUI hierarchy.

In this post, I will be using the example of wrapping AVPlayerViewController under UIViewRepresentable to use it in SwiftUI, but you can use it for pretty much any UIKit view that you want to use in SwiftUI.

Without further ado, let’s get right into it.

Structure of UIViewRepresentable

UIViewRepresentable is itself a protocol that you adopt in a struct in order to override methods to create, update, and tear down your view. SwiftUI in the background would call these methods when appropriate to represent the view in the hierarchy.

However, the system does not by itself communicate changes occurring within the view to other views in the SwiftUI interface. What you must do instead is provide a Coordinator instance to facilitate those changes.

Let us see some code to understand this.

Creating the Legacy Video Player

AVPlayerViewController is the actual view controller that displays the video along with the playback controls. The LegacyVideoPlayer we are going to make is basically a SwiftUI representation of this same view controller.

Let us begin with the struct with the methods:

/// Legacy video player that uses `AVKit` and `AVFoundation` in the background
struct LegacyVideoPlayer: UIViewControllerRepresentable {
    typealias UIViewControllerType = AVPlayerViewController    // Define the type of the UIKit view
    
    let player: AVPlayer    // The controller object that manages the playback of the video
    
    public init(
        player: AVPlayer    // You get an instance of AVPlayer from the initializer
    ) {
        self.player = player
    }
    
    func makeUIViewController(context: Context) -> AVPlayerViewController {
        // Create a new AVPlayerViewController and pass it a reference to the player.
        let controller = AVPlayerViewController()
        player.allowsExternalPlayback = true
        player.audiovisualBackgroundPlaybackPolicy = .automatic
        controller.player = player
        controller.delegate = context.coordinator
        controller.modalPresentationStyle = .automatic
        controller.canStartPictureInPictureAutomaticallyFromInline = true
        controller.entersFullScreenWhenPlaybackBegins = false
        controller.exitsFullScreenWhenPlaybackEnds = false
        controller.allowsPictureInPicturePlayback = true
        controller.showsPlaybackControls = true
        controller.restoresFocusAfterTransition = true
        controller.updatesNowPlayingInfoCenter = true
        return controller
    }
    
    // In our case we do not need to update our `AVPlayerViewController` when AVPlayer changes
    func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {}
    
    // Creates the coordinator that is used to handle and communicate changes in `AVPlayerViewController`
    func makeCoordinator() -> LegacyVideoPlayerCoordinator {
        LegacyVideoPlayerCoordinator(self)
    }
}

Note: Many of the properties that I am setting inside makeUIViewController are optional, but this is what I used in the app that I was working on. I would recommend you take a look at each of them to see if you need it.

Now let us create the coordinator.

Creating the Legacy Video Player Coordinator

Just creating the makeUIViewController and updateUIViewController would be enough for most use cases. But in our case, a video player has a lot of turning parts that need communication with other views. We are going to create a basic coordinator here that handles the major features like full-screen and picture-in-picture.

Here is the class along with a reference to the LegacyVideoPlayer.

public class LegacyVideoPlayerCoordinator: NSObject {
    var parent: LegacyVideoPlayer

    init(_ parent: LegacyVideoPlayer) {
        self.parent = parent
    }
}

In order to handle full screen and PiP, we will be implementing the AVPlayerViewControllerDelegate.

extension LegacyVideoPlayerCoordinator: AVPlayerViewControllerDelegate {
    // This method is called after the user clicks on the full-screen icon and the player begins to go fullscreen
    public func playerViewController(
        _ playerViewController: AVPlayerViewController,
        willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator
    ) {
        let isPlaying = playerViewController.player?.isPlaying ?? false
        coordinator.animate(alongsideTransition: nil) { context in
            // Add coordinated animations
            if context.isCancelled {
                // Still embedded inline
            } else {
                // Presented full screen
                // Take strong reference to playerViewController if needed
            }
            if isPlaying {
                playerViewController.player?.play()
            }
        }
    }
    
    // This method is called after the user clicks on the close icon and the player starts to go out of fullscreen
    public func playerViewController(
        _ playerViewController: AVPlayerViewController,
        willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator
    ) {
        let isPlaying = playerViewController.player?.isPlaying ?? false
        coordinator.animate(alongsideTransition: nil) { context in
            // Add coordinated animations
            if context.isCancelled {
                // Still full screen
            } else {
                // Embedded inline
                // Remove strong reference to playerViewController if held
            }
            if isPlaying {
                playerViewController.player?.play()
            }
        }
    }
    
    // This method returns whether the embedded AVPlayerViewController should be dismissed after PiP starts
    // Unless we are going to handle it some other way, we need this to always return false
    public func playerViewControllerShouldAutomaticallyDismissAtPictureInPictureStart(_ playerViewController: AVPlayerViewController) -> Bool {
        return false
    }
}

I’ve explained what each method does in the comments above them, and you should have a good idea of what to do with your own UIKit view delegates.

Using LegacyVideoPlayer in a view

Let’s use this in a view in a way it would be used in a real app. I am going to embed this LegacyVideoPlayer inside a VideoPlayerView which is basically a page along with some text views and like/dislike buttons.

import SwiftUI
import AVFoundation
import AVKit

protocol PlayableVideo {
    var title: String { get }
    var speakerName: String { get }
    var channelName: String { get }
    var datePublished: Date { get }
}

struct VideoPlayerView: View {
    @Environment(\.presentationMode) var mode: Binding<PresentationMode>
    
    @ObservedObject var videoPlayerViewModel = VideoPlayerViewModel()
    let video: PlayableVideo
    
    init(video: PlayableVideo) {
        self.video = video
    }
    
    var body: some View {
        VStack {
            VStack {
                Spacer()
                    .frame(height: 20)
                
                LegacyVideoPlayer(player: videoPlayerViewModel.player)
                .padding(.leading, 5)
                .padding(.trailing, 5)
                .frame(maxHeight: .infinity)
            }
            .frame(minHeight: 140, maxHeight: 350)
            
            Spacer()
                .frame(height: 10)
            
            ScrollView {
                Text(video.title)
                    .font(.title3)
                
                Text("\(video.speakerName) · \(video.channelName)")
                    .font(.caption)
                    .foregroundColor(Color(uiColor: .secondaryLabel))
                
                Text(video.datePublished.formatted(date: .abbreviated, time: .omitted))
                    .font(.caption)
                    .foregroundColor(Color(uiColor: .secondaryLabel))
                
                Spacer()
                    .frame(height: 20)
                
                HStack(alignment: .center) {
                    Spacer()
                    
                    VStack {
                        Image(systemName: "hand.thumbsup")
                        Text("videoPlayerViewILikeThisLabel")
                    }
                    .padding(.leading)
                    .padding(.top)
                    .padding(.bottom)
                    
                    VStack {
                        Image(systemName: "hand.thumbsdown")
                        Text("videoPlayerViewNotForMeLabel")
                    }
                    .padding(.leading)
                    .padding(.top)
                    .padding(.bottom)
                    
                    Spacer()
                }
                
                Spacer()
            }
        }
        .navigationBarBackButtonHidden(true)
        .navigationBarItems(leading: Button(action : {
            videoPlayerViewModel.onBackPressed()
            self.mode.wrappedValue.dismiss()
        }){
            Image(systemName: "arrow.left")
        })
    }
}

There are a lot of things to crack here that you might not need to know, but what is important here is how I am directly using LegacyVideoPlayer inside the view hierarchy without a problem.

So let’s see what it looks like on the screen.

What it looks like in action

Here is what it looks like in my app. Note that the square video player is the LegacyVideoPlayer that we have created, and the rest are the other views in the VideoPlayerView. The tab bar is an ancestor view so you needn’t care about it.


And that’s all. You have a great video player experience for your app.

And this is how you would use a UIKit view in SwiftUI using UIViewRepresentable.

Let me know if you found this useful or if you have any questions related to AVKit, AVPlayerViewController, or video streaming in general. I’ve been doing a lot of learning on that so will do my best to answer your questions.

Until next time. Bis bald!

2 thoughts on “How to use AVPlayerViewController in SwiftUI using UIViewRepresentable

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.