SwiftUI: 탭 메뉴 만들기(1)

SUJIN JIN
9 min readMar 4, 2023

--

짜잔~! 이번에 만들어 볼 화면은 이것! 탭메뉴로 이동하는 화면을 만들어 보자.

실행화면

1. 뷰 구조잡기

탭 메뉴 View구조
  • Button 들은 HStack 으로 묶어 가로 정렬
  • 현재 활성화된 Button 을 나타내는 Bar 는 Rectangle 로 그려준다.
  • Bar 는 Button 의 너비와 같다.
  • Button 들을 감싸는 HStack 과 Rectangle 은 VStack 으로 묶는다

2. 활성화된 버튼 하단에 Bar 를 애니메이션 하기

터치한 버튼에 따라, Bar 의 x 좌표를 움직여 현재 활성화된 버튼을 표시해보자.

Bar Animation

위의 그림에서 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 컨텐츠를 스크롤 이동했을때, 탭메뉴에 반영하기

이제까지 탭 메뉴를 눌렀을때, 해당 탭 메뉴의 하위 컨텐츠가 보이도록 만들었다.

  1. 탭메뉴 터치(active)
  2. 하위 컨텐츠 페이지 이동

하위 컨텐츠를 슬라이드 이동했을때, 탭메뉴가 이동하게 하려면 어떻게 해야할까?

  1. 하위 컨텐츠 페이지 제스쳐 이동
  2. 해당 탭 메뉴 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가지 이벤트를 감지한다:

  1. 컨텐츠를 스크롤 제스쳐로 이동했을때
  2. 탭 아이템을 터치했을때

즉, 위에서 작성한 barX 업데이트 코드는 이제 필요없어지는 것…:

Button {
activeMenu = menu
// onChange 를 사용하면 이 코드는 무쓸모...
// withAnimation {
// barX = buttonLeadings[menu.rawValue]
// }

} label: {
...

마무리하며…

화면을 어떻게 만들까 우왕자왕하며 검색부터 먼저 해봤는데, SwiftUI 에 익숙하지 않으니 코드 분석에 오랜 시간이 걸렸다. 그래서 마음을 다잡고 노트에 그려보며 이렇게 하면 되지않을까…? 생각해보고 코드로 짜보니 되네…? 라는 느낌으로 진행했다 ㅎ…😌 처음 만들어 본다고 지레 겁먹지 말고 차근차근 생각부터 해봐야겠다.

SwiftUI 를 접한지 얼마되지 않았지만, 프리뷰를 통해 개발하니 확실히 생산성이 좋아진 느낌이다. 빌드 하지 않아도 프리뷰를 통해 변경사항을 바로 확인할 수 있어 편했다.

아래 저장소에서 전체 코드를 확인할 수 있습니다:

--

--