
👋 들어가기 전
기본으로 제공해주는 Tabview를 이용한 Carosel은 내가 원하는 애니메이션과
화면에 보여질 상황을 연출하기 쉽지 않아 커스텀으로 많이 만들었다.
이번 시간은 Kavsoft님의 Snap Carousel을 참고하여 어떤 식으로 만들었는 지 살펴보자.
✊Snap Carousel이란
먼저 Snap Carousel이 무엇인지 살펴보자.
Snap Carousel은 사용자가 스크롤하거나 드래그할 때 특정 아이템(뷰)이
정렬되도록 고정(Snap)되는 캐러셀(Carousel) UI이다.
보통 일반적인 캐러셀(Carousel)은 부드럽게 스크롤이 되지만, Snap Carousel은 사용자가
손을 떼면 가장 가까운 아이템을 기준으로 자동 정렬되며, 페이지 형식으로 이동된다.
☝️필요한 변수
먼저 외부로부터 받을 변수를 살펴보자.
struct SnapCarousel<Content: View, T: Identifiable>: View {
private let _spacing: CGFloat // Carousel간 간격
private let _trailingPadding: CGFloat // 마지막 아이템과 오른쪽 화면간의 간격
private let _content: (T) -> Content // Carousel 내용 뷰
private let _list: [T] // 반복 데이터
@Binding var index: Int // 외부로부터 바인딩된 현재 선택된 index
@GestureState var offset: CGFloat = .zero // Gesture를 통해 움직인 정도
@State var currentIndex: Int = 0 // 현재 뷰에서 사용할 index
init(
spacing: CGFloat = 15,
trailingPadding: CGFloat = 100,
index: Binding<Int>,
items: [T],
@ViewBuilder content: @escaping (T) -> Content
) {
self._spacing = spacing
self._trailingPadding = trailingPadding
self._index = index
self._list = items
self._content = content
}
}
trailingPadding 과 spacing을 사진으로 살펴보자.

✌️뷰 구조
우리는 가로 스크롤을 하기 때문에 Hstack과 ForEach를 이용한다.
또한 width 값을 사용해야하기 때문에 GeometryReader 역시 필요하다.
GeometryReader
GeometryReader { geometry in
let width = geometry.size.width - (_trailingPadding - _spacing)
let adjustMentWidth = (_trailingPadding / 2) - _spacing
...
}
width는 케러셀 하나의 아이템 width를 나타낸다.
계산식은 다음과 같다.
width = 화면 너비 - (오른쪽 패딩 - 아이템 사이 간격)
adjustMentWidth는 아이템 정렬을 위한 조정 값이다.
자세히 살펴보자.
adjustMentWidth를 적용했을 때와 하지 않았을 때를 사진으로 살펴보자.
이전 아이템이 보이는 정도를 살펴보면 차이를 느낄 수 있다.
오른쪽이 조금 더 이전 아이템이 잘 보인다.


Offset을 이용한 Hstack 스크롤
HStack(spacing: _spacing) {
ForEach(_list) { item in
_content(item)
.frame(width: geometry.size.width - _trailingPadding)
}
}
.padding(.horizontal, _spacing)
.offset(x: (CGFloat(currentIndex) * -width) + (currentIndex != 0 ? adjustMentWidth : 0) + offset)
offset 식을 해석하면 다음과 같다.
offset(x: ...)을 이용하여 현재 인덱스(currentIndex)에 따라 HStack을 이동시킨다.
- (CGFloat(currentIndex) * -width): 현재 currentIndex에 해당하는 아이템이 화면 중앙에 오도록 이동.
- (currentIndex != 0 ? adjustMentWidth : 0): 첫 번째 아이템이 아닐 때 정렬 조정.
- + offset: 드래그할 때 움직이는 효과를 적용.
👍 DragGesture 이용
스크롤을 위해 DragGesture를 이용하여 Offset을 조절한다.
여기서 offsetX의 부호는 우리가 생각하는 방향의 반대다
-(왼 -> 오 드래그), +(오 -> 왼 드래그)
.gesture(
DragGesture()
.updating($offset, body: { value, out, _ in
out = value.translation.width
print("out: \(value.translation.width)")
})
.onChanged { value in
let offsetX = value.translation.width
let progress = -offsetX / width
let roundIndex = progress.rounded()
print(offsetX, progress, roundIndex)
index = max(min(currentIndex + Int(roundIndex), _list.count - 1), 0) // index가 범위를 벗어나지 않게
}
.onEnded({ _ in
currentIndex = index
})
)
.animation(.easeInOut, value: offset == 0)
😀 소감 및 마무리
전체 코드
struct SnapCarousel<Content: View, T: Identifiable>: View {
private let _spacing: CGFloat
private let _trailingPadding: CGFloat
private let _content: (T) -> Content
private let _list: [T]
@Binding var index: Int
@GestureState var offset: CGFloat = .zero
@State var currentIndex: Int = 0
init(
spacing: CGFloat = 15,
trailingPadding: CGFloat = 100,
index: Binding<Int>,
items: [T],
@ViewBuilder content: @escaping (T) -> Content
) {
self._spacing = spacing
self._trailingPadding = trailingPadding
self._index = index
self._list = items
self._content = content
}
var body: some View {
GeometryReader { geometry in
let width = geometry.size.width - (_trailingPadding - _spacing)
let adjustMentWidth = (_trailingPadding / 2) - _spacing
HStack(spacing: _spacing) {
ForEach(_list) { item in
_content(item)
.frame(width: geometry.size.width - _trailingPadding)
}
}
.padding(.horizontal, _spacing)
.offset(x: (CGFloat(currentIndex) * -width) + (currentIndex != 0 ? adjustMentWidth : 0) + offset)
.gesture(
DragGesture()
.updating($offset, body: { value, out, _ in
out = value.translation.width
})
.onChanged { value in
let offsetX = value.translation.width
let progress = -offsetX / width
let roundIndex = progress.rounded()
print(offsetX, progress, roundIndex)
index = max(min(currentIndex + Int(roundIndex), _list.count - 1), 0) // index가 범위를 벗어나지 않게
}
.onEnded({ _ in
currentIndex = index
})
)
.animation(.easeInOut, value: offset == 0)
}
}
}
출처
'iOS > SwiftUI' 카테고리의 다른 글
.scrollTargetLayout (0) | 2025.03.15 |
---|---|
textFiledStyle (0) | 2025.03.08 |
TabView (0) | 2025.03.05 |
UIViewRepresentable (0) | 2024.10.12 |