Post

SwiftUI: Representable을 이용해서 UIViewController 띄우기

[rcblock id=”5440”]

소개

SwiftUI 환경에서 _UIKit_을 이용해 만든 UIViewController를 띄우는 방법에 대한 소개입니다.

예를 들어, MPMediaPickerController (import MediaPlayer 필요)는 UIViewController를 상속받은 뷰 컨트롤러인데 UIKit 환경에서는 self.present(viewController, ...)로 바로 띄울 수 있습니다. SwiftUI에서도 이 작업이 가능하지만 복잡한 과정이 필요합니다.

 

방법

1: UIViewControllerRepresentable을 준수하는 구조체 생성

아래와 같이 UIViewControllerRepresentable를 준수(conform)하는 구조체(struct)를 생성합니다.

UIViewControllerRepresentableUIKit의 뷰 컨트롤러를 나타내는 View입니다.

일반적으로 [컨트롤러 이름] + View 라는 이름으로 구조체 이름을 작성하지만 여기서는 편의를 위해 임의로 MPMediaPickerControllerRP라는 이름으로 작성했습니다.

1
2
3
4
struct MPMediaPickerControllerRP: UIViewControllerRepresentable {
    
    // ... //
}

 

2: typealias로 나타내고자 하는 UIViewController 지정

MPMediaPickerControllerRP 구조체 안에 typealias를 추가합니다.

1
typealias UIViewControllerType = MPMediaPickerController

 

3: makeUIViewController와 updateUIViewController 추가
  • makeUIViewController
    • UIViewController를 생성하고 초기화를 수행하는 함수입니다.
  • updateUIViewController
    • UIViewController의 업데이트가 필요할 때 호출됩니다.
    • 이 위치에서 UIViewController에 필요한 데이터 또는 정보를 갱신해야 합니다.

 

이 예제에서는 makeUIViewController만 사용합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct MPMediaPickerControllerRP: UIViewControllerRepresentable {
    
    // ... //
    
    let picker = MPMediaPickerController(mediaTypes: .music)

    // ... //
    
    func makeUIViewController(context: Context) -> UIViewControllerType {
        picker.allowsPickingMultipleItems = false
        return picker
    }
    
    func updateUIViewController(_ uiViewController: MPMediaPickerController, context: Context) {}
    
    // ... //
}
  • makeUIViewController 함수에서 UIViewControllerType(=>MPMediaPickerController)를 리턴하도록 지정한 뒤, 해당 뷰 컨트롤러 인스턴스를 리턴합니다.

 

4: 버튼을 누르면 MPMediaPickerControllerRP가 나타나도록 하기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
struct ContentView: View {
    /*
     @State로 선언한 프로퍼티는 값이 변경되면 뷰 계층 구조의 부분을 업데이트
     @State를 자식 뷰에 전달하면 부모에서 값이 변경될 때마다 자식을 업데이트
     단, 자식 뷰에서 값을 수정하려면, 부모에서 자식으로 Binidng을 전달하여 자식 뷰에서 값을 수정이 가능
     */
    @State var isOpenMusicPickerView = false
    
    var albums: [AlbumInfo] = []
    var songQuery: SongQuery = SongQuery()
    
    var body: some View {
        VStack {
            // ... //
            Button {
                isOpenMusicPickerView = true
            } label: {
                Text("Select a music from library...")
            }.sheet(isPresented: $isOpenMusicPickerView) {
                MPMediaPickerControllerRP()
            }
        }
        // ... //
    }
}

  • sheet(isPresented: $isOpenMusicPickerView)
    • sheet란 아래와 같이 화면이 완전히 겹쳐지지 않고 일부분만 겹친 형태의 팝업을 말합니다.
    • @State 변수인 isOpenMusicPickerViewtrue가 되면 뷰 컨트롤러가 팝업됩니다.

 

Delegate 사용하기

MPMediaPickerController에는 다음과 같은 delegate 함수가 있습니다.

1
2
3
func mediaPicker(_ mediaPicker: MPMediaPickerController, didPickMediaItems mediaItemCollection: MPMediaItemCollection) {
    // ... //
}

앞의 섹션에서 뷰 컨트롤러를 띄우는 것 까지는 성공했는데, delegate는 어떻게 처리해야 할까요?

Coordinator라는 것을 이용하여 처리할 수 있습니다.

구체적인 방법은 다음과 같습니다.

 

사전 작업

노래의 메타데이터를 담은 구조체 MediaMetadata를 생성합니다.

1
2
3
4
5
6
7
8
9
import UIKit

struct MediaMetadata {
    let title: String
    let artist: String
    let albumTitle: String
    let duration: TimeInterval
    let albumArtImage: UIImage?
}

 

1: Representable 구조체 안에 Coordinator 이너 클래스 생성
2: Coordinator 클래스 안에 사용하고자 하는 딜리게이트 함수 추가
3: 생성자에서 metadataCallback과 delegate = self 연결
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
struct MPMediaPickerControllerRP: UIViewControllerRepresentable {
    
    // ... //

    typealias MetadataCallback = (MediaMetadata) -> Void
    var metadataCallback: MetadataCallback

    // ... //
    
    class Coordinator: NSObject, MPMediaPickerControllerDelegate {
        
        // 데이터를 전달하는 콜백(클로저) 함수
        var metadataCallback: MetadataCallback
        
        init(_ viewController: MPMediaPickerController, metadataCallback: @escaping MetadataCallback) {
            self.metadataCallback = metadataCallback
            super.init()
            viewController.delegate = self
        }
        
        // Delegate function
        func mediaPicker(_ mediaPicker: MPMediaPickerController, didPickMediaItems mediaItemCollection: MPMediaItemCollection) {
            let media = mediaItemCollection.items[0]
            let title = media.title ?? "unknown title"
            let artist = media.artist ?? "unknown artist"
            let albumTitle = media.albumTitle ?? "unknown album title"
            let duration = media.playbackDuration
            let albumArtImage = media.artwork?.image(at: .zero)
            let metadata = MediaMetadata(title: title, artist: artist, albumTitle: albumTitle, duration: duration, albumArtImage: albumArtImage)

            // 데이터 밖으로 내보내기
            metadataCallback(metadata)
            
            mediaPicker.dismiss(animated: true)
        }
    }
}
  • 대리자 함수 중 func mediaPicker(...didPickMediaItems...)를 사용할 예정이므로 해당 함수를 Coordinator 클래스 안에 추가합니다.
  • 대리자 함수 안에서 생성된 데이터를 밖에서 사용하려면 콜백(클로저) 를 사용합니다. metadataCallback이 그 역할을 합니다.

 

4: Representable 구조체 안에 makeCoordinator() 함수 추가
1
2
3
4
// Representable에서 delegate 사용
func makeCoordinator() -> Coordinator {
    Coordinator(picker, metadataCallback: metadataCallback)
}
  • 여기서 반환된 Coordinator 인스턴스를 통해 외부에서도 delegate를 사용할 수 있게 됩니다.

 

5: MPMediaPickerControllerRP()에 metadataCallback 부분 (트레일링 클로저) 추가하기
1
2
3
4
5
6
7
8
9
10
11
12
13
Button {
    isOpenMusicPickerView = true
} label: {
    Text("Select a music from library...")
}.sheet(isPresented: $isOpenMusicPickerView) {
    MPMediaPickerControllerRP { metadata in
        // coordinator에서 밖으로 꺼낸 데이터
        print("metadata outside:", metadata)
        mediaTitle = metadata.title
        mediaSubtitle = "\(metadata.artist) - \(metadata.albumTitle)"
        albumImage = metadata.albumArtImage
    }
}
  • 기존의 MPMediaPickerControllerRP() 대신 MPMediaPickerControllerRP(metadata:)로 대체합니다.
    • 트레일링 클로저를 이용해 위와 같은 형태로 축약할 수 있습니다.
  • 하이라이트된 부분과 같이 Coordinator 외부로 전송된 데이터(metadata)를 이용해 해야 할 작업을 처리합니다.

 

https://giphy.com/gifs/EIAJUrg6zH5Hj5i2Rw

 

전체 코드 (ContentView.swift)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
import SwiftUI
import MediaPlayer

struct ContentView: View {
    /*
     @State로 선언한 프로퍼티는 값이 변경되면 뷰 계층 구조의 부분을 업데이트
     @State를 자식 뷰에 전달하면 부모에서 값이 변경될 때마다 자식을 업데이트
     단, 자식 뷰에서 값을 수정하려면, 부모에서 자식으로 Binidng을 전달하여 자식 뷰에서 값을 수정이 가능
     */
    @State var statusText = "Ready to play..."
    @State var isOpenMusicPickerView = false
    @State var mediaTitle = "Select a music..."
    @State var mediaSubtitle = ""
    @State var albumImage: UIImage? = UIImage(named: "Adiemus II")
    
    var albums: [AlbumInfo] = []
    var songQuery: SongQuery = SongQuery()
    
    var body: some View {
        VStack {
            Spacer()
            // 이미지 사이즈 조정
            Image(uiImage: albumImage ?? UIImage())
                .resizable()
                .frame(width: 380, height: 380)
            Spacer()
            
            // 각종 버튼 (HStack)
            HStack {
                Spacer()
                Button {
                    statusText = "Backward"
                } label: {
                    Image(systemName: "backward.end.fill")
                        .font(.system(size: 50))
                        .foregroundColor(.black)
                }
                Spacer()
                Button {
                    statusText = "Play"
                } label: {
                    Image(systemName: "play.fill")
                        .font(.system(size: 50))
                        .foregroundColor(.black)
                }
                Spacer()
                Button {
                    statusText = "Afterward"
                } label: {
                    Image(systemName: "forward.end.fill")
                        .font(.system(size: 50))
                        .foregroundColor(.black)
                }
                Spacer()
            }
            // Spacer 높이 변경
            Spacer().frame(height: 25)
            
            Group {
                Text(mediaTitle)
                    .font(.system(size: 25, weight: .bold))
                Text(mediaSubtitle)
                    .font(.system(size: 18))
                Spacer()
                Text(statusText)
                    .foregroundColor(.gray)
            }
            
            Spacer()
            
            Button {
                isOpenMusicPickerView = true
            } label: {
                Text("Select a music from library...")
            }.sheet(isPresented: $isOpenMusicPickerView) {
                MPMediaPickerControllerRP { metadata in
                    // coordinator에서 밖으로 꺼낸 데이터
                    print("metadata outside:", metadata)
                    mediaTitle = metadata.title
                    mediaSubtitle = "\(metadata.artist) - \(metadata.albumTitle)"
                    albumImage = metadata.albumArtImage
                }
            }
        }
        .padding()
        .onAppear {
            MPMediaLibrary.requestAuthorization { status in
                switch status {
                case .notDetermined:
                    print("status: notDetermined")
                case .denied:
                    print("status: denied")
                case .restricted:
                    print("status: restricted")
                case .authorized:
                    print("status: authorized")
                    
                @unknown default:
                    print("status: unknown default")
                }
            }
        }
    }
}

struct MPMediaPickerControllerRP: UIViewControllerRepresentable {
    
    typealias UIViewControllerType = MPMediaPickerController
    typealias MetadataCallback = (MediaMetadata) -> Void
    
    let picker = MPMediaPickerController(mediaTypes: .music)
    var metadataCallback: MetadataCallback
    
    func makeUIViewController(context: Context) -> UIViewControllerType {
        picker.allowsPickingMultipleItems = false
        return picker
    }
    
    func updateUIViewController(_ uiViewController: MPMediaPickerController, context: Context) {}
    
    // Representable에서 delegate 사용
    func makeCoordinator() -> Coordinator {
        Coordinator(picker, metadataCallback: metadataCallback)
    }
    
    class Coordinator: NSObject, MPMediaPickerControllerDelegate {
        
        // 데이터를 전달하는 콜백(클로저) 함수
        var metadataCallback: MetadataCallback
        
        init(_ viewController: MPMediaPickerController, metadataCallback: @escaping MetadataCallback) {
            self.metadataCallback = metadataCallback
            super.init()
            viewController.delegate = self
        }
        
        // Delegate function
        func mediaPicker(_ mediaPicker: MPMediaPickerController, didPickMediaItems mediaItemCollection: MPMediaItemCollection) {
            let media = mediaItemCollection.items[0]
            let title = media.title ?? "unknown title"
            let artist = media.artist ?? "unknown artist"
            let albumTitle = media.albumTitle ?? "unknown album title"
            let duration = media.playbackDuration
            let albumArtImage = media.artwork?.image(at: .zero)
            let metadata = MediaMetadata(title: title, artist: artist, albumTitle: albumTitle, duration: duration, albumArtImage: albumArtImage)
            
            // 방법 1: system music player로 재생
            let musicPlayer = MPMusicPlayerController.systemMusicPlayer
            musicPlayer.setQueue(with: mediaItemCollection)
            musicPlayer.play()
            
            // 데이터 밖으로 내보내기
            metadataCallback(metadata)
            
            mediaPicker.dismiss(animated: true)
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

 

출처
This post is licensed under CC BY 4.0 by the author.