일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |
- 기존 앱
- List
- development language
- 개발자 면접
- oberve url
- Side Menu
- detect url
- UIPresentationController
- swift
- 상단 탭바
- UIViewControllerTransitioningDelegate
- Swift Package Manager
- convert base64
- Tuist
- base64 변환
- pod install
- url 관찰
- scrolling tab
- SwiftUI
- notifychanged
- GeometryReader
- url 추적
- swift #swift keychain #keychain 사용법
- ViewBuilder
- transformation.map
- ios
- DataBinding
- DevelopmentRegion
- Android
- 스크롤 탭
- Today
- Total
버그 잡이
SwiftUI - Paging 되는 상단 tabbar 구현하기 본문
https://www.youtube.com/watch?v=80n0zv7r9Lc
위 영상을 보고 학습한 내용입니다.
swiftUI로 위와 같은 화면을 만들어 보겠습니다.
위 화면을 만들기 위해서 크게 두 개의 View가 생성될 예정입니다.
1. OffsetPageTabView
- UIScrollView를 활용해서 content를 담을 수 있는 View
- 탭바 아래 스크롤 되는 conent 화면
2. PagerTabView
- TabView와 OffSetPageTabView를 가지고 있는 View
하나씩 차근차근 만들어보겠습니다.
OffsetPageTabView
1. content를 통해서 UIScrollView 안에 넣어줄 View를 받습니다.
2. MakeUIView로 UIScrollView를 만들고 안에 Content를 넣어줍니다.
3. 탭 클릭시 원하는 offset으로 이동하기 위해서 "offset" 변수를 둡니다.
- 탭 선택시에 'offset' 을 변경해주고 scrollView.contentOffset.x와 다르면 스크롤 해줍니다.
struct OffsetPageTabView<Content: View>: UIViewRepresentable {
var content: Content
@Binding var offset: CGFloat
// Content가 될 View를 받아서 ScrollView안에 넣어줌
// 추후에 List 뷰를 받아서 넣어줄 것임
init(offset: Binding<CGFloat>, @ViewBuilder content: @escaping ()->Content) {
self.content = content()
self._offset = offset
}
func makeCoordinator() -> Coordinator {
return OffsetPageTabView.Coordinator(parent: self)
}
// SwiftUI뷰를 받아서 UIKit의 ScrollView에 넣어준다
func makeUIView(context: Context) -> UIScrollView {
let scrollView = UIScrollView()
let hostview = UIHostingController(rootView: content)
hostview.view.translatesAutoresizingMaskIntoConstraints = false
let constraints = [
hostview.view.topAnchor.constraint(equalTo: scrollView.topAnchor),
hostview.view.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
hostview.view.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
hostview.view.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),
hostview.view.heightAnchor.constraint(equalTo: scrollView.heightAnchor)
]
scrollView.addSubview(hostview.view)
scrollView.addConstraints(constraints)
scrollView.isPagingEnabled = true
scrollView.showsVerticalScrollIndicator = false
scrollView.showsHorizontalScrollIndicator = false
// coordinator 연결
scrollView.delegate = context.coordinator
return scrollView
}
// 1. scrollView의 contentOffset이 변경될때마다 호출됨
// 2. self.offset이 변경될때 호출됨
func updateUIView(_ uiView: UIScrollView, context: Context) {
let currentOffet = uiView.contentOffset.x
// 스크롤할때는 setContentOffset을 호출하지 않고 상단 탭 선택시에 스크롤 수동 변경
if currentOffet != offset {
uiView.setContentOffset(CGPoint(x: offset, y: 0), animated: true)
}
}
// UIKit의 scrollView.contentOffset 데이터를 받아서 현재 View의 offset 변수에 넣어줌
class Coordinator: NSObject, UIScrollViewDelegate {
var parent: OffsetPageTabView
init(parent: OffsetPageTabView) {
self.parent = parent
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let offset = scrollView.contentOffset.x
parent.offset = offset
}
}
}
이해가 필요한 개념들에 대해서 좀 더 알아보겠습니다.
*UIViewRespresentable
SwiftUI에서 UIKit을 쓰기 위해서는 UIViewRepresentable를 써야합니다.
위 View도 UIScrollView를 써야하는 상황이었기 때문에 UIViewRepresentable를 활용하였습니다.
간단하게 함수를 이해해봅시다
(어떤 역할을 하는 함수인지 주석을 달아놨습니다.)
public protocol UIViewRepresentable : View where Self.Body == Never {
associatedtype UIViewType : UIView
// View를 생성하고 초기화
func makeUIView(context: Self.Context) -> Self.UIViewType
// View가 업데이트가 필요할 때 호출하는 메소드
func updateUIView(_ uiView: Self.UIViewType, context: Self.Context)
static func dismantleUIView(_ uiView: Self.UIViewType, coordinator: Self.Coordinator)
associatedtype Coordinator = Void
// Coordinator를 통해서 UIKit -> SwiftUI 로 데이터 전달 가능
func makeCoordinator() -> Self.Coordinator
typealias Context = UIViewRepresentableContext<Self>
}
UIKit의 view를 가져와서 쓰고 그 객체와 관련된 함수와 데이터를 가져와서 쓸 수 있게 도와주는 기능입니다.
(자세한 내용은 https://developer.apple.com/documentation/swiftui/uiviewrepresentable 여기를 참고 바랍니다.)
PagerTabView
1. label 과 content를 파라미터로 받아서 넣어줍니다.
2. "탭 선택으로 스크롤" 구현
- overlay해서 tab에 맞게 Rectangle 뷰를 생성하고 onTapGesture를 달아줍니다.
- 그리고 offset 값을 변경해줍니다. (OffsetPageTabView에 offset값이 바인딩 되어 있기 때문에 자동 반영)
3. OffsetPageTabView에서 ScrollView.contentOffset을 받아와서 탭 갯수와 탭바 하단 라인의 offset인 tabOffset을 계산해줍니다.
struct PagerTapView<Content: View, Label: View>: View {
var content: Content
var label: Label
var tint: Color
init(tint: Color,
@ViewBuilder labels: @escaping ()->Label,
@ViewBuilder content: @escaping ()->Content
) {
self.content = content()
self.label = labels()
self.tint = tint
}
@State var offset: CGFloat = 0
@State var maxTab: CGFloat = 0
@State var tabOffset: CGFloat = 0
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 0) {
label
}
.overlay( // 탭 선택으로 스크롤
HStack(spacing: 0) {
ForEach(0..<Int(maxTab), id: \.self) { index in
Rectangle()
.fill(Color.black.opacity(0.01))
.onTapGesture { // 탭 했을때 스크롤뷰 offset 변경
let newOffset = CGFloat(index) * getScreenBounds().width
self.offset = newOffset
}
}
}
)
.foregroundColor(tint)
// 탭바 하단 라인
Capsule()
.fill(tint)
.frame(width: maxTab == 0 ? 0 : (getScreenBounds().width / maxTab),
height: 5)
.padding(.top, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.offset(x: tabOffset)
OffsetPageTabView(offset: $offset) {
HStack(spacing: 0) {
content
}
.overlay(
// ScrollView의 frame 정보를 부모뷰에게 전달하기 위함. onPreferenceChange 에서 전달 받음
GeometryReader { proxy in
Color.clear
.preference(key: TabPreferenceKey.self, value: proxy.frame(in: .global))
}
)
.onPreferenceChange(TabPreferenceKey.self) { proxy in
// ScrollView frame 정보를 바탕으로 maxTab, tabOffset(탭 하단 라인의 offset) 계산
let maxWidth = proxy.width
let screenWidth = getScreenBounds().width
let maxTabs = (maxWidth / screenWidth).rounded()
self.maxTab = maxTabs
let minX = -proxy.minX
let progress = minX / screenWidth // ex) 스크린화면 크기만큼 이동했을때 360 / 360 = 1
let tabOffset = progress * (screenWidth / maxTabs) // 1 * (360/3)
self.tabOffset = tabOffset
}
}
}
}
}
// preference 등록을 위한 객체 생성
struct TabPreferenceKey: PreferenceKey {
static var defaultValue: CGRect = .init()
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
value = nextValue()
}
}
extension View {
func getScreenBounds() -> CGRect {
return UIScreen.main.bounds
}
}
* GeometryReader와 Preference
GeometryReader와 Preference는 중요한 개념이지만 이번 포스팅에서는 현재 상황에서 위 코드에서 어떻게 쓰였는지에 대해서만 알아보겠습니다.
GeometryReader는 상위뷰의 좌표, 크기 정보를 자식 뷰에게 제공하는 역할을 합니다.
여기서는 OffsetPageTabView의 좌표, 크기 정보를 받아옵니다.
Preference는 하위 뷰의 정보를 상위 뷰에 전달할 수 있는 수단입니다.
1. 이를 사용하기 위해서 PreferenceKey를 채택하는 키를 정의해줘야합니다. (여기서는 TabPreferenceKey)
2. GemotryReader 내부의 Color라는 View에 preferenceKey를 등록해줌으로써 OffsetPageTabView의 좌표, 크기 정보를 preference에 전달할 수 있습니다.
3. 위 정보를 onPreferenceChange를 통해서 받아서 쓸 수 있습니다.
'그런데 그냥 GeometryReader 안에서 proxy 값으로 처리하면 되지 않을까요?'
- 안 됩니다. GeoMetryReader는 컨테이너뷰로 클로저 안에서 로직 처리를 할 수 없습니다.
- 그래서 조금은 복잡하지만 preference를 추가적으로 사용해주는 것입니다.
*참고: https://protocorn93.github.io/tags/PreferenceKey/
사용부
1. label과 content를 넘겨줍니다.
2. pageLabel()로 탭들을 화면에 꽉 맞게 채워주고, pageView()로 각 page의 width를 스크린 화면에 맞게 설정합니다.
struct AssetSearchView: View {
@State var offset: CGFloat = 0
@State var keyword: String = ""
var screenSize: CGSize
var body: some View {
VStack {
Spacer().frame(height: 50)
PagerTapView(tint: .black) {
Text("주식")
.pageLabel()
Text("코인")
.pageLabel()
Text("부동산")
.pageLabel()
} content: {
List {
Text("FirstTab")
}
.pageView()
List {
Text("SecondTab")
}
.pageView()
List {
Text("ThirdTab")
}
.pageView()
}
}
}
}
extension View {
func pageLabel() -> some View {
self
.frame(maxWidth: .infinity, alignment: .center)
}
func pageView() -> some View {
self
.frame(width: getScreenBounds().width, alignment: .center)
}
}
'SwiftUI' 카테고리의 다른 글
SwiftUI 성능 이해 및 최적화 방법 알아보기 (1) | 2024.06.07 |
---|---|
SwiftUI List 기본 스타일 지우기 (0) | 2024.03.01 |
SwiftUI - List에서 원하는 Row로 스크롤 하기 #scrollTo (0) | 2023.07.29 |
SwiftUI - scrollIndicators() 로 textEditor 스크롤바 hidden 처리하기 (0) | 2023.06.18 |
SwiftUI - @State, @Binding 개념 이해하기 (0) | 2023.05.13 |