SwiftUI CollectionView
SwiftUI는 대부분의 UIKit에서 쓰던 컴포넌트들을 보다 편리하게 이용할 수 있지만, 아직 몇몇 컴포넌트들은 구현되지 않았습니다. SwiftUI에서 구현되지 않은 컴포넌트 중 UIKit에서 유용하게 사용하는 CollectionView를 어떻게 SwiftUI 프로젝트에서 사용할 지 알아봅시다.
제가 찾아낸 방법은 두 가지입니다. 첫 번째로는 UIViewRepresentable을 이용하는 것이며, 두 번째는 SwiftUI의 List를 이용한 것 입니다. ScrollView와 VStack으로도 스크롤하면서 아이템의 뷰들을 보여줄 수 있지만, 이 경우 Cell Reuse가 되지 않으므로 추천하지 않습니다.
이번 글에서는 CollectionView를 SwiftUI에 적용해보고, 아이템을 눌렀을 때, 네비게이션 컨트롤러 하의 뷰 컨트롤러가 이동하는 것까지 구현합니다.
UIViewRepresentable을 이용하는 방법
애플은 SwiftUI를 발표하며 SwiftUI에서 UIKit의 View와 ViewController를 임베딩하여 사용할 수 있도록 Representable이라는 Wrapper를 만들었습니다. SwiftUI에서 구현되지 않은 View(Controller)을 UIKit을 통해 씀은 물론 기존의 View(Controller)를 신속하게 SwiftUI에서 재활용 할 수 있습니다.
1 2 3 4 5 6 7 8 9 10 11 |
struct CollectionView: UIViewRepresentable { var data = [0,1,2,3,4,5,6,7,8,9] func makeUIView(context: Context) -> UICollectionView { } func updateUIView(_ uiView: UICollectionView, context: Context) { } } |
먼저 UIViewRepresentable의 뼈대를 잡습니다. data는 @Binding을 통해서 바인딩을 통해 받아올 수도 있습니다. makeView 함수에서 UICollectionView를 만들어줍니다. updateUIView는 binding된 데이터가 변화할 때, 뷰를 업데이트하기 위해 호출하는 함수입니다. data를 @Binding을 통해 연결했다면, uiView.reloadData() 등을 호출해서 뷰를 업데이트할 수 있습니다.
1 2 3 4 5 6 7 8 9 |
func makeUIView(context: Context) -> UICollectionView { let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) collectionView.backgroundColor = .white collectionView.dataSource = context.coordinator collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "Cell") return collectionView } |
makeUIView 함수에서는 컬렉션 뷰를 정의합니다. 일반적인 컬렉션 뷰를 만드는 것과 다를 것이 없지만, UICollectionViewDataSource를 context.coordinator로 지정합니다.
1 2 3 4 5 6 7 |
func makeCoordinator() -> Coordinator { Coordinator(self) } class Coordinator: NSObject, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { } |
ViewRepresentable 자신을 인자로 받는 Coordinator를 생성합니다. Coordinator를 통해 CollectionView의 채택한 DataSource와 Delegate를 구현합니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
class Coordinator: NSObject, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { private let parent: CollectionView init(_ collectionView: CollectionView) { self.parent = collectionView } func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { self.parent.books.count } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath) cell.backgroundColor = .blue return cell } func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { return CGSize(width: 100, height: 100) } } |
Coordinator 내부에서는 ViewController에서 구현했던 것 처럼 각 딜리게이트의 함수를 구현해주면됩니다. 이 코드에서 Coordinator의 parent는 ViewRepresentable이 됩니다.
이 상태로 SwiftUI의 코드에 ViewRepresentable로 구현한 View를 호출하면 정상적으로 표시됩니다만, 각 셀을 누르면 다른 SwiftUI의 View로 넘어가도록 하고 싶을 수 있습니다. 만약 Navigation View에서 View를 전환하고 싶다면, SwiftUI의 NavigationLink를 써야하는데, 이는 SiwftUI의 요소이므로 NavigationController에 ViewController를 Push하는 것과는 다르게 Coordinator에서 직접 사용할 수가 없습니다. @State 변수에 따라 NavigationLink가 작동하게 해야합니다.
1 2 |
@State var selection: Bool? = false @State var itemIndex: Int? |
CollectionView의 상위 뷰에 @State variable을 선언합니다. selection은 Navigation Link가 작동해야 하는지를 나타내는 상태 변수이며, itemIndex는 CollectionView에서 어떤 아이템을 클릭했는지 가져올 상태 변수입니다.
1 2 3 4 5 6 7 |
struct CollectionView: UIViewRepresentable { var data = [0, 1,2,3,4,5,6,7,8,9] @Binding var selection: Bool? @Binding var itemIndex: Int? ... } |
방금 만든 CollectionView represntable에 해당 변수를 바인딩 시켜줍니다.
1 2 3 4 |
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { parent.itemIndex = indexPath.item parent.selection = true } |
딜리게이트 함수를 통해 셀을 눌렀을 때, ViewRepresentable의 변수 값을 변화시킵니다. 이 변수는 바인딩 되어 있으므로, Reperesentable의 상위 뷰로 전달됩니다.
1 2 3 4 |
VStack{ NavigationLink(destination: AnotherView(), tag: true, selection: $selection, label: {EmptyView()}) CollectionView(selection: $selection, itemIndex: $itemIndex) } |
VStack을 이용해 CollectionView 위에 네비게이션 링크를 만듭니다. destination은 눌렀을 때 옮겨갈 View로 합니다. tag는 selection에 지정된 변수가 tag의 값과 똑같을 때, 링크로 이동함을 의미합니다. 실제로 이 링크는 누르는 Link가 아니기 때문에 빈 View를 내용물로 합니다. 그다음 CollectionView의 인자를 수정합니다. 이렇게 함으로써 셀을 클릭했을 때 뷰를 이동할 수 있습니다.
List를 이용하는 방법
SwiftUI에서는 UIKit의 Table View와 비슷한 List라는 View를 제공합니다. List는 ScrollView와 다르게 안에 들어가는 셀(뷰)를 재사용합니다. List를 이용하여 CollectionView와 비슷하게 이용합시다.
먼저 ViewModel을 정의합니다. 이때, ViewModel에서 표시하고자 하는 데이터를 2차원 배열 형태로 나타냅니다.
1 2 3 |
class ViewModel: ObservableObject { var items = [[0, 1], [2, 3], [4, 5]] } |
리스트 안에서 ForEach 문을 이중으로 작성합니다. 즉, CollectionView와는 다르게, row당 여러 아이템을 ForEach문을 이용하여 그리는 것 입니다.
1 2 3 4 5 6 7 8 9 10 11 |
List { ForEach(self.viewModel.items, id: \.self) { row in HStack { ForEach(row, id: \.self) { item in Text(String(item)) .frame(width: 180, height: 200) } } } } } |
방금 작성한 셀의 역할에 해당하는 TextView를 꾸미고, 탭 했을 때의 동작을 정의합니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
List { ForEach(self.viewModel.items, id: \.self) { row in HStack { ForEach(row, id: \.self) { item in Text(String(item)) .frame(width: 180, height: 200) .background(Color.green) .onTapGesture { self.selection = true self.itemIndex = item } } } } } |
네비게이션링크를 이용해 CollectionView의 경우 처럼 selection 여부에 따라 이동할 뷰가 보이도록 합니다.
1 |
NavigationLink(destination: Text(String(self.itemIndex)), tag: true, selection: self.$selection, label: {EmptyView()}) |
전체적인 코드는 아래와 같습니다.
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 |
import SwiftUI class ViewModel: ObservableObject { var items = [[0, 1], [2, 3], [4, 5]] } struct ExampleView: View { @ObservedObject var viewModel = ViewModel() @State var selection: Bool? = false @State var itemIndex = 0 var body: some View { NavigationView { VStack { NavigationLink(destination: Text(String(self.itemIndex)), tag: true, selection: self.$selection, label: {EmptyView()}) List { ForEach(self.viewModel.items, id: \.self) { row in HStack { ForEach(row, id: \.self) { item in Text(String(item)) .frame(width: 180, height: 200) .background(Color.green) .onTapGesture { self.selection = true self.itemIndex = item } } } } } } } } } |
필요에 따라, ViewModel의 데이터가 변하면 변할 때 마다 배열의 구조에 맞게 데이터를 삭제/추가 해주면 ListView를 CollectionView처럼 이용할 수 있습니다.