SwiftUI로 네이버웹툰 상단 배너 만들기

김가영·2022년 11월 10일
1

swift

목록 보기
2/3
post-thumbnail
post-custom-banner

네이버 웹툰의 상단 배너

  • 무한 스크롤
  • 상단 이미지는 스크롤되지 않으면서 하단의 타이틀만 스크롤됨
  • 자동 스크롤

drag gesture

  • 겉보기에는 스크롤이 되는 것처럼 보이지만 scroll은 disable 시키고 drag gesture를 인식해서 직접 offset을 바꿔주는 방법을 사용했다.

무한 스크롤

  • 무한 스크롤을 구현하기 위해 첫번째 배너(1번째 배너) 왼쪽에 마지막 배너를 하나 더 추가해주고(0번째 배너), 마지막 배너 오른 쪽에 첫번째 배너를 한 번 더 추가(n+1번째 배너)해줬다. n번째 비너에서 다음장으로 스크롤할 경우 애니메이션 없이 0번째 배너로 이동한 후 1번째 배너로 이동하여 애니메이션이 자연스럽게 이어지게 해줬다.
  • ScrollViewReader를 이용해보려고 했는데 proxy.scrollTo() 를 여러번 사용할 경우 마지막 거만 작동돼서 직접 구현하는 방법을 사용했다.
  • scrollView 하위의 contentView(HStack)의 x offset 을 현재 index에 맞춰 바꿔줬다.

자동 스크롤

  • Combine의 TimerPublisher를 이용했다.
  • onReceive() 를 이용하면 인자로 publisher를 받아서 해당 Publisher가 값을 보낼때마다 실행할 함수를 지정할 수 있다. 이 경우 3초마다 다음 장으로 이동시켰다.
  • 유저가 직접 스크롤한 경우에는 기존 타이머를 제거하고 새로운 타이머를 작동시켰다. 기존 타이머와 상관 없이 스크롤한 뒤부터 3초 후에 다음장으로 이동한다.

전체 코드


struct NaverCarouselView: View {
    
    let items: [ItemModel]
    
    @State var timer = Timer.publish(every: 3, on: .main, in: .common).autoconnect()
    @State var currentImage: String?
    @State private var currentIndex = 0 {
        didSet {
            scrollToCurrentPage()
            withAnimation(.easeInOut) {
                currentImage = items[currentIndex].image
            }
        }
    }
    @State private var contentOffsetX: CGFloat = 0
    @State private var titleViewWidth: CGFloat = 0
    let spacing: CGFloat = 10
    
    
    var body: some View {
        GeometryReader { geometry in
            ZStack(alignment: .bottom) {
                ZStack(alignment: .bottom) {
                    Image(currentImage ?? "")
                        .resizable()
                        .scaledToFill()
                        .frame(width: geometry.size.width, height: geometry.size.width * 0.8)
                        .clipped()
                    LinearGradient(colors: [.white, .clear], startPoint: .bottom, endPoint: .top)
                        .frame(height: 50)
                }
                VStack(alignment: .trailing) {
                    Text("\(currentIndex+1)/\(items.count)")
                        .padding(.horizontal, 6)
                        .background(Color.white.opacity(0.4))
                        .cornerRadius(10)
                        .padding(.trailing, 20)
                        .foregroundColor(.white)
                        .font(.system(size: 14, weight: .bold))
                    ScrollView(.horizontal, showsIndicators: false) {
                        HStack(spacing: spacing) {
                            Group {
                                ForEach(-1..<items.count + 1, id: \.self) { i in
                                    CarouselTitleView(item: items[i < 0 ? items.count - 1 : (i >= items.count ? 0 : i)])
                                    .frame(width: titleViewWidth)
                                    
                                }
                            }
                        }
                        .offset(x: contentOffsetX, y: 0)
                    } //: ScrollView
                    .scrollDisabled(true)
                }
            } //: ZStack
            .gesture(
                DragGesture()
                    .onEnded { value in
                        if value.translation.width < 0 {
                            currentIndex += 1
                        } else if value.translation.width > 0 {
                            currentIndex -= 1
                        }
                        timer.upstream.connect().cancel()
                        timer = Timer.publish(every: 3, on: .main, in: .common).autoconnect()
                    }
            )
            .onAppear {
                currentImage = items[0].image
                titleViewWidth = geometry.size.width - 40
                contentOffsetX = -titleViewWidth + spacing
            }
            .onReceive(timer) { _ in
                currentIndex += 1
            }
        } //: GeometryReader
    }
    
    private func scrollToCurrentPage() {
        if currentIndex == items.count {
            contentOffsetX = 0
            currentIndex = 0
        } else if currentIndex < 0 {
            contentOffsetX = -titleViewWidth * CGFloat(items.count+1) + spacing * CGFloat(items.count - 1)
            currentIndex = items.count - 1
        }

        withAnimation {
            contentOffsetX = -titleViewWidth * CGFloat(currentIndex+1) - spacing * CGFloat(currentIndex - 1)
        }
    }
}
profile
개발블로그
post-custom-banner

0개의 댓글