Dev-iOS/UI

[UI] pull to refresh - (SwiftUI 2.0 - iOS 14 이하)

lafortune 2023. 7. 12. 15:51
반응형

SwiftUI에서 ScrollView를 Safari와 같이 스크롤을 아래로 내리면 새로고침을 하는 pull to refresh를 구현해보겠습니다.

 

SwiftUI 3.0 - iOS 15 에서는 .refreshable Modifier를 이용해서 ScrollView 간단하게 사용할 수 있지만 iOS 15 미만에서는 SwiftUI에서 해당 기능을 제공하지 않기 때문에 UIKit의 UIScrollView를 이용해서 구현해야 합니다.

 

UIViewRepresentable

UIKit의 UIScrollView와 UIRefreshControl을 활용해야 합니다. UIKit의 뷰를 SwiftUI에서 사용할 수 있도록 UIViewRepresentable 프로토콜을 사용합니다.

 

struct RefreshableScrollView<Content: View>: UIViewRepresentable {
    var content: Content
    var onRefresh: (UIRefreshControl) -> ()
    var refreshControl = UIRefreshControl()

    init(@ViewBuilder content: @escaping () -> Content, onRefresh: @escaping (UIRefreshControl) -> ()) {
        self.content = content()
        self.onRefresh = onRefresh
    }

    func makeCoordinator() -> Coordinator {
        return Coordinator(parent: self)
    }

    func makeUIView(context: Context) -> UIScrollView {
        let uiScrollView = UIScrollView()

        refreshControl.attributedTitle = NSAttributedString(string: "더 보기")
        refreshControl.tintColor = .blue
        refreshControl.addTarget(context.coordinator, action: #selector(context.coordinator.onRefresh), for: .valueChanged)
        uiScrollView.refreshControl = refreshControl

        let hostingView = UIHostingController(rootView: content.frame(maxHeight: .infinity, alignment: .top))

        hostingView.view.translatesAutoresizingMaskIntoConstraints = false
        let constraints = [
            hostingView.view.topAnchor.constraint(equalTo: uiScrollView.topAnchor),
            hostingView.view.bottomAnchor.constraint(equalTo: uiScrollView.bottomAnchor),
            hostingView.view.leadingAnchor.constraint(equalTo: uiScrollView.leadingAnchor),
            hostingView.view.trailingAnchor.constraint(equalTo: uiScrollView.trailingAnchor),

            hostingView.view.widthAnchor.constraint(equalTo: uiScrollView.widthAnchor),
            hostingView.view.heightAnchor.constraint(greaterThanOrEqualTo: uiScrollView.heightAnchor, constant: 1),
        ]

        uiScrollView.addConstraints(constraints)
        uiScrollView.addSubview(hostingView.view)

        return uiScrollView
    }

    func updateUIView(_ uiView: UIViewType, context: Context) {

    }

    class Coordinator {
        var parent: RefreshableScrollView

        init(parent: RefreshableScrollView) {
            self.parent = parent
        }

        @objc func onRefresh() {
            parent.onRefresh(parent.refreshControl)
        }
    }
}

RefreshableScrollView 뷰는 generic 타입 Content를 사용하여 어떤 SwiftUI View도 넣을 수 있는 컨테이너 뷰를 구성하였습니다.

 

그리고 두 개의 클로저를 매개변수로 받는 초기화 함수를 가지며, 첫 번째 클로저는 ScrollView에 포함될 SwiftUI 뷰를 정의하고, 두 번째 클로저는 새로 고침이 발생했을 때 수행할 액션을 정의합니다.

 

makeUIView 함수에서 UIScrollView를 생성하고, 새로 고침 기능을 가진 UIRefreshControl을 설정합니다. 그리고 UIHostingController를 사용하여 SwiftUI View를 UIScrollView에 추가합니다. init 함수에서 전달받은 ScrollView에 포함될  SwiftUI View는 ScrollView에 꽉 차도록 하며, 윗 정렬을 하기 위해서

 .frame(maxHeight: .infinity, alignment: .top)

를 사용했습니다.

 

그리고 이 SwiftUI View는 ScrollView의 영역에 딱 맞게 구성하기 위해서 contraints를 top, bottom, leading, trailing들의 anchor에 일치시켰습니다. 너비도 마찬가지로 일치시켰고, 높이같은 경우는 greaterThanOrEqualTo를 사용했고, constant를 1로 했습니다.

UIScrollView는 그 내부의 콘텐츠가 스크롤 뷰 자체의 크기보다 클 경우에만 스크롤이 가능합니다.
greaterThanOrEqualTo로 해야지만 UIScrollView의 콘텐츠 뷰(hostingView.view)의 높이가 UIScrollView 자체의 높이보다 같거나 크도록 제약 조건을 설정해서 hostingView.view의 높이가 UIScrollView의 높이와 같거나 크게 만들어서 스크롤이 가능하도록 합니다. 만약 eqaulTo를 사용했을 경우에는 hostingView.view의 높이가 UIScrollView의 높이보다 작으면 스크롤이 발생하지 않을 수 있기 때문입니다.

constant를 1로 하면 hostingView.view의 높이를 최소한 UIScrollView의 높이보다 1pt 더 크게 만듭니다. 이렇게 하면 UIScrollView의 높이와 콘텐츠 뷰의 높이가 정확히 같은 경우에도 스크롤이 가능하도록 합니다.

즉, UIScrollView의 콘텐츠 뷰가 UIScrollView 자체의 높이보다 크거나 같도록 하여 스크롤이 항상 가능하게 만드는 역할을 합니다.

 

그리고 Coordinator을 사용하여 UIRefreshControl의 새로 고침 액션을 처리합니다. UIRefreshControl은 @objc 메서드를 호출하기 때문에, 이를 처리하기 위해 Coordinator가 필요합니다.

 

 

 

사용

위에서 만든 RefreshableScrollView는 SwiftUI에서 아래와 같이 사용할 수 있습니다. RefreshableScrollView의 첫번째 인자에는 ScrollView에 담길 View를 구성하고, 두번째 인자인 onRefresh에서는 refresh가 발생하는 동안 작업할 코드를 작성하고 이 작업이 완료되면 endRefreshing을 하여 refresh를 종료합니다.

struct PullToRefresh: View {
    var body: some View {
        RefreshableScrollView {
            VStack {
                ForEach(1...10, id: \.self) { index in
                    Color.blue
                        .frame(height: 200)
                }
            }
            .padding()
        } onRefresh: { control in
            /// Network 요청
            DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
                control.endRefreshing()
            }
        }

    }
}

 

 

반응형