본문 바로가기
iOS

[SwiftUI] SwiftUI에서 AVPlayerLayer 사용하기

by jaemjung 2023. 2. 20.

문제상황

최근에 인스타그램의 릴스처럼 영상으로 구성된 피드 UI를 만들어야 하는 일이 생겼다.

API에서 영상의 url을 가져와 영상을 플레이하는 뷰를 제작해야 했는데,

문제는 기본적으로 SwiftUI에서 제공하는 영상 플레이어의 커스텀이 극도로 제한적이라는 것...

import AVKit


struct VideoContainer: some View {
	//...
    var body: some View {
    	//SwiftUI에서 제공하는 기본 비디오 플레이어
        VideoPlayer(player: AVPlayer(url:  Bundle.main.url(forResource: "video", withExtension: "mp4")!))
    .frame(height: 400)
    }
    //...
}

 AVKit에서 제공하는 VideoPlayer를 이용하면 손쉽게 영상을 재생하는 플레이어를 사용할 수 있지만, 플레이 컨트롤러, 현재 영상의 재생상태 등을 가져올 수 없다는 단점이 있었다.

 

요구사항을 구현하기 위해서는

1. 커스텀 컨트롤 적용

2. 영상의 상태에 따른 에러처리(로딩 실패, 버퍼링 등)

가 필수적이었기 때문에 대안을 찾아야만 했다.

 

해결방법

결국 AVFoundation에서 제공하는 AVPlayerLayer를 이용하여 직접 VideoPlayer를 만들기로 했다.

 

AVPlayerLayer는 AVPlayer를 사용하여 영상을 재생할 수 있는 가장 기초적인 클래스로, UIView에 배경 레이어로 AVPlayerLayer를 추가하는 방식으로 사용하면 컨트롤이 없이 순수하게 영상만 표시하는 View를 만들 수 있다.

class PlayerView: UIView {

    // 뷰의 배경 레이어를 AVPlayerLayer로 오버라이딩 해준다.
    override static var layerClass: AnyClass { AVPlayerLayer.self }
    
    // 옵셔녈 변수로 선언하여 필요할 때 AVPlayer를 주입받을 수 있다.
    var player: AVPlayer? {
        get { playerLayer.player }
        set { playerLayer.player = newValue }
    }
    
    private var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
}

요렇게 UIView에 layer를 추가한 후, 변수로 주입되는 player를 조작하여 영상을 컨트롤 하는 방식으로 커스텀 비디오 플레이 컨트롤을 적용 가능하다.

 

이런 방식으로 간단하게 별도의 컨트롤이 없이 순수하게 영상만 표시하는 뷰를 만들 수 있다.

그러나 이것은 UIView.

몇가지 옵션을 추가하여 SwiftUI스럽게 사용할 수 있는 View로 바꿔주기로 했다.

 

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
        //frame을 .zero로 설정하여 SwiftUI 상에서 frame의 크기와 위치가 결정될 수 있도록 하였다.
        super.init(frame: .zero)
        self.player = player
        self.backgroundColor = .black
        setupLayer()
    }
    
    // leaks if not set nil on deinit
    deinit {
        self.player = nil
    }
    
    required init?(coder: NSCoder) {
        self.gravity = .resize
        super.init(coder: coder)
        self.backgroundColor = .black
        setupLayer()
    }
    
    func setupLayer() {
        switch gravity {
            
        case .aspectFill:
            playerLayer.contentsGravity = .resizeAspectFill
            playerLayer.videoGravity = .resizeAspectFill
            
        case .resize:
            playerLayer.contentsGravity = .resize
            playerLayer.videoGravity = .resize
        }
    }
    
    var playerLayer: AVPlayerLayer {
        return layer as! AVPlayerLayer
    }
    
    override static var layerClass: AnyClass {
        return AVPlayerLayer.self
    }
    
}

영상의 gravity(영상이 화면의 비율을 유지하면서 view를 채우게 할 것인지, 아니면 비율을 무시하고 채우게 할 것인지)를 설정할 수 있는 옵션을 추가하였고, 해당 UIView의 init 시 frame을 zero로 주어 SwiftUI단에서 뷰의 크기와 위치를 결정할 수 있도록 만들었다. 

 

그리고 제작 과정에서 확인한 것인데 deinit에서 명시적으로 player를 nil로 설정해주지 않으면 메모리 누수가 발생하였다... 따라서 객체 소멸 시 player에 대한 참조를 해제하였다.

 

이렇게 만들어진 PlayerView를 UIViewRepresentable 프로토콜을 적용한 View로 감싸주면 SwiftUI상에서도 사용할 수 있다.

struct VideoPlayerContainerView: 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) { }
}

 

 

 

 

댓글