Custom Carousel 만들기

2025. 3. 16. 21:30·iOS/SwiftUI
반응형

👋 들어가기 전

기본으로 제공해주는 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' 카테고리의 다른 글

커스텀 뷰를 만들 때 고민점  (0) 2025.07.05
@Observable 매크로  (0) 2025.07.03
.scrollTargetLayout  (0) 2025.03.15
textFiledStyle  (0) 2025.03.08
TabView  (0) 2025.03.05
'iOS/SwiftUI' 카테고리의 다른 글
  • 커스텀 뷰를 만들 때 고민점
  • @Observable 매크로
  • .scrollTargetLayout
  • textFiledStyle
Hamp
Hamp
남들에게 보여주기 부끄러운 잡다한 글을 적어 나가는 자칭 기술 블로그입니다.
  • Hamp
    Hamp의 분리수거함
    Hamp
  • 전체
    오늘
    어제
    • 분류 전체보기 (304)
      • CS (30)
        • 객체지향 (2)
        • Network (7)
        • OS (6)
        • 자료구조 (1)
        • LiveStreaming (3)
        • 이미지 (1)
        • 잡다한 질문 정리 (0)
        • Hardware (2)
        • 이론 (6)
        • 컴퓨터 그래픽스 (0)
      • Firebase (3)
      • Programing Langauge (37)
        • swift (32)
        • python (4)
        • Kotlin (1)
      • iOS (132)
        • UIKit (37)
        • Combine (1)
        • SwiftUI (32)
        • Framework (7)
        • Swift Concurrency (22)
        • Tuist (6)
        • Setting (11)
        • Modularization (1)
        • Instruments (6)
      • PS (59)
        • 프로그래머스 (24)
        • 백준 (13)
        • LeetCode (19)
        • 알고리즘 (3)
      • Git (18)
        • 명령어 (4)
        • 이론 (2)
        • hooks (1)
        • config (2)
        • action (7)
      • Shell Script (2)
      • Linux (6)
        • 명령어 (5)
      • Spring (13)
        • 어노테이션 (1)
        • 튜토리얼 (11)
      • CI-CD (4)
      • Android (0)
        • Jetpack Compose (0)
      • AI (0)
        • 이론 (0)
  • 블로그 메뉴

    • 홈
    • 태그
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    dispatch
    property
    dfs
    CS
    프로그래머스
    boostcamp
    백준
    Tuist
    Spring
    GIT
    IOS
    protocol
    dp
    AVFoundation
    concurrency
    lifecycle
    UIKit
    Swift
    투포인터
    SwiftUI
  • 최근 댓글

  • 최근 글

  • 반응형
  • hELLO· Designed By정상우.v4.10.0
Hamp
Custom Carousel 만들기
상단으로

티스토리툴바