안녕하세요!
오늘은 제가 예전에 구현한 커스텀 인디케이터를 소개해보려고 합니다.
예전에 나이키 앱을 클론 코딩할 일이 있었는데
생각지도 못한 부분에서 막혔었습니다.
보이시나요?
좌우로 완전히 이동하지 않고 조금씩 움직이는게 인디케이터에 반영 됩니다.
저는 원래 저런 뷰를 주로 아래의 코드처럼 탭 뷰로 만들었습니다.
struct ContentView: View {
@State var selPageNumber: Int = 0
var body: some View {
TabView(selection: $selPageNumber) {
Text("1번 화면")
.tag(1)
Text("2번 화면")
.tag(1)
Text("3번 화면")
.tag(2)
}//: TabView
.background(Color.green)
.frame(width: 300, height: 200)
.tabViewStyle(.page)
}//: Body
}//: ContentView
그러면 편하게 아래처럼 따로 인디케이터를 설정해주지 않아도 인디케이터를 기본으로 제공해줍니다!
하지만 원하던 모양이랑 많이 다르죠.
그러니 커스텀 인디케이터를 만들어야 하는데 제일 큰 문제는
탭 바의 선택된 페이지에 해당하는 변수의 타입이 Int라는 것입니다...
타입이 Int면 완전히 페이지가 이동할 때 저 값이 바뀌기 때문에
OnChange로도 구현할 수 없을거 같았습니다.
어쨌든 Double로 페이지의 변화를 받아야 할텐데...
라고 고민하다 ScrollView와 GeometryReader를 사용해봐야겠다는 생각이 들었습니다.
.scrollTargetBehavior(.paging)는 iOS17부터 지원하는 기능으로,
설정하면 Scrollview를 TabView의 tabViewStyle(.page)와 유사하게 사용할 수 있습니다.
GeometryReader는 하위뷰의 위치 및 크기를 파악하는데 사용합니다.
구현한 전체 코드를 보여드리겠습니다!
import SwiftUI
struct ContentView: View {
// 현재 화면 위치 값
@State var offset: CGFloat = 0.0
// 화면을 이동할 비율 값
var ofssetValue: CGFloat {
// 아이템 너비
let itemWidth = itemWidth * CGFloat(count - 1)
// 사이 간격
let spacing = itemSpacing * CGFloat(count - 1)
return scrollWidth / (itemWidth + spacing)
}
// 인디케이터 갯수
var count: Int = 5
private let itemWidth: CGFloat = 20
private let itemHeight: CGFloat = 3
private let itemSpacing: CGFloat = 4
// 스크롤 뷰 전체 너비 값(스크롤 뷰 전체 너비 - 화면 너비)
// 화면 끝에서 끝 => 0 ~ scrollViewWidth
@State var scrollWidth: Double = 0
private var imageSpacing: CGFloat = 20
var body: some View {
VStack(spacing: 30) {
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: imageSpacing) {
ForEach(0..<count, id: \.self) { index in
Image(systemName: "person")
.resizable()
.scaledToFit()
.frame(width: UIScreen.main.bounds.width - imageSpacing, height: 110)
.background(.gray)
}
}
.padding(.horizontal, imageSpacing / 2)
.background(
GeometryReader { prox in
Color.clear
.onAppear {
scrollWidth = prox.size.width - UIScreen.main.bounds.width
print(scrollWidth)
}
.onChange(of: prox.frame(in: .global)) { oldValue, newValue in
print("\(-newValue.minX)")
offset = -newValue.minX
}
}
)
}
.scrollTargetBehavior(.paging)
.frame(height: 110)
ZStack {
HStack(spacing: itemSpacing) {
ForEach(0..<count, id: \.self) { index in
Rectangle()
.frame(width: itemWidth, height: itemHeight)
}
}
HStack(spacing: itemSpacing) {
ForEach(0..<count, id: \.self) { index in
Rectangle()
.frame(width: itemWidth, height: itemHeight)
.foregroundStyle(.gray)
}
}
.mask(
HStack {
Rectangle()
.frame(width: itemWidth, height: itemHeight)
.foregroundStyle(.gray)
.offset(x: (offset / ofssetValue))
Spacer()
}
)
}
}
}
}
#Preview {
ContentView()
}
아래의 ZStack 부분이 인디케이터인데,
첫번째 HStack에서 선택되지 않은 칸을
두번째 HStack에서 선택된 칸을 쌓습니다.
그리고 .mask를 통해 선택된 부분 외에는 두번째 HStack을 감춥니다.
ScrollView가 포함된 전체 너비와 화면의 비율을 맞춰서
옆으로 이동한만큼 .mask의 Rectangle에
offset을 주어 아래 인디케이터에 움직임을 줍니다.
.scrollTargetBehavior(.paging)는 iOS 17부터니
현업에서는 아직 구현을 못할거 같은데 음 다른 방법이 있나 생각해봐야겠네요
읽어주셔서 감사합니다!