SwiftUI에서 VideoPlayer 만들어보기

마이노·2024년 4월 18일
1

15주 글쓰기 🐣

목록 보기
8/10
post-thumbnail
post-custom-banner

SwiftUI에서 비디오를 재생하려면 어떻게 할까요?

비디오플레이어 기능을 제공하고는 있습니다만 굉장히 제한적입니다.

import SwiftUI
import AVKit

struct VideoPlayerView: View {
    let url: URL = URL(string: "")!
    var body: some View {
        VideoPlayer(player: AVPlayer(url: url))
    }
}
기본영역 조절 후

오버레이 된 컴포넌트 또한 없앨 수가 없습니다. 단지 해당 플레이어에 추가로 얹을 수만 있습니다.
저희는 이러한 동영상 플레이어를 원하는 것이 아니잖아요? 필요한 기능들을 넣고 뺄 수 있는 커스텀 기능이 SwiftUI에서는 아예 존재하지 않습니다.

따라서 눈물을 머금고🥲 유킷에서 래핑해서 가져와야 합니다.

만들어 봅시다 !

우선 UIKit에서 뷰를 가져와야 합니다. 가져오기 전 생각할 것이 있어요.
UIViewController로 가져올 것인가? 아니면 UIView로 가져올 것인가?

라이프사이클을 오버라이드해서 사용할 것이라면 UIViewController를 추천드립니다. 저는 사용하지 않을 것이기 때문에 UIView로 만들어볼게요!

struct VideoPlayerView: UIViewRepresentable {
	func makeUIView(context: Context) -> UIView {
        return 만들 뷰()
    }
    
    func updateUIView(_ uiView: UIView, context: Context) { }
}

UIViewRepresentable 프로토콜을 준수해야 하기 때문에 필수 구현 메서드 두개를 만들어줍니다.

이제 makeUIView에서의 사용 할 UIView를 만들어주면 됩니다.

class PlayerContainer: UIView {}

기존 스유에서 VideoPlayer의 생성자를 기억하시나요?

 VideoPlayer(player: AVPlayer(url: url))

AVPlayer를 받아주고 있어요. 저희도 똑같이 받겠습니다.

class PlayerContainer: UIView { 
	var player: AVPlayer
	var playerItem: AVPlayerItem?
    
    init(player: AVPlayer) {
    	self.player = player
    	super.init(frame: .zero)
    }
}

플레이어의 기본 정보를 들고있는 AVPlayer를 받아주고 playerItem을 optional 변수로 하나 만들어주었습니다.

이름 그대로 재생되는 기기를 player, 재생되는 항목을 playerItem으로 설정해 주요 정보들을 받을 예정입니다.

func configurePlayer() {
	let playerLayer = AVPlayerLayer(player: self.player)
	self.playerItem = player.currentItem
	player.play()
	layer.addSublayer(playerLayer)
}

이후에 AVPlayerLayer를 통해 재생되는 영역 Layer를 AVPlayer를 주입해줌으로써 얻어내고 현재 재생되는 아이템을 optional로 선언해둔 playerItem에 넣어줍니다.
이후 동영상을 실행하게 되고 레이어 영역을 layer에 addSublayer 해줍니다.

여기까지 작성했다면 비디오는 재생됩니다.

화면이 안나와요...

직전 AVPlayerLayer를 만들어 추가해주었던 것을 기억하시나요? 저희는 Subview의 layout을 그릴 때 개입해주어야 합니다. 따라서 첫번째 sublayer의 영역을 뷰의 frame으로 지정해줍니다.

override func layoutSubviews() {
	super.layoutSubviews()
	layer.sublayers?.first?.frame = frame
}

동영상을 만들어봤습니다. 어떤가요? 상당히 밋밋하고 아무오버레이도 없는 채로 재생되는 플레이어입니다. 오히려 좋습니다. 하나씩 얹어가면 되니까요!

저는 다음과 같은 비디오 플레이어 오버레이를 구성해봤어요.

  1. 중앙에 재생중인지 ,아니면 일시정지 상태인지를 구분하는 재생버튼

  2. 우측 상단의 위치한 원래화면으로 돌아가는 x버튼

  3. 얼마나 재생되었는지 체크하고 프레임 이동이 가능한 슬라이더

  4. 현재 시간과 동영상의 총 시간을 체크하는 레이블 2개


버튼의 레이아웃을 잡는 것은 일단 넘어가고 기능들에 대해 구현해볼께요.

재생과 일시정지

if player.rate == 1.0 {
	DispatchQueue.main.async {
		let img = self.button.buttonImageSize(systemImageName: "play.fill", size: 50)
		self.button.setImage(img, for: .normal)
	}
	player.pause()
} else {
	DispatchQueue.main.async {
		let img = self.button.buttonImageSize(systemImageName: "pause.fill", size: 50)
		self.button.setImage(img, for: .normal)
	}
	player.play()
}

재생은 어떻게 했었나요? player.play() 였습니다.
일시정지는 반대로 player.pause()를 사용하면 되겠어요.

rate를 통해 player가 재생중인지 일시정지 상태인지 알 수 있어요. 1일때 재생중입니다. 조건에 따라 재생중이면 멈추고 이미지를 바꿔주고 반대상황이면 반대로 처리하게 로직을 작성해주시면 됩니다.

뒤로가기

매우 간단합니다. 단지 버튼의 addTarget을 해주고 사용 할 뷰에 기댈 수 있게 스트림을 연결해주면 됩니다.

동영상 시간과 이동

동영상의 총 시간과 남은시간을 추적하기 위해선 Observer를 사용해야 합니다.

let timeObserver: Any?

...
func setTimeObserver() {
	timeObserver = player.addPeriodicTimeObserver(
    		forInterval: CMTime(
    				seconds: 1, 
    				preferredTimescale: 1), 
    		queue: DispatchQueue.main) { [weak self] time in
    }

많은 정보들을 옵저버를 통해 해결하고 있는데요, 하나하나 뜯어가며 살펴볼께요. 우선 player.addPeriodicTimeObserver()의 리턴값은 Any? 입니다. 따로 저장해둔 이유는 나중에 비디오플레이어를 벗어날 때 deinit에서 날려버리기 위해서 입니다.

addPeriodicTimeObserver는 비디오플레이어를 주기적으로 호출하여 시간이 변경됨을 요청합니다.
forInterval은 호출되는 시간간격이에요. CMTime은 int64/int32로 표현가능하게 하는 시간값입니다. preferredTimescale 또한 1로 설정해주어 시간의 척도는 1이다. 를 알려줍니다.
queue에는 메인큐를 사용할거에요. 이 작업은 화면에 표시되는 컴포넌트를 위한것이니까요.

guard let self else { return }
guard let playerItem else { return }

weak self로 사용하기 때문에 옵셔널을 반복적으로 사용하는 것이 귀찮아 guard let으로 처리해주었습니다. 현재 재생되는 playerItem또한 옵셔널이라면 흐름에 어긋나기 때문에(동영상 재생 주기를 옵저빙 하는데 옵셔널이면 재생 주기가 없으므로) 우선처리를 해주어야 합니다.

let currentTime = CMTimeGetSeconds(time)
let duration = CMTimeGetSeconds(playerItem.duration)

self.slider.value = Float(currentTime) / Float(duration)

현재시간과 duration을 얻어와줍니다. 이를 통해 현재 slider의 value를 수정할 수 있습니다. 현재 진행된 시간과 전체 주기를 나눠준다면 value가 얼마나 진행되었는지 알 수 있습니다.

let endMinute = Int(duration) / 60
let endSecond = Int(duration) % 60
let currentMinute = Int(currentTime) / 60
let currentSecond = Int(currentTime) % 60

self.currentTimeLabel.text = "\(currentMinute.addZero):\(currentSecond.addZero)"
self.endTimeLabel.text = "\(endMinute.addZero):\(endSecond)"

duration과 currentTime은 Float64 type이기 때문에 Int로 형변환을 우선적으로 해줍니다.

전체 주기의 초들을 60으로 나눠주고 60으로 나눈 나머지를 갖습니다.
그 결과 분,초를 얻을 수 있습니다. 이를 이제 그대로 얹어주기만 하면됩니다.

이렇게 동영상 플레이어를 만들 수 있습니다.

디테일 살리기

    func fadeIn(_ duration: TimeInterval = 0.2) {
        slider.isHidden = false
        slider.alpha = 0
        UIView.animate(
            withDuration: duration,
            animations: {
                self.slider.alpha = 1
                ...
            }
        )
    }
    
    func fadeOut(_ duration: TimeInterval) {
        UIView.animate(withDuration: duration, animations: {
            self.slider.alpha = 0
        }) { _ in
            self.slider.isHidden = true
            ...
        }
    }

해당 애니메이션을 통해 화면 터치를 받고 시간이 어느정도 지났을 때 컴포넌트들을 숨김처리, 나타남처리를 해줍니다.

저는 풀스크린 동영상을 만들었기 때문에 이전 뷰에서는 썸네일이 나오는 상황에서 전체화면으로 지금까지 만든 뷰를 생각했습니다. 그렇기 때문에 동영상이 전체화면이 될때에는 화면전환을 해주어야 했습니다.

guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else {
	return
}
windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: .portrait))

interfaceOrientations의 값에는 portrait와 landscape가 있어요.
저희는 landscape가 되기를 바라기 때문에 onAppear에서 호출해주었다가 화면을 벗어날 때 다시 세로화면으로 돌리는 로직을 추가함으로써 자연스럽게 화면전환을 이뤄낼 수 있었습니다. 이는 Deployment Info에서 landscape left,right를 꼭 켜주셔야 합니다..!

마치며

SwiftUI을 15부터 사용해보며 많은 기능들이 시간이 지남에 따라 생겨났습니다. 이번에 VideoPlayer도 사용해보며 아직까지 완벽한 커스텀화는 불가능하다는 결론이 났고 직접 만들어보는 흥미로운 경험도 하게 되었습니다. 아직까지는 유킷을 떼어내지는 못하는 걸까요? 버전업이 많이많이 되었으면 하는 바램입니다 ㅎㅎㅎ

profile
아요쓰 정벅하기🐥
post-custom-banner

4개의 댓글

comment-user-thumbnail
2024년 4월 19일

👍👍
Swift가 나와도 Objc를 버릴 수 없는 것처럼
SwiftUI가 나와도 UIKit은 살아있군요

sublayer는 왜 첫번째 것만 사이즈를 설정하면 되는건가요?

1개의 답글
comment-user-thumbnail
2024년 4월 20일

와 영상 슬라이더를 만들어 줄 수 있군요!
UIKit에 대한 지식이 사라진 상태인데 고려해야할 다양한 요소들에 대해 읽을 수 있어 너무 좋았습니다. (슬라이더가 사라지는 처리까지도!) 특히 interfaceOrientations를 통해 화면 상태를 먹여줄 수 있음을...! 이를 구현해야 할 상황이 없어 처음 알게 된 사실입니다. 좋은 정보 알아갑니다!

답글 달기
comment-user-thumbnail
2024년 4월 21일

여전히 swiftUI에도 UIkit이 필요하다는게 가끔은 신기하더라고요 ㅎㅎ
그래도 예전에는 imagepicker를 UIkit으로 사용했어야했는데 iOS17부터인지 최근에는 swiftUI만으로도 사용이가능하게된것처럼 서서히 swiftUI로 대체되지않을까싶네요!

답글 달기