본문 바로가기
Dev/구현

[SwiftUI] pagerView 만들기 (iOS 버전대응)

by Callie_ 2024. 8. 14.

 

 

 

회사 프로젝트에서 스크롤 뷰로 구현되어 있던 부분에 페이징 기능을 추가해달라는 기획서 수정이 있었다. UIKit으로 구현되어 있었다면 UIPageViewController를 사용하면 되겠지만, 프로젝트를 SwiftUI 중심으로 작업 중인 상황이라 생각보다 기획서대로 개발하는 것이 까다로웠다.

 

까다로웠던 이유는 두 가지였다.

  1. SwiftUI의 ScrollView는 커스텀이 제한적이라는 점
  2. 회사 프로젝트의 최소 버전이 iOS 15 이상이라는 점

그래도 iOS 17 이상부터 애플이 ScrollView의 기능을 대폭 추가 및 개선해 주어서 사실상 문제가 없었는데, 문제는 ScrollView의 버전 대응이었다.

 

 

 

https://developer.apple.com/support/app-store

 

App Store - Support - Apple Developer

App Store The App Store makes it simple for users to discover, purchase, and download apps for iPhone, iPad, Mac, Apple TV, and Apple Watch. Enroll in the Apple Developer Program to distribute your apps worldwide on the App Store.

developer.apple.com

 

애플에서 제공하는 자료를 보면 대략 15~20%의 사용자가 iOS 17 미만을 사용하고 있었다. 기업 입장에서는 놓치기 아까운 수치이기에 버전 대응을 해줘야 했다.

 

 

여러 GitHub나 코드들을 참고하여 구현했기 때문에, 나도 정리하면서 최종적으로 이해하는 시간이 필요했다. TabView를 써야지! 했다가 양옆 여백에 좌우 아이템이 보여야 하는 조건을 충족시켜야 해서 여기저기 샘플 코드를 참고하고 만들며 삽질을 했었다.

 

그래서 이번 글에서는 iOS 17 이상에서의 페이징 기능iOS 17 미만에서의 페이징 기능 구현을 정리하고자 한다.

 

 

 


 

 

 

1.  iOS 17 미만:  ScrollView 없이 스크롤 뷰 + 페이징 기능 구현하기

ScrollView를 사용하는 대신 gesture를 활용한 방식이다.

 

 

 

 

구현 코드

 

import SwiftUI

struct CarouselViewSwiftUI: View {

    /// Carousel에서 보여줄 색상 배열
    let colors: [Color] = [.red, .blue, .green, .pink, .purple]

    /// 드래그 오프셋 저장
    @State var dragOffset: CGFloat = .zero
    /// 현재 인덱스 저장
    @State var currentIndex: Int = 0

    /// 아이템 사이 간격
    let itemSpacing: CGFloat = 12

    var body: some View {

        /// 아이템 너비
        let itemWidth = 200
        /// 현재 인덱스를 기반으로 아이템 오프셋 계산
        let currentOffset = CGFloat(currentIndex) * (CGFloat(itemWidth) + itemSpacing)

        GeometryReader { proxy in
            
            HStack(alignment: .center, spacing: itemSpacing) {
                
                ForEach(colors, id: \.self) { color in
                    color
                        .frame(width: CGFloat(itemWidth), height: 300)
                        .cornerRadius(12)
                } // forEach
                
            } // hStack
            
            // 드래그 제스처 추가
            .gesture(dragGesture)
            // 드래그 오프셋에 따라 아이템 이동
            .offset(x: 24 + dragOffset - currentOffset)
            .frame(width: proxy.size.width,
                   height: proxy.size.height,
                   alignment: .leading)
        } // geo
        .frame(height: 96)
        
    } // body
} // view

extension CarouselViewSwiftUI {

    // 드래그 제스처 정의
    private var dragGesture: some Gesture {
        DragGesture()
            .onChanged { newValue in
                // 드래그중인 오프셋 실시간 업데이트
                dragOffset = newValue.translation.width
            } // onChanged
            .onEnded { endValue in
                
                // - 드래그가 끝났을 때의 처리
                /// 드레그 인식 값
                let threshold: CGFloat = 30
                /// 인덱스 갱신
                var newIndex = currentIndex

                /// 드래그의 방향에 따라 인덱스 변경 (아이템 좌우 이동)
                if endValue.translation.width > threshold {
                    newIndex -= 1
                } else if endValue.translation.width < -threshold {
                    newIndex += 1
                }

                /// 바뀐 인덱스 범위 조절
                newIndex = max(min(newIndex, colors.count - 1), 0)

                /// 인덱스 업데이트 + 드래그 오프셋 초기화
                withAnimation(.spring()) {
                    currentIndex = newIndex
                    dragOffset = .zero
                }
                
            } // onEnded
    }
}

 

이 방식은 제스처를 트리거로 드래그 오프셋과 현재 인덱스를 관찰하여 실시간으로 바뀐 값을 저장 및 업데이트한다.

 

 

1.1 GeometryReader 사용

GeometryReader는 상위뷰나 전체 뷰 크기에 따라 하위뷰의 크기 및 위치를 정할 때 주로 사용한다. GeometryReade의 proxy로 전체 뷰에 맞춰서 스크롤 가능한 영역의 너비(proxy.size.width)를 맞추고, 드래그 시 계산한 offset 값에 맞는 컬러 인덱스로 바뀌도록 했다.

 

1.2. 스크롤 액션을 위해 offset 활용

offset은 드래그한 크기에 현재 인덱스의 오프셋값을 빼주었는데, 여기에 24를 더해놓은 이유는 leading 패딩값을 임의적으로 주기 위해서다. 원래빼면 tabView를 쓴 것처럼 드래그 액션이 일어나면 color가 왼쪽에 딱 붙는다. scrollView를 활용할 수 있었다면 해당 패딩을 주기 위해서 LazyHStack이나 HStack에 24의 패딩값을 주었을 것.

 

1.3. gesture 사용

사실 gesture를 사용하는 것이 ScrollView를 쓰지 않는 페이저 구현의 핵심이다.

 

.onChanged { newValue in
	// 드래그중인 오프셋 실시간 업데이트
    dragOffset = newValue.translation.width
}

 

드래그 된 오프셋을 드래그 제스처로 변화된 값의 translation.width로 저장하고 있다. translation은 드래그를 시작한 지점에서 현재 드래그 위치까지의 이동 거리다. 즉, translation.width는 수평으로 이동한 거리이고, translation.height는 수직으로 이동한 거리다.

 

나는 HStack을 쓰고 있고 드래그 액션이 좌우로 일어날 것이기에 translation.width를 사용했다. 그래서 드래그 액션이 일어날 때마다 드래그 오프셋을 translation.width 값으로 저장해 이동시켰다.

 

 

.onEnded { endValue in

                /// 드레그 인식 값
                let threshold: CGFloat = 30
                /// 인덱스 갱신
                var newIndex = currentIndex

                /// 드래그의 방향에 따라 인덱스 변경 (아이템 좌우 이동)
                if endValue.translation.width > threshold {
                    newIndex -= 1
                } else if endValue.translation.width < -threshold {
                    newIndex += 1
                }

                /// 바뀐 인덱스 범위 조절
                newIndex = max(min(newIndex, colors.count - 1), 0)

                /// 인덱스 업데이트 + 드래그 오프셋 초기화
                withAnimation(.spring()) {
                    currentIndex = newIndex
                    dragOffset = .zero
                }
                
} // onEnded

 

드래그 액션이 끝나면 갱신되어야 하는 값들을 onEnded에 작성하면 된다.

 

 

 

2.  iOS 17 미만: ScrollView를 써서 페이징 기능 구현하기

 

스처로 만들면 다 되는 거 아니야? 싶었지만, 스크롤 뷰를 꼭 써야 하는 상황이 생겼다. 바로, 액션이 일어났을 때 아이템 위치 조정을 자동으로 해주어야 할 때다. 보통 이런 경우 ScrollViewReader의 proxy.scrollTo(id, anchor: .center) 등을 써서 조정해 주는데, 앞서 1번 방법을 쓰면 이 쉬운 방법을 쓸 수 없었다.

 

그래서 이 부분은 팀장님과 고민을 하다가 코드의 간결성을 위해,

 

https://github.com/izakpavel/SwiftUIPagingScrollView

 

GitHub - izakpavel/SwiftUIPagingScrollView: implementation of generic paging scrollView in SwiftUI

implementation of generic paging scrollView in SwiftUI - izakpavel/SwiftUIPagingScrollView

github.com

 

위  GitHub의 PagingScrollView 파일을 사용하기로 했다. ScrollView가 제공해주는 API를 활용해야 하는 페이징 부분은 해당 코드로 대체하여 사용했다.

 

페이지 인덱스, 아이템 너비, 아이템 패딩(간격)만 알면 PagingScrollView 내의 애니메이션을 수정하여 복잡한 구현을 피하고, 코드를 뜯어 커스텀할 수도 있다.

 

 

3.  iOS 17 이상: ScrollView로 페이징 기능 구현하기

 

그리고 iOS 17부터는 애플이 scrollTargetLayout와 scrollTargetBehavior를 추가해 주어 아주 간결하게 구현할 수 있게 되었다.

이 API 기능들이 무엇이고 어떻게 적용할 수 있는지에 대해서는 아래 게시글에 따로 정리했다.

 

https://calliek.tistory.com/59

 

[SwiftUI] scrollTargetLayout과 ScrollTargetBehavior

iOS 17이전까지 ScrollView를 활용할 때 제한적인 부분이 많았다. 특히 오프셋을 직접 구현해서 페이징 기능을 커스텀으로 만들어야한다는 불편함이 있었는데, 애플에서 scrollTargetLayout과 scrollTargetBe

calliek.tistory.com

 

 

 

 

 

 


 

References

 

https://www.hackingwithswift.com/books/ios-swiftui/moving-views-with-draggesture-and-offset

 

Moving views with DragGesture and offset() - a free Hacking with iOS: SwiftUI Edition tutorial

Was this page useful? Let us know! 1 2 3 4 5

www.hackingwithswift.com

https://developer.apple.com/documentation/swiftui/draggesture/value/translation

 

translation | Apple Developer Documentation

The total translation from the start of the drag gesture to the current event of the drag gesture.

developer.apple.com