SwiftUI를 사용하다보면 가끔.. 가끔 한번씩 UIKit이 그리워질 때가 있다.
(e.g. 도대체 ScrollView bounce 없앨 수 있는 옵션은 왜 지원 안 하는 거야)
이런 걸 염두에 둔 건지 UIViewRepresentable 프로토콜을 구현하면 SwiftUI에서도 UIKit의 요소를 사용할 수 있는데,,
'스유도 낯선데.. 너란 놈 더 낯설다..'
'SwiftUI를 쓰면서 UIKit을 끌고 오는 건 Swift 쓰면서 Objective-C 끌고 오는 느낌 아닌가?'
'웬만하면 스유 울타리에서 해결하는 게 좋은거 아닌가?'
라는 생각으로 그동안 크게 눈여겨보진 않았다.
하지만.. 그래도 써야만 하는 순간이 있다.. 바로지금 롸잇나우
그런데 막상 사용해보니 UIKit이랑 크게 다를 것도 없고 그렇다고 스유에서 벗어나는 느낌도 아니었다.
앞으로도 스유 사용하다가 UIKit 그리워질때 유용하게 사용할 것 같음!
SwiftUI에서도 VideoPlayer라고 동영상을 재생할 수 있는 View를 제공하지만,
- Fullscreen 버튼 없음
- AirPlay 버튼 제거 안 됨
- Gesture 잘 안 됨
당면한 문제들
특히 마지막 제스처 쪽이 기본 플레이어의 showControl 쪽이랑 충돌하는지 당최 매끄럽게 동작하지를 않더라
결과: 그래 그냥 내가 다 하나하나 갖다 붙일란다
17년도 쯤에도 플레이어 갖고 이케저케 애쓰다가 결국 커스텀으로 구현했던 것 같은데 기시감이..
1. UIViewRepresentable
struct SwiftUIView: UIViewRepresentable {
func makeUIView(context: Context) -> UIView {
}
func updateUIView(_ uiView: UIView, context: Context) {
}
}
UIViewRepresentable 프로토콜을 상속하려면 위 두 개의 메소드를 필수로 구현해야 한다.
- makeUIView에는 내가 그리고 싶은 UIView 타입 클래스를 리턴해주면 되고,
- updateUIView는 SwiftUI View의 State가 바뀔 때마다 호출되는 곳으로, UIView의 업데이트가 필요하다면 해당 알고리즘을 작성하면 된다!
//---------------------------------------------------------------------------------
// MARK: - UIKit Player
//---------------------------------------------------------------------------------
fileprivate struct LocalPlayerView: UIViewRepresentable {
let player: AVPlayer?
func makeUIView(context: Context) -> UIView {
return PlayerUIView()
}
func updateUIView(_ uiView: UIView, context: Context) {
guard let playerLayerView = uiView as? PlayerUIView else { return }
playerLayerView.setPlayer(player)
}
}
fileprivate class PlayerUIView: UIView {
private var playerLayer: AVPlayerLayer?
override init(frame: CGRect) {
super.init(frame: frame)
setupPlayerLayer()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupPlayerLayer()
}
private func setupPlayerLayer() {
playerLayer = AVPlayerLayer()
playerLayer?.videoGravity = .resizeAspect
layer.addSublayer(playerLayer!)
}
func setPlayer(_ player: AVPlayer?) {
playerLayer?.player = player
playerLayer?.frame = bounds
}
override func layoutSubviews() {
super.layoutSubviews()
playerLayer?.frame = bounds
}
}
요런 식으로!
나 같은 경우는 볼륨 슬라이더를 최종적으로는 제외해서 지운 코드이긴 한데,
만약 디바이스 볼륨을 컨트롤 하고 싶다면 MPVolumeView를 사용해야 해서 그것도 기재해두자면,
fileprivate struct VolumeSlider: UIViewRepresentable {
func makeUIView(context: Context) -> MPVolumeView {
let volumeView = MPVolumeView()
volumeView.showsRouteButton = false // AirPlay 버튼 숨기기
volumeView.showsVolumeSlider = true // 볼륨 슬라이더 표시
return volumeView
}
func updateUIView(_ uiView: MPVolumeView, context: Context) {}
}
뭐 UIViewRepresentable 사용 예시는 이정도면 넉넉한것 같다
(참고로 적자면 player.volume을 통해서도 음량 조절이 가능하긴 하다.
다만 이 경우는 플레이어의 소리만 줄이는 거라서,
1. 디바이스 볼륨과 별개로 동작함
2. 대신 커스텀 슬라이더를 사용하거나 음소거 버튼을 마음대로 갖다 붙이거나 할 수 있음)
2. 동영상 플레이어 기능
2-a. 플레이어의 기능
동영상 플레이어를 만들기 위해 시작된 작업이었으니 추가로 기재해두면 좋을 것 같은 것도 적어둔다.
// 앞뒤 탐색(seek) 함수
private func seek(seconds: Int64) {
guard let player = player else { return }
let currentTime = player.currentTime().seconds
let newTime = CMTime(seconds: currentTime + Double(seconds), preferredTimescale: 600)
// 허용 오차를 zero로 설정하여 정확히 지정한 시간으로 이동하도록 시도
player.seek(to: newTime, toleranceBefore: .zero, toleranceAfter: .zero)
}
tolerance 옵션 없이 그냥 player.seek를 사용하게 되면 가끔 10초를 이동시켰는데 8초가 이동될 때도 있었다.
저렇게 수정하고 난 뒤로는 10초씩 정상동작해서 따로 기재해봤다.
2-b. Player Observer
// 재생시간 & 전체 재생시간
player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 1, preferredTimescale: 600), queue: .main) { [weak self] time in
guard let self = self else { return }
playbackTime = time.seconds
if let currentItem = player.currentItem {
let itemDuration = currentItem.duration
if CMTIME_IS_NUMERIC(itemDuration) {
let seconds = itemDuration.seconds
// NaN이 아닌 경우에만 값을 사용
duration = seconds.isNaN ? 0 : seconds
}
} else {
// 재생할 미디어가 아직 로드되지 않았거나, 미디어의 길이를 알 수 없는 경우, currentItem.duration이 NaN 반환됨
duration = 0
}
}
// 재생 상태 감지
player.observe(\.timeControlStatus, options: [.initial, .new]) { [weak self] player, change in
guard let self = self else { return }
switch player.timeControlStatus {
case .paused:
isPlaying = false
case .playing:
isPlaying = true
case .waitingToPlayAtSpecifiedRate:
break
@unknown default:
break
}
}
// 재생완료 감지
NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: .main) { [weak self] _ in
guard let self = self else { return }
player.seek(to: .zero)
// 반복 재생
if isLooping {
player.play()
}
}
전부 옵저버 객체를 변환하는 형태라서 onDisappear에서 해제할 수 있다.
3. Full Screen
재생, 탐색 이런 기능들은 딱히 기재 안 해도 쉽게 적용할 수 있을 것 같고,
전체 화면 기능도 화면 회전 관련하여 좀 애먹었으므로 적어둔다22
AppDelegate에 지원 방향에 대한 변수를 만들어두고,
전체 화면에 들어갈 때만 이 변수를 LandScape, 화면 벗어날 때는 다시 Portrait으로 바꿔주는 작업을 하면 된다.
혹시라도 설마 AppDelegate가 없다면.. 금방 간단하게 추가할 수 있으니 구글링 ㄱㄱ
class AppDelegate: NSObject, UIApplicationDelegate {
static var orientationLock = UIInterfaceOrientationMask.portrait
func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
return AppDelegate.orientationLock
}
}
//---------------------------------------------------------------------------------
// MARK: - 전체화면
//---------------------------------------------------------------------------------
fileprivate struct FullScreenVideoPlayerView: View {
var player: AVPlayer?
@Binding var isFullScreen: Bool
var body: some View {
HStack(alignment: .bottom, spacing: 0) {
Rectangle()
.frame(width: 24)
if let player = player {
VideoPlayer(player: player)
}
VStack {
Button(action: {
isFullScreen = false // 전체화면 모드 해제
setPortraitOrientation()
}) {
Image(systemName: "arrow.down.right.and.arrow.up.left")
.foregroundColor(.white)
.frame(width: 24)
.padding()
}
Spacer()
}
}
.ignoresSafeArea()
.background(Color.black)
.onAppear {
setLandscapeOrientation()
}
}
/// Landscape 모드로 변경
private func setLandscapeOrientation() {
AppDelegate.orientationLock = .landscape
UIDevice.current.setValue(UIInterfaceOrientation.landscapeRight.rawValue, forKey: "orientation")
}
/// Portrait 모드로 복원
private func setPortraitOrientation() {
AppDelegate.orientationLock = .portrait
UIDevice.current.setValue(UIInterfaceOrientation.portrait.rawValue, forKey: "orientation")
}
}
작성한 FullScreenVideoPlayerView는 .fullScreenCover(isPresented:)로 띄워줬다.
위 코드에서는 기본 VideoPlayer 사용했는데 열심히 만들어둔 커스텀 사용해도 완전 무방!
<도움 받은 링크들>
'개발노트 > iOS' 카테고리의 다른 글
SwiftUI의 Navigation - iPad version (feat. Custom Alert popup) (0) | 2024.11.10 |
---|---|
SwiftUI의 Navigation(화면 전환) - 선언형 구조와 명령형 구조의 차이 (0) | 2024.10.28 |
SwiftUI와 MVI (2) (0) | 2024.10.20 |
SwiftUI와 MVI (1) (3) | 2024.10.15 |
UINavigationBar left title & right bar buttons (0) | 2022.12.21 |