짜잔~! 이번에 만들어 볼 화면은 이것! 탭메뉴로 이동하는 화면을 만들어 보자.
1. 뷰 구조잡기
- Button 들은 HStack 으로 묶어 가로 정렬
- 현재 활성화된 Button 을 나타내는 Bar 는 Rectangle 로 그려준다.
- Bar 는 Button 의 너비와 같다.
- Button 들을 감싸는 HStack 과 Rectangle 은 VStack 으로 묶는다
2. 활성화된 버튼 하단에 Bar 를 애니메이션 하기
터치한 버튼에 따라, Bar 의 x 좌표를 움직여 현재 활성화된 버튼을 표시해보자.
위의 그림에서 Bar 는 활성화한 버튼을 기준으로 (버튼 너비 + Padding) 만큼 이동해야 한다.
Bar 가 움직일 위치를 계산해보자:
- Button[0] :
0
- Button[1] :
ButtonWidth + Padding
- Button[2] :
(ButtonWidth + Padding) * 2
이를 코드로 구현하면 다음과 같다:
var leadings = [CGFloat](repeating: 0, count: menus.count)
for i in 0..<menus.count {
let leading = (buttonWidth+padding) * CGFloat(i)
leadings[i] = leading
}
각 버튼의 x 좌표를 계산한 배열을 만들고 계산한 값을 넣었다. Button 의 index 로 배열에 접근하여 Bar 가 이동할 X 좌표를 얻을 수 있다.leadings[buttonIndex]
3. 코드 구현
- Tab Menu 와 누른 버튼에 따라 컨텐츠를 보여줄 TabView 로 화면을 구성했다.
- 현재 누른 메뉴를 저장하기 위한 프로퍼티로 activeMenu 를 두었다.
- Tab Menu에게 화면을 그려야할 전체 크기를 알려주기 위해 GeometryReader 를 사용했다.
struct ContentView: View {
@State private var activeMenu = Menu.menu1
var body: some View {
NavigationView {
// 하위뷰(HeaderTabView)에 상위뷰(VStack)의 크기를 넣어주기 위해 사용
GeometryReader { geo in
VStack {
// 탭 메뉴
HeaderTabView(
activeMenu: $activeMenu,
menus: Menu.allCases,
fullWidth: geo.size.width,
spacing: 32,
horizontalInset: 40)
// 컨텐츠 화면들
TabView(selection: $activeMenu) {
...
enum 으로 메뉴를 만들었고, index 로 Bar 를 이동해야 하므로 Int 를 채택했다:
enum Menu: Int, CaseIterable {
case menu1 = 0
case menu2 = 1
var title: String {
switch self {
case .menu1: return "메뉴1"
case .menu2: return "메뉴2"
}
}
}
컨텐츠 화면은 TabView 를 사용해 2개로 만들었고, 선택한 메뉴에 따라 페이지 이동이 될 수 있도록 activeMenu 의 바인딩을 넘겨주었다:
// Pages
TabView(selection: $activeMenu) {
ContentListView()
.tag(Menu.menu1)
Text(Menu.menu2.title)
.tag(Menu.menu2)
}
// activeMenu 가 바뀌었을때 컨텐츠 슬라이드
.animation(.default, value: activeMenu)
.frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height - 200)
.tabViewStyle(.page(indexDisplayMode: .never))
HeaderTabView 는 상위뷰에서 받아온 값인 activeMenu 를 바인딩한다. 초기화 코드는 아래와 같다:
struct HeaderTabView: View {
let menus: [Menu]
@Binding var activeMenu: Menu
@State private var barX: CGFloat = 0
// 버튼과 버튼 사이의 간격
private let spacing: CGFloat
private let buttonLeadings: [CGFloat]
private let barWidth: CGFloat
private let buttonWidth: CGFloat
private let fullWidth: CGFloat
init(
activeMenu: Binding<Menu>,
menus: [Menu],
fullWidth: CGFloat,
spacing: CGFloat,
horizontalInset: CGFloat
) {
//...
}
이니셜라이저에서 인자값을 사용해 Bar 가 움직일 위치, 버튼 크기를 결정한다.
버튼 크기 계산
전체 크기에서 옆간격, Padding 을 뺀 나머지가 Button 이 자치할 수 있는 크기다.
버튼 2개일때:
|옆간격|Button| Padding |Button|옆간격|
전체크기 = (버튼2) + (패딩1) + (옆 간격*2)
버튼 3개일때:
|옆간격|Button| Padding |Button| Padding |Button|옆간격|
전체 크기 = (버튼3) + (패딩2) + (옆 간격*2)
⇒ 패딩은 (버튼 갯수 — 1) 이라는 것을 알 수 있다. 코드로 구현해보면 이렇게 된다:
self.buttonWidth =
(self.fullWidth-spacing * CGFloat(menus.count-1)) / CGFloat(menus.count)
탭 버튼 눌렀을때 애니메이션하기
버튼을 눌렀을때 바인딩 프로퍼티인 activeMenu 를 갱신해준다. 이때, withAnimation 를 사용하면 Bar 가 부드럽게 애니메이션 되는 것을 확인할 수 있다:
// MARK: - HeaderTabView
HStack(spacing: spacing) {
ForEach(menus, id: \\.self) { menu in
Button {
activeMenu = menu
withAnimation {
barX = buttonLeadings[menu.rawValue]
}
} label: {
Text(menu.title)
.frame(maxWidth: buttonWidth)
.border(.orange, width: 1)
.foregroundColor(.white)
.bold()
}
}
}
withAnimation 함수의 구현은 다음과 같다:
func withAnimation<Result>(
_ animation: Animation? = .default,
_ body: () throws -> Result
) rethrows -> Result
withAnimation 의 클로저(body) 에 값을 변경하는 코드를 넣으면,
SwiftUI 가 새로 변경된 사항과 관련있는 뷰를 애니메이션 하도록 만들어 준다.
4. 번외: TabView 컨텐츠를 스크롤 이동했을때, 탭메뉴에 반영하기
이제까지 탭 메뉴를 눌렀을때, 해당 탭 메뉴의 하위 컨텐츠가 보이도록 만들었다.
- 탭메뉴 터치(active)
- 하위 컨텐츠 페이지 이동
하위 컨텐츠를 슬라이드 이동했을때, 탭메뉴가 이동하게 하려면 어떻게 해야할까?
- 하위 컨텐츠 페이지 제스쳐 이동
- 해당 탭 메뉴 active
HeaderTabView 에 onChange 이벤트를 걸면 된다.
activeMenu 는 상위에서 바인딩을 통해 받아오므로, 그 값이 바뀌었을때 onChange 를 통해 이벤트를 감지할 수 있다.
// MARK: - HeaderTabView
var body: some View {
VStack(alignment: .leading) {
....
}
// 이 view 에서 특정한값(activeIndex) 이 변경되었을때, 이벤트 발생!
.onChange(of: activeMenu) { selectedMenu in
withAnimation {
barX = buttonLeadings[selectedMenu.rawValue]
}
}
.background(.purple)
}
}
.onChange(of: activeMenu)
는 2가지 이벤트를 감지한다:
- 컨텐츠를 스크롤 제스쳐로 이동했을때
- 탭 아이템을 터치했을때
즉, 위에서 작성한 barX 업데이트 코드는 이제 필요없어지는 것…:
Button {
activeMenu = menu
// onChange 를 사용하면 이 코드는 무쓸모...
// withAnimation {
// barX = buttonLeadings[menu.rawValue]
// }
} label: {
...
마무리하며…
화면을 어떻게 만들까 우왕자왕하며 검색부터 먼저 해봤는데, SwiftUI 에 익숙하지 않으니 코드 분석에 오랜 시간이 걸렸다. 그래서 마음을 다잡고 노트에 그려보며 이렇게 하면 되지않을까…? 생각해보고 코드로 짜보니 되네…? 라는 느낌으로 진행했다 ㅎ…😌 처음 만들어 본다고 지레 겁먹지 말고 차근차근 생각부터 해봐야겠다.
SwiftUI 를 접한지 얼마되지 않았지만, 프리뷰를 통해 개발하니 확실히 생산성이 좋아진 느낌이다. 빌드 하지 않아도 프리뷰를 통해 변경사항을 바로 확인할 수 있어 편했다.
아래 저장소에서 전체 코드를 확인할 수 있습니다: