본문 바로가기
iOS/SwiftUI Tutorial

SwiftUI Tutorial 8 - Interfacing with UIKit

by 6cess 2024. 7. 10.

1. Create a view to represent a UIPageViewController

UIViewControllerRepresentable 프로토콜을 준수하는 구조체를 만든다.

UIViewControllerRepresentable이 요구하는 메서드를 구현한다.SwiftUI 는 뷰를 보여줄 준비가 되었을 때 makeUIViewController(context:)를 호출하고

updateUIViewController는 해당 뷰 컨트롤러가 업데이트되어야 할 때마다 호출한다.

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)
    }
}

 

튜토리얼에서 제공하는 이미지 파일(파일 이름이 "_feature"로 끝남)을 에셋에 추가한 후

 

데이터 모델이 되는 Landmark에 featureImage 라는 computed property를 추가한다.

var featureImage: Image? {
    isFeatured ? Image(imageName + "_feature") : nil
}

 

FeatureCard view를 구현

Landmark에 방금 추가한 프로퍼티를 통해 Image 를 선언하고 overLay modifier로 텍스트와 그라디언트를 덮는다.

struct FeatureCard: View {
    var landmark: Landmark

    var body: some View {
        landmark.featureImage?
            .resizable()
            .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()
        }
        .foregroundStyle(.white)
    }
}

#Preview {
    FeatureCard(landmark: ModelData().features[0])
        .aspectRatio(3 / 2, contentMode: .fit)
}

 

PageView 에서 PageViewController를 자식 view로 선언

import SwiftUI

struct PageView<Page: View>: View {
    var pages: [Page]

    var body: some View {
        PageViewController(pages: pages)
            .aspectRatio(3 / 2, contentMode: .fit)
    }
}

#Preview {
    PageView(pages: ModelData().features.map { FeatureCard(landmark: $0) })
}

 

2. Create the view controller's data source

PageViewController에 Coordinator 라는 중첩 클래스를 선언한다.

class Coordinator: NSObject {
    var parent: PageViewController
    var controllers = [UIViewController]()

    init(_ pageViewController: PageViewController) {
        parent = pageViewController
        controllers = parent.pages.map { UIHostingController(rootView: $0) }
    }
}

 

makeCoordinator() 메서드에서 Coonrdinator(self)를 반환.
SwiftUI는 makeUIViewController(context:)를 호출하기 전에 makeCoordinator를 호출한다.

이후 viewController를 조정할 때 Coordinator에 접근할 수 있게 된다.

updateUIViewController에서 context.coordinator.controllers 라는 변수로 접근한다.

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(
            [context.coordinator.controllers[0]], direction: .forward, animated: true)
    }

    class Coordinator: NSObject {
        var parent: PageViewController
        var controllers = [UIViewController]()

        init(_ pageViewController: PageViewController) {
            parent = pageViewController
            controllers = parent.pages.map { UIHostingController(rootView: $0) }
        }
    }
}

 

Coordinator 에 UIPageViewControllerdataSource 프로토콜을 추가. 프로토콜이 요구하는 메서드를 구현한다.

페이지의 스크롤 시 첫번째 페이지의 전 페이지, 마지막 페이지의 전 페이지를 설정하는 로직이다.

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]
        }
    }

 

마지막으로 PageViewController를 보이기 전에 makeUIViewController(context:) 메서드 내부에서 

pageViewController.Source 에 context.coordinator를 넘겨준다.

func makeUIViewController(context: Context) -> UIPageViewController {
    let pageViewController = UIPageViewController(
        transitionStyle: .scroll,
        navigationOrientation: .horizontal)
    pageViewController.dataSource = context.coordinator

    return pageViewController
}

 

3. Track the page in a SwiftUI view's state

다음으로 PageView에서 PageViewController의 currentPage를 추적하도록 구현한다.

PageView 에서 @State로 선언한 변수를 PageViewController에 전달하여 바인딩

struct PageView<Page: View>: View {
    var pages: [Page]
    @State private var currentPage = 0

    var body: some View {
        PageViewController(pages: pages, currentPage: $currentPage)
            .aspectRatio(3 / 2, contentMode: .fit)
    }
}

 

PageView의 currentPage가 업데이트되면 UIViewController도 이에 반응하도록 수정

struct PageViewController<Page: View>: UIViewControllerRepresentable {
    var pages: [Page]
    @Binding var currentPage: Int
    
    ...
    
    func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
        pageViewController.setViewControllers(
            [context.coordinator.controllers[currentPage]], direction: .forward, animated: true)
    }

 

반대로 PageViewController의 스크롤로 페이지가 바뀔 경우 currentPage도 바뀌도록 수정

UIPageViewControllerDelegate 프로토콜을 추가하여 페이지 전환이 완료되면 바인딩된 currentPage에 값이 변하도록 구현

class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate { 

    ...

    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
        }
    }
}

 

makeUIViewController(context:) 메서드에서 pageViewController.delegate에 Coordinator 전달

func makeUIViewController(context: Context) -> UIPageViewController {
    let pageViewController = UIPageViewController(
        transitionStyle: .scroll,
        navigationOrientation: .horizontal)
    pageViewController.dataSource = context.coordinator
    pageViewController.delegate = context.coordinator

    return pageViewController
}

 

4. Add a custom page control

UIViewRepresentable 프로토콜을 준수하는 PageControl 

currentPage를 바인딩

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
        }
    }
}

 

이에 PageControl과 PageView를 CategoryHome에 붙이자.

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)
        }
        .aspectRatio(3 / 2, contentMode: .fit)
    }
}
struct CategoryHome: View {
    @Environment(ModelData.self) var modelData

    var body: some View {
        NavigationSplitView {
            List {
                PageView(pages: modelData.features.map { FeatureCard(landmark: $0) })
                    .listRowInsets(EdgeInsets())
                ForEach(modelData.categories.keys.sorted(), id: \.self) { key in
                    CategoryRow(categoryName: key, items: modelData.categories[key]!)
                }
                .listRowInsets(EdgeInsets())
            }
            .navigationTitle("Featured")
        } detail: {
            Text("Select a Landmark")
        }
    }
}

#Preview {
    CategoryHome()
        .environment(ModelData())
}

 

챕터 끝나고 나니 이전 챕터를 건너뛰고 했다는걸 깨달았다...