SwiftUI

SwiftUI - Custom ScrollTabView

GREEN.1229 2023. 7. 10. 21:58

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

이번 포스팅에서는 커스텀한 스크롤 탭뷰를 만들어보려해요!

정확하게는 탭뷰 내 컨텐츠가 스크롤되는것 외에도 TabBar 즉, 헤더 영역 자체가 스크롤 되는것을 구현해보려 합니다🙌

 

얼마전에 SwiftUI로 커스텀 탭 뷰를 구현해본적이 있어요.

https://green1229.tistory.com/385

 

SwiftUI - Custom TabView

안녕하세요. 그린입니다🍏 이번 포스팅에서는 SwiftUI로 커스텀한 탭뷰를 구현해보려 합니다🙌 SwiftUI에서 기본적으로 제공해주는 TabView 컴포넌트가 있지만 아예 완전 커스텀하게 탭뷰를 입맛대

green1229.tistory.com

 

그런데 해당 커스텀 뷰에서 탭 바의 역할을 하는 헤더 영역은 많은 갯수의 탭이 위치하게 될 경우 스크롤 되지 않기에 정말 무수히 많이 들어온다면 서로 겹치는 현상이 발생할 수 있습니다.

width를 디바이스의 사이즈에서 각 탭의 갯수만큼 나눠주어 영역 자체는 겹칠 수 없지만 각 탭을 구성하는 타이틀인 String은 길이가 고정되었기에 겹칠 수 있습니다.

겹치지 않는다면 말줄임으로 나타나겠죠🥲

 

요렇게 말이죠!

 

 

그렇기에 해당 포스팅에서는 무수히 많은 탭이 들어오더라도 스크롤 되도록 구현하여 이를 해결해보자 합니다🙋🏻

추가로, 이전에서 하나 더 발전시켜서 탭을 클릭하지 않더라도 컨텐츠를 좌우 스와이프 시키면 뷰가 변하고 이에 따라 선택된 탭도 변하도록 해보겠습니다ㅎㅎ

 

먼저 어떤걸 구현하려는지 동작부터 보시죠!

 

보시면 컨텐츠를 스와이프 할때 위 탭 바 헤더 영역도 따라서 이동하는걸 볼 수 있습니다.

그리고 각 탭 바 양 옆 에 자연스럽게 그라데이션 뷰를 심어 뚝 끊겨서 보이지 않도록 덮어써줬어요!

또한, 오늘 해보려했던 헤더 영역 (탭 바)이 스크롤도 잘 되는것을 확인할 수 있습니다 😊

 

자 그럼 구현 해보러 가시죠!

 

Custom ScrollTabView 만들기

 

우선은 전체 View 코드이니 이해가 안되면 스킵하고 아래 설명을 보시는걸 추천드립니다 🙌

import SwiftUI

public protocol TabTitleConvertible {
  var title: String { get }
}

public struct CustomScrollTabView<Selection>: View where Selection: Hashable & Identifiable & Comparable & TabTitleConvertible {
  public var views: [Selection: AnyView]
  @Binding public var selection: Selection
  @State private var barXOffset: CGFloat = 0
  @State private var barIsActive = false
  @State private var viewOffset: CGFloat = 0
  
  public init(
    views: [Selection: AnyView],
    selection: Binding<Selection>
  ) {
    self.views = views
    self._selection = selection
  }
  
  public var body: some View {
    LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) {
      Section(
        header:
          GeometryReader { geometry in
            ZStack {
              ScrollViewReader { scrollProxy in
                ScrollView(.horizontal, showsIndicators: false) {
                  LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
                    Section(
                      header:
                        VStack(spacing: 0) {
                          HStack(spacing: 0) {
                            ForEach(
                              views.keys.sorted(),
                              content: { key in
                                Button(
                                  action: {
                                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                                      selection = key
                                    }
                                  },
                                  label: {
                                    HStack(spacing: 0) {
                                      Spacer()
                                      Text(key.title)
                                      
                                      Spacer()
                                    }
                                    .id(key)
                                  }
                                )
                                .frame(width: 76)
                                .background(GeometryReader { geometry in
                                  Color.clear.onAppear {
                                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
                                      if key == selection {
                                        barXOffset = geometry.frame(in: .named("barSpace")).minX
                                      }
                                    }
                                  }
                                })
                              }
                            )
                          }
                          .padding(.vertical, 15)
                          .frame(height: 53)
                          .coordinateSpace(name: "barSpace")
                          
                          ZStack(alignment: .bottom) {
                            Rectangle()
                              .fill(.gray)
                              .frame(height: 1)
                            
                            HStack {
                              Rectangle()
                                .fill(Color.black)
                                .frame(width: 76, height: 3)
                                .offset(x: barXOffset)
                                .animation(barIsActive ? .linear(duration: 0.25) : .none, value: barXOffset)
                              
                              Spacer()
                            }
                          }
                        }
                    ) {
                    }
                  }
                }
                .onAppear {
                  let selectedIndex = CGFloat((selection.id as? Int ?? 0))
                  barXOffset = selectedIndex * 76
                  
                  DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                    barIsActive = true
                  }
                }
                .onChange(of: viewOffset, perform: { newValue in
                  let percentMoved = -newValue / UIScreen.main.bounds.width
                  let currentTabIndex = CGFloat((selection.id as? Int ?? 0))
                  barXOffset = (currentTabIndex + percentMoved) * 76
                })
                .onChange(of: selection, perform: { newValue in
                  DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                    let selectedIndex = CGFloat((newValue.id as? Int ?? 0))
                    barXOffset = selectedIndex * 76
                    withAnimation {
                      scrollProxy.scrollTo(newValue, anchor: .trailing)
                    }
                  }
                })
              }
              
              HorizontalGradationView()
            }
          }
        
          .frame(height: 56)
          .background(Color.white)
      ) {
        ZStack {
          if let previousIndex = views.keys.sorted().firstIndex(of: selection), previousIndex > 0,
             let previousView = views[views.keys.sorted()[previousIndex - 1]] {
            previousView
              .animation(.none)
              .edgesIgnoringSafeArea(.bottom)
              .offset(x: -UIScreen.main.bounds.width + viewOffset, y: 0)
              .gesture(
                DragGesture()
                  .onChanged(onDragChanged(drag:))
                  .onEnded(onDragEnded(drag:))
              )
          }
          
          if let view = views[selection] {
            view
              .animation(.none)
              .edgesIgnoringSafeArea(.bottom)
              .offset(x: viewOffset, y: 0)
              .gesture(
                DragGesture()
                  .onChanged(onDragChanged(drag:))
                  .onEnded(onDragEnded(drag:))
              )
          }
          
          if let nextIndex = views.keys.sorted().firstIndex(of: selection), nextIndex < views.keys.sorted().count - 1,
             let nextView = views[views.keys.sorted()[nextIndex + 1]] {
            nextView
              .animation(.none)
              .edgesIgnoringSafeArea(.bottom)
              .offset(x: UIScreen.main.bounds.width + viewOffset, y: 0)
              .gesture(
                DragGesture()
                  .onChanged(onDragChanged(drag:))
                  .onEnded(onDragEnded(drag:))
              )
          }
        }
      }
    }
  }
  
  private func onDragChanged(drag: DragGesture.Value) {
    if (views.keys.sorted().first != selection || drag.translation.width < 0) &&
        (views.keys.sorted().last != selection || drag.translation.width > 0) {
      viewOffset = drag.translation.width
    }
  }
  
  private func onDragEnded(drag: DragGesture.Value) {
    if viewOffset > UIScreen.main.bounds.width * 0.5 {
      if let previousIndex = views.keys.sorted().firstIndex(of: selection), previousIndex > 0 {
        selection = views.keys.sorted()[previousIndex - 1]
      }
    } else if viewOffset < -UIScreen.main.bounds.width * 0.5 {
      if let nextIndex = views.keys.sorted().firstIndex(of: selection), nextIndex < views.keys.sorted().count - 1 {
        selection = views.keys.sorted()[nextIndex + 1]
      }
    }
    withAnimation {
      viewOffset = 0
    }
  }
}

// MARK: - 스크롤 시 양 옆 그라데이션 뷰
private struct HorizontalGradationView: View {
  fileprivate var body: some View {
    HStack(spacing: 0) {
      LinearGradient(
        gradient: Gradient(
          colors: [
            .white,
            .white.opacity(0.0)
          ]
        ),
        startPoint: .leading,
        endPoint: .trailing
      )
      .frame(width: 32)
      
      Spacer()
      
      LinearGradient(
        gradient: Gradient(
          colors: [
            .white.opacity(0.0),
            .white
          ]
        ),
        startPoint: .leading,
        endPoint: .trailing
      )
      .frame(width: 32)
    }
    .frame(height: 48)
  }
}

코드가 좀 긴데요 ㅎㅎ..

맨 서두에 말씀드린 이전 커스텀 탭 뷰 구현 포스팅을 참고해보고 오면 거의 모든 부분이 중첩이라 이해가 쉽습니다!

 

그럼 변경되는 부분들만 골라서 확인해볼께요!

 

우선 이전과 다른점은 LazyVStack으로 해당 헤더 영역은 고정되도록 스티키하게 구현해줍니다.

그리고 해당 각 탭의 background를 통해 영역 크기를 계산해줍니다.

즉, barXOffset이 따라 갈 수 있도록 해당 오프셋을 geometry의 minX로 설정되도록 구현해요.

 

그리고 변경되는 포인트!

 

onChange 변경 부분

.onChange(of: viewOffset, perform: { newValue in
  let percentMoved = -newValue / UIScreen.main.bounds.width
  let currentTabIndex = CGFloat((selection.id as? Int ?? 0))
  barXOffset = (currentTabIndex + percentMoved) * 76
})
.onChange(of: selection, perform: { newValue in
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
    let selectedIndex = CGFloat((newValue.id as? Int ?? 0))
    barXOffset = selectedIndex * 76
    withAnimation {
      scrollProxy.scrollTo(newValue, anchor: .trailing)
    }
  }
})

viewOffset이 변경되면 검은 바가 자연스럽게 따라 이동할 수 있도록 viewOffset을 인자로 받는 onChange 메서드를 구현합니다.

그리고, selection이 변경되면 현재 탭 된 아이템의 인덱스를 각 최소 탭의 width인 76으로 곱해줍니다.

그럼 해당 수치는 barXOffset, 즉 아래 선택된 탭 바 하단에 위치하도록 하는 검은 바의 오프셋을 잡아요.

그리고 해당 탭이 화면에 가려지지 않고 trailing 위치로 나타나도록 scrollTo로 이동시켜 줍니다.

 

그리고 이제 또 중요한 변경사항이 있어요!
바로 컨텐츠 부분입니다.

 

컨텐츠 변경 부분

탭의 컨텐츠 영역을 스와이프하여 이동시키고 그에 따라 탭과 검은 바가 변경되도록 구현해야 합니다.

ZStack {
  if let previousIndex = views.keys.sorted().firstIndex(of: selection), previousIndex > 0,
     let previousView = views[views.keys.sorted()[previousIndex - 1]] {
    previousView
      .animation(.none)
      .edgesIgnoringSafeArea(.bottom)
      .offset(x: -UIScreen.main.bounds.width + viewOffset, y: 0)
      .gesture(
        DragGesture()
          .onChanged(onDragChanged(drag:))
          .onEnded(onDragEnded(drag:))
      )
  }
  
  if let view = views[selection] {
    view
      .animation(.none)
      .edgesIgnoringSafeArea(.bottom)
      .offset(x: viewOffset, y: 0)
      .gesture(
        DragGesture()
          .onChanged(onDragChanged(drag:))
          .onEnded(onDragEnded(drag:))
      )
  }
  
  if let nextIndex = views.keys.sorted().firstIndex(of: selection), nextIndex < views.keys.sorted().count - 1,
     let nextView = views[views.keys.sorted()[nextIndex + 1]] {
    nextView
      .animation(.none)
      .edgesIgnoringSafeArea(.bottom)
      .offset(x: UIScreen.main.bounds.width + viewOffset, y: 0)
      .gesture(
        DragGesture()
          .onChanged(onDragChanged(drag:))
          .onEnded(onDragEnded(drag:))
      )
  }
}

먼저 이전과 현재 그리고 이후의 뷰를 ZStack으로 표출해줍니다.

그런데 보시면 이전과 이후 뷰의 offset의 x를 보면 이전은 디바이스의 width를 반전하여 현재 viewOffset으로 설정하고 이후는 디바이스의 width에 viewOffset으로 보여줍니다.

즉, 현재 탭된 뷰가 정상적으로 보여지고 이전과 이후는 디바이스의 width 기준으로 이전과 이후에 들어가 있게 되죠!

 

그렇기에 사용자가 드래그 할때 보여지게 되죠!

 

그럼 이제 컨텐츠 드래그를 위해 두 메서드를 구현해야 합니다.

 

Drag 메서드 구현하기

private func onDragChanged(drag: DragGesture.Value) {
  if (views.keys.sorted().first != selection || drag.translation.width < 0) &&
      (views.keys.sorted().last != selection || drag.translation.width > 0) {
    viewOffset = drag.translation.width
  }
}

private func onDragEnded(drag: DragGesture.Value) {
  if viewOffset > UIScreen.main.bounds.width * 0.5 {
    if let previousIndex = views.keys.sorted().firstIndex(of: selection), previousIndex > 0 {
      selection = views.keys.sorted()[previousIndex - 1]
    }
  } else if viewOffset < -UIScreen.main.bounds.width * 0.5 {
    if let nextIndex = views.keys.sorted().firstIndex(of: selection), nextIndex < views.keys.sorted().count - 1 {
      selection = views.keys.sorted()[nextIndex + 1]
    }
  }
  withAnimation {
    viewOffset = 0
  }
}

먼저 드래그가 되고 있으면서 변경되는 동안 onDragChanged 메서드가 불려요.

즉, 이전과 이후에 보여질 뷰가 있으면 viewOffset을 드래그된 width로 설정해서 이전과 이후가 드래그 되는 동안에도 자연스럽게 보이죠!

그리고, 드래그가 끝난 시점에서 만약 드래깅이 화면 절반을 넘어갔다면 onDragEnded에서 이전이나 이후 뷰를 보여주고 절반을 넘어가지 않고 드래그를 끝내면 현재 뷰로 다시 포커싱을 하게 됩니다.

그리고 다시 viewOffset은 초기화 해줍니다.

 

그 다음 헤더 탭 바 영역 좌우 그라데이션을 위한 뷰를 구성해보죠!

 

그라데이션 뷰 구현하기

private struct HorizontalGradationView: View {
  fileprivate var body: some View {
    HStack(spacing: 0) {
      LinearGradient(
        gradient: Gradient(
          colors: [
            .white,
            .white.opacity(0.0)
          ]
        ),
        startPoint: .leading,
        endPoint: .trailing
      )
      .frame(width: 32)
      
      Spacer()
      
      LinearGradient(
        gradient: Gradient(
          colors: [
            .white.opacity(0.0),
            .white
          ]
        ),
        startPoint: .leading,
        endPoint: .trailing
      )
      .frame(width: 32)
    }
    .frame(height: 48)
  }
}

LinearGradient로 자연스럽게 컬러와 프레임을 설정합니다.

그리고 이건 최상위 ZStack에서 상단에 노출되도록 후에 얹어주면 됩니다.

 

마지막으로 실제로 상위 뷰에서 호출해서 사용하는걸 보겠습니다 🙋🏻

CustomScrollTabView(
  views: [
    .one: AnyView(ChildView(color: .red)),
    .two: AnyView(ChildView(color: .blue)),
    .three: AnyView(ChildView(color: .yellow)),
    .four: AnyView(ChildView(color: .gray)),
    .five: AnyView(ChildView(color: .orange)),
    .six: AnyView(ChildView(color: .brown)),
    .seven: AnyView(ChildView(color: .cyan)),
    .eight: AnyView(ChildView(color: .indigo)),
  ],
  selection: $viewModel.selectedTab
)

private struct ChildView: View {
  var color: Color
  
  fileprivate var body: some View {
    color
      .frame(height: 300)
  }
}

각 이미 정의해둔 Tab 케이스별로 각 뷰를 넣어주기만 하면 됩니다!

 

아주 간단하게 만들 수 있습니다 

 

참고

해당 예제 프로젝트는 아래 제 깃헙 레포에서 확인해보실 수 있습니다 😃

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