▶️ AVKit
비디오 플레이어 UI와 컨트롤을 위한 고급 프레임워크
iOS 8+SwiftUI 지원
✨ AVKit이란?
AVKit은 비디오 재생을 위한 완전한 사용자 인터페이스를 제공하는 프레임워크입니다. AVFoundation 위에 구축되어 재생 컨트롤, Picture-in-Picture, AirPlay, 자막, 챕터 네비게이션 등 프로페셔널한 비디오 플레이어 기능을 손쉽게 구현할 수 있습니다. UIKit의 AVPlayerViewController와 SwiftUI의 VideoPlayer를 모두 지원합니다.
💡 핵심 기능: 재생 컨트롤 · Picture-in-Picture · AirPlay · 자막 · 챕터 · 속도 조절 · 전체화면 · 제스처 · 시스템 통합
🎬 1. 기본 비디오 플레이어 (SwiftUI)
SwiftUI의 VideoPlayer를 사용하여 간단하게 비디오를 재생합니다.
VideoPlayerView.swift — SwiftUI 비디오 플레이어
import SwiftUI import AVKit struct SimpleVideoPlayer: View { let url: URL var body: some View { VideoPlayer(player: AVPlayer(url: url)) .frame(height: 300) } } // 사용 예제 struct ContentView: View { var body: some View { VStack { if let url = URL(string: "https://example.com/video.mp4") { SimpleVideoPlayer(url: url) } } } } // 커스텀 컨트롤이 있는 VideoPlayer struct VideoPlayerWithOverlay: View { @State private var player: AVPlayer @State private var isPlaying = false init(url: URL) { _player = State(initialValue: AVPlayer(url: url)) } var body: some View { VideoPlayer(player: player) { // 커스텀 오버레이 VStack { Text("나만의 비디오") .font(.title) .foregroundStyle(.white) .padding() Spacer() HStack { Button { if isPlaying { player.pause() } else { player.play() } isPlaying.toggle() } label: { Image(systemName: isPlaying ? "pause.fill" : "play.fill") .font(.largeTitle) .foregroundStyle(.white) } } .padding() } } .frame(height: 300) } }
📱 2. AVPlayerViewController (UIKit)
UIKit에서 AVPlayerViewController를 사용하여 전체 기능을 갖춘 플레이어를 만듭니다.
PlayerViewController.swift — UIKit 플레이어
import SwiftUI import AVKit struct PlayerView: UIViewControllerRepresentable { let player: AVPlayer func makeUIViewController(context: Context) -> AVPlayerViewController { let controller = AVPlayerViewController() controller.player = player // 재생 컨트롤 설정 controller.showsPlaybackControls = true // Picture-in-Picture 허용 controller.allowsPictureInPicturePlayback = true // 비디오 중력 설정 controller.videoGravity = .resizeAspect // 자막 표시 controller.canStartPictureInPictureAutomaticallyFromInline = true return controller } func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {} } // 사용 예제 struct VideoPlayerScreen: View { @State private var player: AVPlayer? var body: some View { VStack { if let player = player { PlayerView(player: player) .frame(height: 300) } else { ProgressView() } } .onAppear { loadVideo() } } func loadVideo() { guard let url = URL(string: "https://example.com/video.mp4") else { return } player = AVPlayer(url: url) } }
📺 3. Picture-in-Picture
Picture-in-Picture 기능을 구현하여 비디오를 떠있는 창에서 재생합니다.
PiPPlayerView.swift — Picture-in-Picture
import SwiftUI import AVKit @Observable class PiPManager: NSObject { var pipController: AVPictureInPictureController? var isPiPActive = false var isPiPSupported = false func setupPiP(with playerLayer: AVPlayerLayer) { // PiP 지원 확인 isPiPSupported = AVPictureInPictureController.isPictureInPictureSupported() guard isPiPSupported else { return } // PiP 컨트롤러 생성 pipController = AVPictureInPictureController(playerLayer: playerLayer) pipController?.delegate = self // 오디오 세션 설정 try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback) try? AVAudioSession.sharedInstance().setActive(true) } func startPiP() { pipController?.startPictureInPicture() } func stopPiP() { pipController?.stopPictureInPicture() } } extension PiPManager: AVPictureInPictureControllerDelegate { func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { isPiPActive = true } func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { isPiPActive = false } func pictureInPictureController( _ pictureInPictureController: AVPictureInPictureController, failedToStartPictureInPictureWithError error: Error ) { print("PiP 시작 실패: \(error.localizedDescription)") } } struct PiPPlayerView: View { @State private var player: AVPlayer @State private var pipManager = PiPManager() init(url: URL) { _player = State(initialValue: AVPlayer(url: url)) } var body: some View { VStack { PiPVideoView(player: player, pipManager: pipManager) .frame(height: 300) Button { if pipManager.isPiPActive { pipManager.stopPiP() } else { pipManager.startPiP() } } label: { Label( pipManager.isPiPActive ? "PiP 종료" : "PiP 시작", systemImage: "pip" ) } .disabled(!pipManager.isPiPSupported) .buttonStyle(.borderedProminent) .padding() } } } struct PiPVideoView: UIViewRepresentable { let player: AVPlayer let pipManager: PiPManager func makeUIView(context: Context) -> UIView { let view = UIView() let playerLayer = AVPlayerLayer(player: player) playerLayer.videoGravity = .resizeAspect view.layer.addSublayer(playerLayer) // PiP 설정 pipManager.setupPiP(with: playerLayer) DispatchQueue.main.async { playerLayer.frame = view.bounds } return view } func updateUIView(_ uiView: UIView, context: Context) { if let layer = uiView.layer.sublayers?.first as? AVPlayerLayer { layer.frame = uiView.bounds } } }
📡 4. AirPlay
AirPlay를 통해 다른 기기로 비디오를 스트리밍합니다.
AirPlayView.swift — AirPlay 지원
import SwiftUI import AVKit struct AirPlayButton: UIViewRepresentable { func makeUIView(context: Context) -> AVRoutePickerView { let routePickerView = AVRoutePickerView() routePickerView.tintColor = .UIColor.systemBlue routePickerView.activeTintColor = .UIColor.systemGreen routePickerView.prioritizesVideoDevices = true return routePickerView } func updateUIView(_ uiView: AVRoutePickerView, context: Context) {} } struct VideoPlayerWithAirPlay: View { let url: URL @State private var player: AVPlayer? var body: some View { VStack { if let player = player { VideoPlayer(player: player) .frame(height: 300) } HStack { Text("AirPlay로 재생") .font(.headline) Spacer() AirPlayButton() .frame(width: 40, height: 40) } .padding() } .onAppear { setupPlayer() } } func setupPlayer() { player = AVPlayer(url: url) // AirPlay를 위한 오디오 세션 let audioSession = AVAudioSession.sharedInstance() try? audioSession.setCategory(.playback, mode: .moviePlayback) try? audioSession.setActive(true) // 외부 재생 허용 player?.allowsExternalPlayback = true player?.usesExternalPlaybackWhileExternalScreenIsActive = true } }
⚙️ 5. 커스텀 컨트롤
완전히 커스터마이즈된 재생 컨트롤을 만듭니다.
CustomPlayerView.swift — 커스텀 컨트롤
import SwiftUI import AVKit import Combine @Observable class CustomPlayerManager { var player: AVPlayer var isPlaying = false var currentTime: Double = 0 var duration: Double = 0 var playbackRate: Float = 1.0 var volume: Float = 1.0 private var timeObserver: Any? init(url: URL) { self.player = AVPlayer(url: url) setupObservers() } func setupObservers() { // 재생 시간 관찰 timeObserver = player.addPeriodicTimeObserver( forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), queue: .main ) { [weak self] time in self?.currentTime = time.seconds } // 재생 완료 감지 NotificationCenter.default.addObserver( forName: .AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: .main ) { [weak self] _ in self?.isPlaying = false self?.player.seek(to: .zero) } // 영상 길이 로드 Task { if let asset = player.currentItem?.asset, let duration = try? await asset.load(.duration) { self.duration = duration.seconds } } } func togglePlayPause() { if isPlaying { player.pause() } else { player.play() } isPlaying.toggle() } func seek(to time: Double) { let cmTime = CMTime(seconds: time, preferredTimescale: 600) player.seek(to: cmTime) } func skipForward(_ seconds: Double = 10) { seek(to: currentTime + seconds) } func skipBackward(_ seconds: Double = 10) { seek(to: max(0, currentTime - seconds)) } func changePlaybackRate(to rate: Float) { playbackRate = rate player.rate = rate } func changeVolume(to volume: Float) { self.volume = volume player.volume = volume } } struct CustomPlayerView: View { @State private var playerManager: CustomPlayerManager @State private var showControls = true init(url: URL) { _playerManager = State(initialValue: CustomPlayerManager(url: url)) } var body: some View { ZStack { // 비디오 레이어 VideoLayer(player: playerManager.player) .onTapGesture { withAnimation { showControls.toggle() } } // 커스텀 컨트롤 if showControls { VStack { Spacer() // 진행 바 VStack(spacing: 8) { Slider( value: Binding( get: { playerManager.currentTime }, set: { playerManager.seek(to: $0) } ), in: 0...max(playerManager.duration, 1) ) .tint(.white) HStack { Text(formatTime(playerManager.currentTime)) Spacer() Text(formatTime(playerManager.duration)) } .font(.caption) .foregroundStyle(.white) } .padding(.horizontal) // 재생 컨트롤 HStack(spacing: 40) { Button { playerManager.skipBackward() } label: { Image(systemName: "gobackward.10") .font(.title) } Button { playerManager.togglePlayPause() } label: { Image(systemName: playerManager.isPlaying ? "pause.fill" : "play.fill") .font(.title) } Button { playerManager.skipForward() } label: { Image(systemName: "goforward.10") .font(.title) } Menu { Button("0.5x") { playerManager.changePlaybackRate(to: 0.5) } Button("1.0x") { playerManager.changePlaybackRate(to: 1.0) } Button("1.5x") { playerManager.changePlaybackRate(to: 1.5) } Button("2.0x") { playerManager.changePlaybackRate(to: 2.0) } } label: { Image(systemName: "gauge") .font(.title) } } .foregroundStyle(.white) .padding() } .background( LinearGradient( colors: [.Color.clear, .Color.black.opacity(0.7)], startPoint: .top, endPoint: .bottom ) ) .transition(.opacity) } } .frame(height: 300) .background(Color.black) } func formatTime(_ time: Double) -> String { let minutes = Int(time) / 60 let seconds = Int(time) % 60 return String(format: "%d:%02d", minutes, seconds) } } struct VideoLayer: UIViewRepresentable { let player: AVPlayer func makeUIView(context: Context) -> UIView { let view = UIView() let playerLayer = AVPlayerLayer(player: player) playerLayer.videoGravity = .resizeAspect view.layer.addSublayer(playerLayer) DispatchQueue.main.async { playerLayer.frame = view.bounds } return view } func updateUIView(_ uiView: UIView, context: Context) { if let layer = uiView.layer.sublayers?.first as? AVPlayerLayer { layer.frame = uiView.bounds } } }
📱 종합 예제
VideoApp.swift — 비디오 앱
import SwiftUI import AVKit struct VideoApp: View { @State private var selectedVideo: VideoItem? let videos = [ VideoItem(title: "샘플 비디오 1", url: URL(string: "https://example.com/video1.mp4")!), VideoItem(title: "샘플 비디오 2", url: URL(string: "https://example.com/video2.mp4")!), ] var body: some View { NavigationStack { List(videos) { video in Button { selectedVideo = video } label: { HStack { Image(systemName: "play.rectangle.fill") .font(.largeTitle) .foregroundStyle(.blue) Text(video.title) .font(.headline) } } } .navigationTitle("비디오") .sheet(item: $selectedVideo) { video in VideoPlayerSheet(video: video) } } } } struct VideoPlayerSheet: View { let video: VideoItem @Environment(\.dismiss) var dismiss var body: some View { NavigationStack { CustomPlayerView(url: video.url) .navigationTitle(video.title) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("닫기") { dismiss() } } } } } } struct VideoItem: Identifiable { let id = UUID() let title: String let url: URL }
💡 HIG 가이드라인
- 네이티브 컨트롤: 가능하면 AVPlayerViewController의 기본 컨트롤 사용
- 제스처: 탭으로 컨트롤 표시/숨김, 스와이프로 탐색
- Picture-in-Picture: 멀티태스킹을 위해 PiP 지원
- AirPlay: 외부 디스플레이 재생 허용
- 백그라운드: Info.plist에 백그라운드 모드 추가
- 접근성: 자막 및 오디오 설명 지원
🎯 실전 활용
- 스트리밍 앱: Netflix, YouTube 스타일 플레이어
- 교육 플랫폼: 강의 비디오 재생
- 소셜 미디어: 인피드 비디오 재생
- 라이브 방송: 실시간 스트리밍 시청
- 비디오 편집: 미리보기 플레이어
📚 더 알아보기
⚡️ 성능 팁: AVPlayerViewController를 사용하면 시스템이 자동으로 최적화된 재생 경험을 제공합니다. 커스텀 컨트롤을 만들 때는
addPeriodicTimeObserver의 간격을 너무 짧게 설정하지 마세요. 0.5초 정도가 적당합니다.