ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [SwiftUI] 8. Interfacing with UIKit [마지막]
    iOS Dev/SwiftUI 2023. 5. 11. 01:41
    반응형

    SwiftUI로 이루어진 뷰에 UIKit 요소를 적용시킬 수 있을까?

    이번 튜토리얼에서는 UIKit 요소를 어떻게 활용할 수 있는지를 알아본다.

    Section 1. Create a View to Represent a UIPageViewController

    UIView 요소를 사용하고 싶다면 UIViewRepresentable을, UIViewController 요소를 사용하고 싶다면 UIViewControllerRepresentable 프로토콜을 채택해야 한다.

    Views 그룹에 PageView 라는 이름의 그룹을 만들고, 그 안에 PageViewController.swift 파일을 만든다.

    다음과 같은 내용으로 채워준다.

    import SwiftUI
    import UIKit
    
    struct PageViewController<Page: View>: UIViewControllerRepresentable {
        
        var pages: [Page]
        
        func makeUIViewController(context: Context) -> UIPageViewController {
            let pageViewController = UIPageViewController(
                transitionStyle: .scroll,
                navigationOrientation: .horizontal
            )
            
            return pageViewController
        }
        
        func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
            pageViewController.setViewControllers([UIHostingController(rootView: pages[0])], direction: .forward, animated: true)
        }
    }

    먼저, UIViewControllerRepresentable은 ViewController를 SwiftUI의 뷰 요소에 집어넣기 위해 채택해야 하는 프로토콜이다. 당연하게도 struct에 채택해야 하며, makeUIViewController와 updateUIViewController 메소드를 필수 구현해야 한다. 
    까보면 associatedtype으로 UIViewControllerType을 지정해주어야 하는데, 예제에서는 이 타입을 makeUIViewController 메소드 또는 updateUIViewController 메소드를 통해 UIPageViewController으로 정의하고 있다. 

    makeUIViewController는 가장 초기에 ViewController 객체를 만들고 구성하는 단계이다. 시스템은 이 메소드를 뷰 컨트롤러를 만들 때 딱 한번만 호출한다. 그 다음부터 뷰에 영향을 주는 작업에 대해 시스템은 모조리 updateUIViewController를 호출한다.
    또한, 이 메소드는 @MainActor으로 구성되어있다. 이는 UI 관련 작업이기 때문에 Main Actor 쓰레드에서 돌아야한다는 것을 명시한다. 

    다음은 updateUIViewController이다. 이 친구는 특정 ViewController의 상태 변화에 대응하는 메소드이다. 새로운 정보를 context로 받아서 업데이트 해주면 된다.

    다음은 UIHostingController이다. 간단하게 말하자면 SwiftUI 뷰 요소를 띄울 수 있는 ViewController이다. 여기서는 ViewController가 pages[0]이라는 SwiftUI View를 띄운다.

    다음으로 진행하기 전에 Asset에 feature card들을 추가해주어야 한다. 다음 파일 압축을 해제하여 등록하자.

    아카이브.zip
    1.59MB

    보면 이미 에셋에 등록되어 있는 친구들이지만 dimension이 다르다. Landmark.swift 파일로 이동하여 다음 내용을 추가한다.

    import Foundation
    import SwiftUI
    import CoreLocation
    
    struct Landmark: Hashable, Codable, Identifiable {
        var id: Int
        var name: String
        var park: String
        var state: String
        var description: String
        var isFavorite: Bool
        var isFeatured: Bool
        
        var category: Category
        enum Category: String, CaseIterable, Codable {
            case lakes = "Lakes"
            case rivers = "Rivers"
            case mountains = "Mountains"
        }
        
        private var imageName: String
        var image: Image {
            return Image(imageName)
        }
        var featureImage: Image? { // ✅
            isFeatured ? Image(imageName + "_feature") : nil
        }
        
        private var coordinates: Coordinates
        var locationCoordinate: CLLocationCoordinate2D {
            CLLocationCoordinate2D(latitude: coordinates.latitude, longitude: coordinates.longitude)
        }
        
        struct Coordinates: Hashable, Codable {
            var latitude: Double
            var longitude: Double
        }
    }

    다음, PageView 그룹에 FeatureCard.swift 파일을 추가한다.

    다음 내용을 추가한다.

    import SwiftUI
    
    struct FeatureCard: View {
        var landmark: Landmark
    
        var body: some View {
            landmark.featureImage?
                .resizable()
                .aspectRatio(3 / 2, contentMode: .fit)
        }
    }
    
    struct FeatureCard_Previews: PreviewProvider {
        static var previews: some View {
            FeatureCard(landmark: ModelData().features[0])
        }
    }

    이렇게 해놓고 preview를 해보니 안되서 뭐가 문제인가 봤더니 [0]번 사진 이름을 mosu로 바꿔놓아서 그렇다. 그럼 새로 받은 파일 이름도 바꿔야겠지? 

    문제 해결. 이제 코드를 마저 채워보자.

    import SwiftUI
    
    struct FeatureCard: View {
        var landmark: Landmark
    
        var body: some View {
            landmark.featureImage?
                .resizable()
                .aspectRatio(3 / 2, contentMode: .fit)
                .overlay {
                    TextOverlay(landmark: landmark)
                }
        }
    }
    
    struct TextOverlay: View {
        var landmark: Landmark
        
        var gradient: LinearGradient {
            .linearGradient(
                Gradient(colors: [.black.opacity(0.6), .black.opacity(0)]),
                startPoint: .bottom,
                endPoint: .center
            )
        }
        
        var body: some View {
            ZStack(alignment: .bottomLeading) {
                gradient
                VStack(alignment: .leading) {
                    Text(landmark.name)
                        .font(.title)
                        .bold()
                    Text(landmark.park)
                }
                .padding()
            }
            .foregroundColor(.white)
        }
    }
    
    struct FeatureCard_Previews: PreviewProvider {
        static var previews: some View {
            FeatureCard(landmark: ModelData().features[0])
        }
    }

    한 가지 이상한 점이 눈에 띄었다. ZStack 부분의 gradient이다.
    gradient를 주석처리 하고 결과를 한번 보자.

    Text가 중앙으로 올라왔다. 분명 alignment는 bottomLeading이고, overLay도 이미지에서 적용했는데 왜 중앙으로 올까?
    먼저 알아야 할 것은 SwiftUI가 뷰를 배치할 때 부모는 자식의 크기와 위치를 제안하고, 자식은 해당 제안을 고려해서 크기와 위치를 결정해서 알려준다. 부모는 그 결정을 보고 뷰에 적절히 배치한다.
    이제 LinearGradient 코드를 까보자. The gradient maps the unit space points into the bounding rectangle of each shape filled with the gradient. 라고 적혀있다. 아하! 결국 꽉 찬 rectangle을 만들어놓는거랑 똑같은 것이다.
    Rectangle에 frame 등을 지정하지 않는다면 layout neutral, 즉 레이아웃 중립 상태이기 때문에, 부모 뷰인 imageView와 동일한 사이즈를 갖게 된다. 따라서 text를 포함한 VStack을 놓을 때 전체 imageView 영역에서 판단하게 된다. 순서가 바뀌어도 레아이웃은 동일하게 적용된다. (어차피 다 같은 ZStack의 자식들이니.)
    gradient를 뺀 상황에서는 Text를 포함한 VStack이 Text가 필요한 사이즈 만큼만 갖기 때문에 중앙에 놓이게 된다.

    가설을 증명하기 위해 gradient를 잠시 지워두고 clear한 Rectangle() 하나를 둬봤다.

        var body: some View {
            ZStack(alignment: .bottomLeading) {
    //            gradient
                Rectangle()
                    .fill(.clear)
                VStack(alignment: .leading) {
                    Text(landmark.name)
                        .font(.title)
                        .bold()
                    Text(landmark.park)
                }
                .padding()
            }
            .foregroundColor(.white)
        }

    순서를 바꾸면?

        var body: some View {
            ZStack(alignment: .bottomLeading) {
    //            gradient
                VStack(alignment: .leading) {
                    Text(landmark.name)
                        .font(.title)
                        .bold()
                    Text(landmark.park)
                }
                .padding()
                Rectangle()
                    .fill(.clear)
            }
            .foregroundColor(.white)
        }

    역시나 똑같다. 증명 완료.

    다음 PageView 그룹에 PageView.swift 파일을 만들자.

    다음 내용으로 채우자.

    import SwiftUI
    
    struct PageView<Page: View>: View {
        var pages: [Page]
        
        var body: some View {
            PageViewController(pages: pages)
        }
    }
    
    struct PageView_Previews: PreviewProvider {
        static var previews: some View {
            PageView(pages: ModelData().features.map {
                FeatureCard(landmark: $0)
            })
            .aspectRatio(3 / 2, contentMode: .fit)
        }
    }

     

    Section 2. Create the View Controller's Data Source

    PageViewController.swift 에 다음 내용을 추가한다.

    import SwiftUI
    import UIKit
    
    struct PageViewController<Page: View>: UIViewControllerRepresentable {
        
        var pages: [Page]
        
        func makeCoordinator() -> Coordinator {
            Coordinator(self)
        }
        
        func makeUIViewController(context: Context) -> UIPageViewController {
            let pageViewController = UIPageViewController(
                transitionStyle: .scroll,
                navigationOrientation: .horizontal
            )
            
            return pageViewController
        }
        
        func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
            pageViewController.setViewControllers([UIHostingController(rootView: pages[0])], direction: .forward, animated: true)
        }
        
        class Coordinator: NSObject {
            var parent: PageViewController
            
            init(_ pageViewController: PageViewController) {
                parent = pageViewController
            }
        }
    }

    Coordinator 요소가 추가되었다. UIViewControllerRepresentable에서 Coordinator를 추가로 구현한다는 것은, 다른 SwiftUI 요소와 상호작용할 일이 생길 때만 넣어주면 된다. 이 예제에서는 화면 간 전환이 이루어지기 때문에 추가한 것으로 보인다.
    추가로, makeCoordinator() 메소드는 앞서 언급한 makeUIViewController() 메소드가 호출되기 전에 시스템에서 호출한다.

    이제 마저 코드를 채워 넣는다.

    import SwiftUI
    import UIKit
    
    struct PageViewController<Page: View>: UIViewControllerRepresentable {
        
        var pages: [Page]
        
        func makeCoordinator() -> Coordinator {
            Coordinator(self)
        }
        
        func makeUIViewController(context: Context) -> UIPageViewController {
            let pageViewController = UIPageViewController(
                transitionStyle: .scroll,
                navigationOrientation: .horizontal
            )
            pageViewController.dataSource = context.coordinator
            return pageViewController
        }
        
        func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
            pageViewController.setViewControllers([context.coordinator.controllers[0]], direction: .forward, animated: true)
        }
        
        class Coordinator: NSObject, UIPageViewControllerDataSource {
            var parent: PageViewController
            var controllers = [UIViewController]()
            
            init(_ pageViewController: PageViewController) {
                parent = pageViewController
                controllers = parent.pages.map { UIHostingController(rootView: $0) }
            }
            
            func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
                guard let index = controllers.firstIndex(of: viewController) else {
                    return nil
                }
                
                if index == 0 {
                    return controllers.last
                }
                return controllers[index - 1]
            }
            
            func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
                guard let index = controllers.firstIndex(of: viewController) else {
                    return nil
                }
                
                if index + 1 == controllers.count {
                    return controllers.first
                }
                return controllers[index + 1]
            }
        }
    }

    다시 PageView.swift로 와서 preview를 보자. 스크롤이 잘 되는 것을 확인할 수 있다.

     

    Section 3. Track the Page in a SwiftUI View's State

    UIPageControl 을 써보기 전에 먼저 state의 binding을 ViewController에 어떻게 시키는지 알아본다.

    PageViewController.swift로 가서 다음을 수정한다.

    import SwiftUI
    import UIKit
    
    struct PageViewController<Page: View>: UIViewControllerRepresentable {
        
        var pages: [Page]
        @Binding var currentPage: Int // ✅
        
        func makeCoordinator() -> Coordinator {
            Coordinator(self)
        }
        
        func makeUIViewController(context: Context) -> UIPageViewController {
            let pageViewController = UIPageViewController(
                transitionStyle: .scroll,
                navigationOrientation: .horizontal
            )
            pageViewController.dataSource = context.coordinator
            return pageViewController
        }
        
        func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
            pageViewController.setViewControllers([context.coordinator.controllers[currentPage]], direction: .forward, animated: true) // ✅
        }
        
        class Coordinator: NSObject, UIPageViewControllerDataSource {
            var parent: PageViewController
            var controllers = [UIViewController]()
            
            init(_ pageViewController: PageViewController) {
                parent = pageViewController
                controllers = parent.pages.map { UIHostingController(rootView: $0) }
            }
            
            func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
                guard let index = controllers.firstIndex(of: viewController) else {
                    return nil
                }
                
                if index == 0 {
                    return controllers.last
                }
                return controllers[index - 1]
            }
            
            func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
                guard let index = controllers.firstIndex(of: viewController) else {
                    return nil
                }
                
                if index + 1 == controllers.count {
                    return controllers.first
                }
                return controllers[index + 1]
            }
        }
    }

    상위 뷰인 PageView.swift 파일로 가서 state를 추가해준다. 뷰도 살짝 추가해준다.

    import SwiftUI
    
    struct PageView<Page: View>: View {
        var pages: [Page]
        @State private var currentPage = 0
        
        var body: some View {
            VStack {
                PageViewController(pages: pages, currentPage: $currentPage)
                Text("Current Page: \(currentPage)")
            }
            
        }
    }
    
    struct PageView_Previews: PreviewProvider {
        static var previews: some View {
            PageView(pages: ModelData().features.map {
                FeatureCard(landmark: $0)
            })
            .aspectRatio(3 / 2, contentMode: .fit)
        }
    }

    다시 PageViewController.swift로 가서 Delegate를 추가 적용시켜준다.

    import SwiftUI
    import UIKit
    
    struct PageViewController<Page: View>: UIViewControllerRepresentable {
        
        var pages: [Page]
        @Binding var currentPage: Int
        
        func makeCoordinator() -> Coordinator {
            Coordinator(self)
        }
        
        func makeUIViewController(context: Context) -> UIPageViewController {
            let pageViewController = UIPageViewController(
                transitionStyle: .scroll,
                navigationOrientation: .horizontal
            )
            pageViewController.dataSource = context.coordinator
            pageViewController.delegate = context.coordinator // ✅
            return pageViewController
        }
        
        func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
            pageViewController.setViewControllers([context.coordinator.controllers[currentPage]], direction: .forward, animated: true)
        }
        
        class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
            var parent: PageViewController
            var controllers = [UIViewController]()
            
            init(_ pageViewController: PageViewController) {
                parent = pageViewController
                controllers = parent.pages.map { UIHostingController(rootView: $0) }
            }
            
            func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
                guard let index = controllers.firstIndex(of: viewController) else {
                    return nil
                }
                
                if index == 0 {
                    return controllers.last
                }
                return controllers[index - 1]
            }
            
            func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
                guard let index = controllers.firstIndex(of: viewController) else {
                    return nil
                }
                
                if index + 1 == controllers.count {
                    return controllers.first
                }
                return controllers[index + 1]
            }
            
            func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) { // ✅
                if completed,
                   let visibleViewController = pageViewController.viewControllers?.first,
                   let index = controllers.firstIndex(of: visibleViewController) {
                    parent.currentPage = index
                }
            }
        }
    }

     

    Section 4. Add a Custom Page Control

    UIPageControl을 적용해보자.

    PageView 그룹에 PageControl.swift 파일을 만들자.

    다음과 같이 내용을 채운다.

    import SwiftUI
    import UIKit
    
    struct PageControl: UIViewRepresentable {
        var numberOfPages: Int
        @Binding var currentPage: Int
        
        func makeUIView(context: Context) -> UIPageControl {
            let control = UIPageControl()
            control.numberOfPages = numberOfPages
            
            return control
        }
        
        func updateUIView(_ uiView: UIPageControl, context: Context) {
            uiView.currentPage = currentPage
        }
    }

    다시 PageView.swift로 가서 다음을 수정한다.

    import SwiftUI
    
    struct PageView<Page: View>: View {
        var pages: [Page]
        @State private var currentPage = 0
        
        var body: some View {
            ZStack(alignment: .bottomTrailing) {
                PageViewController(pages: pages, currentPage: $currentPage)
                PageControl(numberOfPages: pages.count, currentPage: $currentPage)
                    .frame(width: CGFloat(pages.count * 18))
                    .padding(.trailing)
            }
            
        }
    }
    
    struct PageView_Previews: PreviewProvider {
        static var previews: some View {
            PageView(pages: ModelData().features.map {
                FeatureCard(landmark: $0)
            })
            .aspectRatio(3 / 2, contentMode: .fit)
        }
    }

    이제 PageControl.swift 파일로 돌아가서 coordinator 작업을 진행한다.

    import SwiftUI
    import UIKit
    
    struct PageControl: UIViewRepresentable {
        var numberOfPages: Int
        @Binding var currentPage: Int
        
        func makeCoordinator() -> Coordinator {
            Coordinator(self)
        }
        
        func makeUIView(context: Context) -> UIPageControl {
            let control = UIPageControl()
            control.numberOfPages = numberOfPages
            control.addTarget(context.coordinator, action: #selector(Coordinator.updateCurrentPage(sender:)), for: .valueChanged)
            return control
        }
        
        func updateUIView(_ uiView: UIPageControl, context: Context) {
            uiView.currentPage = currentPage
        }
        
        class Coordinator: NSObject {
            var control: PageControl
            
            init(_ control: PageControl) {
                self.control = control
            }
            
            @objc
            func updateCurrentPage(sender: UIPageControl) {
                control.currentPage = sender.currentPage
            }
        }
    }

    objc 메소드도 등장한다!

    지금까지 한 내용을 CategoryHome.swift에 등록하자.

    import SwiftUI
    
    struct CategoryHome: View {
        @EnvironmentObject var modelData: ModelData
        @State private var showingProfile = false
    
        var body: some View {
            NavigationStack {
                List {
                    PageView(pages: modelData.features.map { FeatureCard(landmark: $0) }) // ✅
                        .aspectRatio(3 / 2, contentMode: .fit)
                        .listRowInsets(EdgeInsets())
                    ForEach(modelData.categories.keys.sorted(), id: \.self) { key in
                        CategoryRow(categoryName: key, items: modelData.categories[key] ?? [])
                    }
                    .listRowInsets(EdgeInsets())
                }
                .listStyle(.inset)
                .navigationTitle("Featured")
                .toolbar {
                    Button {
                        showingProfile.toggle()
                    } label: {
                        Label("User Profile", systemImage: "person.crop.circle")
                    }
                }
                .sheet(isPresented: $showingProfile) {
                    ProfileHost()
                        .environmentObject(modelData)
                }
            }
        }
    }
    
    struct CategoryHome_Previews: PreviewProvider {
        static var previews: some View {
            CategoryHome()
                .environmentObject(ModelData())
        }
    }

    끝!!

     

    Check Your Understanding

    답은 3 3

    반응형

    댓글

Designed by Tistory.