ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • SwiftUI - Infinity Carousel View (feat. TCA)
    SwiftUI 2022. 11. 17. 15:51

    안녕하세요. 그린입니다🍏

    이번 포스팅에서는 SwiftUI에서 Infinity Carousel View를 만드는 학습을 해보겠습니다🙌

     

    UIKit에서는 쉽게 구현 가능한 Infinity Carousel View를 SwiftUI에서는 조금 까다롭더라구요 구현하기가😭

    그래서 여러 레퍼들을 참고하여 TCA에 녹여 구현해봤습니다.

     

    이번에는 설명은 크게 없고 코드로 보는게 좋을것 같아요!

    전체적으로 Carousel Core/View가 있고 이를 사용하는 부분에서 Pullback하여 얹어줍니다.

    제 코드에서는 Main에서 이를 해주고 있어요.

     

    그럼 바로 코드 보시죠!
    Carousel View부터 보겠습니다.

     

    Carousel View

    더보기
    import ComposableArchitecture
    import SwiftUI
    
    public struct InfiniteCarouselView: View {
      let store: Store<CarouselState, CarouselAction>
      
      public var body: some View {
        WithViewStore(store) { viewStore in
          TabView(
            selection: .init(
              get: { viewStore.state.index },
              set: { viewStore.send(.setIndex($0)) }
            )
          ) {
            ForEach(viewStore.state.tabs, id: \.hashValue) { tab in
              VStack {
                Spacer()
                
                Text(tab.title)
                  .font(.largeTitle)
                
                Spacer()
              }
              .frame(maxWidth: .infinity, maxHeight: .infinity)
              .background(tab.bgColor)
              .tag(
                getIndex(
                  tabs: viewStore.state.tabs,
                  tab: tab
                )
              )
            }
          }
          .tabViewStyle(.page(indexDisplayMode: .never))
          .frame(height: 300)
          .onAppear {
            viewStore.send(.onAppear)
          }
          .onChange(of: viewStore.state.index) { _ in
            viewStore.send(.onChange)
          }
          .animation(.easeInOut, value: UUID())
          .simultaneousGesture(
            DragGesture()
              .onChanged { _ in
                viewStore.send(.startUserScroll)
              }
          )
        }
      }
    }

    기본적으로 TabView로 구성하고 있으며 page 형태로 나타내주었어요.

    이제 주요한 로직인 Core를 보시죠!

     

    Carousel Core

    더보기
    import Foundation
    import ComposableArchitecture
    
    struct CarouselState: Equatable {
      public var tabs: [Tab]
      public var index: Int
      
      init(
        tabs: [Tab],
        index: Int = 0
      ) {
        self.tabs = tabs
        self.index = index
      }
    }
    
    enum CarouselAction: Equatable {
      case onAppear
      case onChange
      
      case setIndex(Int)
      case nextIndex
      case incrementIndex
      
      case startUserScroll
    }
    
    struct CarouselEnvironment {
      var mainQueue: AnySchedulerOf<DispatchQueue>
      
      init(mainQueue: AnySchedulerOf<DispatchQueue>) {
        self.mainQueue = mainQueue
      }
    }
    
    let carouselReducer = Reducer<CarouselState, CarouselAction, CarouselEnvironment> { state, action, env in
      struct TimeID: Hashable { }
      
      switch action {
      case .onAppear:
        guard var first = state.tabs.first,
              var last = state.tabs.last
        else {
          return .none
        }
        state.tabs.append(first)
        
        return Effect.concatenate([
          Effect(value: .setIndex(0)),
          Effect(value: .nextIndex),
        ])
        
      case .onChange:
        if state.index == state.tabs.count {
          return Effect(value: .setIndex(0))
        }
        return .none
        
      case let .setIndex(index):
        state.index = index
        return .none
        
      case .nextIndex:
        return Effect.timer(id: TimeID(), every: 1.0, on: env.mainQueue)
          .map { _ in .incrementIndex }
        
      case .incrementIndex:
        if state.index >= state.tabs.count - 1 {
          state.index = 0
        } else {
          state.index += 1
        }
        return .none
        
      case .startUserScroll:
        return .merge([
          Effect.cancel(id: TimeID()),
          Effect(value: .nextIndex)
            .delay(for: 1, scheduler: env.mainQueue)
            .eraseToEffect(),
        ])
      }
    }
    
    func getIndex(
      tabs: [Tab],
      tab: Tab
    ) -> Int {
      let index = tabs.firstIndex { currentTab in
        return currentTab.title == tab.title
      } ?? 0
      return index
    }

    실제 무한 캐러셀 뷰에 나타날 데이터들의 배열을 tabs라고 칭했습니다.

    요 정보들은 아래 Tab 타입을 만들어줬구요.

    import Foundation
    import SwiftUI
    
    public struct Tab: Hashable {
      public var title: String
      public var bgColor: Color
    }

    그리고 인덱스를 갈아끼워 뷰를 바꿔주고 돌려주기 위해 index 프로퍼티를 가져갑니다.

    onAppear 되었을때 처음과 마지막이 존재하는지 판단해줍니다.

    그 다음 처음 데이터를 마지막에 동일하게 넣어줍니다.

    여기서 주요한 로직은 onChange 부분입니다.

    만약 현재 index가 전체 데이터 수 값과 동일하면 다시 index를 0번째로 돌려주는것이죠.

    이 자체로 캐러셀을 구현해주게 된것입니다.

    아까 첫 데이터를 또 한번 넣어줬던것의 이유죠.

    그 다음 추가적으로 자동으로 매 초마다 변환될 수 있게 nextIndex라는 액션에서 타이머로 발행해줍니다.

    그럼 index를 조건을 분석해 하나씩 증가 시켜주며 뷰를 갈아줍니다.

    유저 스크롤 시에는 우선 Timer를 해제하고 딜레이를 걸어줘 어색함을 덜하게 해줍니다.

     

    그럼 이렇게 구현된 캐러셀 뷰를 사용해보죠!

     

    Main View

    더보기
    import ComposableArchitecture
    import SwiftUI
    
    struct MainView: View {
      let store: Store<MainState, MainAction>
      let carouselStore: Store<CarouselState, CarouselAction>
      
      init(store: Store<MainState, MainAction>) {
        self.store = store
        self.carouselStore = self.store.scope(
          state: \.carousel,
          action: MainAction.carousel
        )
      }
      
      var body: some View {
        WithViewStore(store) { viewStore in
          InfiniteCarouselView(
            store: carouselStore
          )
          .padding(.horizontal, 20)
        }
      }
    }

     

    Main Core

    더보기
    import ComposableArchitecture
    import Foundation
    import SwiftUI
    
    struct MainState: Equatable {
      var tabs: [Tab]
      var carousel: CarouselState
      
      init(
        tabs: [Tab] = [
          Tab(title: "하나면 하나지 둘이겠느냐", bgColor: .green),
          Tab(title: "둘이면 둘이지 셋이겠느냐", bgColor: .red),
          Tab(title: "셋이면 셋이지 넷이겠느냐", bgColor: .blue),
          Tab(title: "넷이면 넷이지 넷이겠느냐", bgColor: .brown),
          Tab(title: "다섯이면 다섯이지 다섯이겠느냐", bgColor: .yellow),
          Tab(title: "여섯이면 여섯이지 여섯이겠느냐", bgColor: .cyan),
        ],
        currentIndex: Int = 0
      ) {
        self.tabs = tabs
        self.carousel = .init(tabs: self.tabs)
      }
    }
    
    enum MainAction: Equatable {
      case carousel(CarouselAction)
    }
    
    struct MainEnvironment {
      var mainQueue: AnySchedulerOf<DispatchQueue>
      
      init(mainQueue: AnySchedulerOf<DispatchQueue>) {
        self.mainQueue = mainQueue
      }
    }
    
    let mainReducer = Reducer.combine([
      carouselReducer
        .pullback(
          state: \.carousel,
          action: /MainAction.carousel,
          environment: {
            CarouselEnvironment(mainQueue: $0.mainQueue)
          }
        ) as Reducer<MainState, MainAction, MainEnvironment>,
      Reducer<MainState, MainAction, MainEnvironment> { state, action, env in
        switch action {
        case .carousel:
          return .none
        }
      }
    ])

    메인에서는 단순히 풀백만 해주고 데이터만 연결해주는것으로 족합니다.

     

    시연 영상

    그렇다면 어떻게 동작하는지 보시죠!

    이렇게 의도대로 무한 캐러셀 뷰를 구현할 수 있습니다!

     

    마무리

    생각보다 시간이 많이 걸렸습니다..😱

    해당 구현 코드는 아래 제 깃헙 레포에서 확인할 수 있습니다!

    https://github.com/GREENOVER/playground/tree/main/InfiniteCarousel

     

    GitHub - GREENOVER/playground: 학습을 하며 간단한 예제들을 만들어 보는 작고 소중한 놀이터

    학습을 하며 간단한 예제들을 만들어 보는 작고 소중한 놀이터. Contribute to GREENOVER/playground development by creating an account on GitHub.

    github.com

     

    [참고 자료]

    https://www.youtube.com/watch?v=pXOsbgoI-mc 

Designed by Tistory.