안녕하세요
요즘 보기 시작한 Swift정보를 잘 알려주시는 외국 유튜버분이 있어서 영상을 보면서 공부하다가
어느 순간 코드가 이해가 안가서 스스로 어? 나 이대로 괜찮나? 하면서 위기감이 확 드는 겁니다.
그래서 지금 코드를 이해 못하면 아무것도 못할거 같은 불안함이 생겨 한줄한줄 최대한 이해해보려고 정리하는김에
오랜만에 블로그도 써야겠다 하고 글을 쓰게 되었습니다 ㅎㅎ
https://www.youtube.com/watch?v=sCK0W39nVEk
참고한 영상이구요! (아주 추천하는 채널입니다 저도 시간이 되는대로 많이 보려구요)
저의 결과물입니다.
저렇게 스와이프하거나 드래그 했을 때의 페이지 처리는 금방 이해가 갔는데
상단의 커스텀 탭 바의 선의 길이가 글자 크기에 맞춰 계산되는 로직이 이해가 안가더라구요.
그러니까 이해할 겸 지금부터 정리해보겠습니다!
파일 구조는 다음과 같습니다.
모델부터 정리할게요!
먼저 TabModel입니다.
tabView를 직접 쓰는게 아니기 때문에 tab과 관련된 모델이 필요합니다.
struct TabModel: Identifiable {
// 외부에서 읽기만 가능하도록 private(set) 처리
private(set) var id: Tab
// 뷰(탭)의 크기
var size: CGSize = .zero
// 최소 x좌표 -> 추후 탭의 진행상황을 구하기 위해 필요
var minX: CGFloat = .zero
// enum으로 탭의 종류를 설정, rawValue 사용
enum Tab: String, CaseIterable {
case reserach = "Reseach"
case deployment = "Development"
case analytics = "Analytics"
case audience = "Audience"
case privacy = "Privacy"
}
}
다음은 RectKey입니다.
여기서 쓰인 PreferenceKey라는 프로토콜이 생소하실수도 있는데요(저는 생소했습니다)
보통 저렇게 defaultValue를 설정하고 reduce() 함수와 같이 사용합니다.
PreferenceKey를 사용하는 이유는 하위 뷰에서 상위 뷰로 값을 전달하고
값이 변하는 시점에 이벤트를 처리하기 위해서 많이 사용합니다.
(저는 @Publish와 비슷한 느낌이 드는데 나중에 따로 용도의 차이점을 정리해도 재밌겠네요)
그리고 View에 extension으로 rect를 선언했는데,
GeometryReader로 뷰의 frame크기를 가져옵니다. (스크롤 뷰의 .horizontal axis 기준)
이어서 .preference와 .onPreferenceChange가 보이시죠?
PreferenceKey 프로토콜을 사용했을 때 자주 같이 사용되는 메서드입니다.
.preference로 값을 전달하고 .onPreferenceChange로 값이 변화된 시점에 이벤트를 처리합니다.
정리하자면! RectKey 모델을 통해 특정 뷰의 frame을 구하고 그 값이 변하는 시점(이벤트를 처리하기 위한)도 구합니다.
struct RectKey: PreferenceKey {
static var defaultValue: CGRect = .zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
value = nextValue()
}
}
extension View {
@ViewBuilder
func rect(completion: @escaping (CGRect) -> ()) -> some View {
self
.overlay {
GeometryReader {
let rect = $0.frame(in: .scrollView(axis: .horizontal))
Color.clear
.preference(key: RectKey.self, value: rect)
.onPreferenceChange(RectKey.self, perform: completion)
}
}
}
}
다음으로 모델의 마지막,,,
이해가 안가기 시작하던 Interpolation입니다.
너무 이해가 안가서 저 공식은 무엇인가 찾아봤더니 '선형 보간법'이라는 것을 나타낸 공식이더군요
https://ko.wikipedia.org/wiki/선형_보간법
현재 탭이 몇번째 탭이고, 다른 탭 사이의 길이를 계산하기 위해 사용되었습니다.
파라미터로 입력 범위(inputRange), 출력 범위(outputRange)를 받습니다.
만약 입력 범위가 [0, 1, 2]고 출력 범위가 [10, 20, 30]이라면 입력범위가 1일 때 20을 출력하는 겁니다.
자세한 흐름은 주석을 참고해주세요.
extension CGFloat {
func interpolate(inputRange: [CGFloat], outputRange: [CGFloat]) -> CGFloat {
// 현재 값
let x = self
// 입력 범위의 길이
let length = inputRange.count - 1
// x가 입력 범위의 제일 작은 값인 경우 - 출력 범위의 제일 작은 값을 리턴
if x <= inputRange[0] {
return outputRange[0]
}
// 선형 보간법으로 어떤 범위에 속하는지 계산
for index in 1...length {
let x1 = inputRange[index - 1]
let x2 = inputRange[index]
let y1 = outputRange[index - 1]
let y2 = outputRange[index]
// x(현재 값)이 특정 범위에 속하면 해당 범위의 출력 값을 반환
if x <= inputRange[index] {
let y = y1 + ((y2 - y1) / (x2 - x1)) * (x - x1)
return y
}
}
// x가 입력 범위의 제일 큰 값인 경우 - 출력 범위의 제일 큰 값을 리턴
return outputRange[length]
}
}
뷰에서 눈여겨 보실 내용은 부분으로 따로 설명해드리겠습니다.
progress = -rect.minX / size.width 인데요,
rect.minX는 제일 왼쪽 x좌표이기 때문에 오른쪽으로 스크롤 될수록 음수값으로 커집니다.
여기서 -rect.minX에 size.width를 나누면 0 ~ 1이 나옵니다.
결과적으로 탭의 진행도를 계산해줍니다.
ScrollView(.horizontal) {
LazyHStack(spacing: 0) {
ForEach(tabs) { tab in
Text(tab.id.rawValue)
.frame(width: size.width, height: size.height)
.contentShape(.rect)
}
}
.scrollTargetLayout()
.rect { rect in
progress = -rect.minX / size.width
}
}
커스텀 인디케이터의 너비와 진행 위치를 정해주는 핵심 코드입니다.
인디케이터 위치를 계산하기 위한 outputPositionRange 부분이 저도 아직 이해가 어려운데,
각 탭의 .minX가 각 탭의 시작 위치를 나타내고 있습니다.
.overlay(alignment: .bottom, content: {
ZStack(alignment: .leading) {
Rectangle()
.fill(.gray.opacity(0.3))
.frame(height: 1)
// 입력 범위
let inputRange = tabs.indices.compactMap { return CGFloat($0) }
// 출력 범위(각 탭의 너비)
let ouputRange = tabs.compactMap { return $0.size.width }
// 출력 위치 범위
let outputPositionRange = tabs.compactMap { return $0.minX }
// 계산된 인디케이터 너비
let indicatorWidth = progress.interpolate(inputRange: inputRange, outputRange: ouputRange)
// 계산된 인디케이터 위치
let indicatorPosition = progress.interpolate(inputRange: inputRange, outputRange: outputPositionRange)
Rectangle()
.fill(.primary)
.frame(width: indicatorWidth, height: 1.5)
.offset(x: indicatorPosition)
}
})
아래 긴 코드는 뷰의 전체 코드 입니다.
struct Home: View {
@State private var tabs: [TabModel] = [
.init(id: TabModel.Tab.reserach),
.init(id: TabModel.Tab.deployment),
.init(id: TabModel.Tab.analytics),
.init(id: TabModel.Tab.audience),
.init(id: TabModel.Tab.privacy),
]
@State private var activeTab: TabModel.Tab = .reserach
@State private var tabBarScrollState: TabModel.Tab?
@State private var mainViewScrollState: TabModel.Tab?
@State private var progress: CGFloat = .zero
var body: some View {
VStack(spacing: 0) {
HeaderView()
CustomTabBar()
GeometryReader {
let size = $0.size
ScrollView(.horizontal) {
LazyHStack(spacing: 0) {
ForEach(tabs) { tab in
Text(tab.id.rawValue)
.frame(width: size.width, height: size.height)
.contentShape(.rect)
}
}
.scrollTargetLayout()
.rect { rect in
progress = -rect.minX / size.width
}
}
.scrollPosition(id: $mainViewScrollState)
.scrollIndicators(.hidden)
.scrollTargetBehavior(.paging)
.onChange(of: mainViewScrollState) { oldValue, newValue in
if let newValue {
withAnimation(.snappy) {
tabBarScrollState = newValue
activeTab = newValue
}
}
}
}
}
}
@ViewBuilder
func HeaderView() -> some View {
HStack {
Image(systemName: "play.rectangle.fill")
.foregroundStyle(.red)
.font(.title)
Text("YouTube")
.fontWeight(.heavy)
Spacer(minLength: 0)
Button("", systemImage: "plus.circle") {
}
.font(.title2)
.tint(.primary)
Button("", systemImage: "bell") {
}
.font(.title2)
.tint(.primary)
Button {
} label: {
Image(systemName: "person")
.resizable()
.frame(width: 30, height: 30)
.clipShape(.circle)
}
}
.padding(15)
.overlay(alignment: .bottom) {
Rectangle()
.fill(.gray.opacity(0.3))
.frame(height: 1)
}
}
@ViewBuilder
func CustomTabBar() -> some View {
ScrollView(.horizontal) {
HStack(spacing: 20) {
ForEach($tabs) { $tab in
Button {
withAnimation(.snappy) {
activeTab = tab.id
tabBarScrollState = tab.id
mainViewScrollState = tab.id
}
} label: {
Text(tab.id.rawValue)
.padding(.vertical, 12)
.foregroundStyle(activeTab == tab.id ? Color.primary : .gray)
.contentShape(.rect)
}
.buttonStyle(.plain)
.rect { rect in
tab.size = rect.size
tab.minX = rect.minX
}
}
}
.scrollTargetLayout()
}
.scrollPosition(id: .init(get: {
return tabBarScrollState
}, set: { _ in
}), anchor: .center)
.overlay(alignment: .bottom, content: {
ZStack(alignment: .leading) {
Rectangle()
.fill(.gray.opacity(0.3))
.frame(height: 1)
let inputRange = tabs.indices.compactMap { return CGFloat($0) }
let ouputRange = tabs.compactMap { return $0.size.width }
let outputPositionRange = tabs.compactMap { return $0.minX }
let indicatorWidth = progress.interpolate(inputRange: inputRange, outputRange: ouputRange)
let indicatorPosition = progress.interpolate(inputRange: inputRange, outputRange: outputPositionRange)
Rectangle()
.fill(.primary)
.frame(width: indicatorWidth, height: 1.5)
.offset(x: indicatorPosition)
}
})
.scrollIndicators(.hidden)
.safeAreaPadding(.horizontal, 15)
}
}
저였다면 아마 각 탭의 크기를 저장하고 몇번째 탭인지를 .onChange로 받아서
offset과 동시에 크기 조절을 하는 방향으로 코드를 짜다 각 탭의 크기를 어디에 어떻게 저장할까에서 고민할거 같은데
이런 방식으로 코드를 짤수도 있네요.
뭔가 더 높은 경지?를 체험한 느낌입니다.
긴 글 읽어주셔서 감사합니다!