🦁Custom TabView / TabBar🦁
탭뷰/ 탭바는 매우 많은 앱에서 사용하는 기능이다.
기본 탭뷰는 아래와 같은 형태이다. 하지만 기본 컴포넌트인 만큼 커스텀에 제약이 많다.
따라서 기본 탭뷰와 가장 유사하게 동작하지만, 많은 부분을 커스텀할 수 있는 탭뷰를 구현해 보자.
TabView(selection: $selection) {
Color.pink
.tabItem {
Image(systemName: "house")
Text("home")
}
Color.green
.tabItem {
Image(systemName: "tree")
Text("tree")
}
Color.blue
.tabItem {
Image(systemName: "drop.circle")
Text("water")
}
}
//TabView Definition
public struct TabView<SelectionValue, Content> : View where SelectionValue : Hashable, Content : View {
/// Creates an instance that selects from content associated with
/// `Selection` values.
public init(selection: Binding<SelectionValue>?, @ViewBuilder content: () -> Content)
이와 같이 플로팅 탭바의 느낌으로 구현하고자 한다 !
Enum TabItem
먼저 탭 아이템을 보면 심볼을 위한 이름, 탭 아이템 이름, 그리고 컬러가 필요하다.
모델을 만들어 관리한다면 아래와 같이 할 수 있을 것이다.
struct TabBarItem: Hashable {
let iconName: String
let title: String
let color: Color
}
하지만 보통 탭 아이템의 개수는 서버통신등에 의해 변하지 않고, 처음부터 개발자의 의도대로 정해져 있다.(개수를 알고 있음)
따라서 enum을 활용해서 아래와 같이 관리하는 것이 효율적일 수 있다.
enum TabBarItem: Hashable {
case home, likes, profile
var iconName: String {
switch self {
case .home: return "house"
case .likes: return "heart"
case .profile: return "person"
}
}
var title: String {
switch self {
case .home: return "Home"
case .likes: return "Likes"
case .profile: return "Profile"
}
}
var color: Color {
switch self {
case .home: return .red
case .likes: return .green
case .profile: return .blue
}
}
}
탭뷰의 구조는 크게 실제 컨텐츠가 들어가는 컨텐츠 영역, 그리고 하단의 탭아이템(탭바)영역으로 나눌 수 있다.
먼저 탭아이템(탭바)영역부터 구현해 본다.
TabBarView
먼저 하단의 탭바 부분을 구현해보자.
사전에 TabBarItem enum을 만들어 뒀다는 것을 기억하자.(계산속성으로 iconName, title, Color 리턴)
import SwiftUI
struct CustomTabBarView: View {
//배열로 TabBarItem을 받아옴
let tabs: [TabBarItem]
//탭바 선택에 따라 컨텐츠 전환을 위한 바인딩 프로퍼티
@Binding var selection: TabBarItem
//탭바 내부 전환에만 애니메이션을 걸기 위한 @State 프로퍼티
@State var localSelection: TabBarItem
//matchedGeometryEffect를 통해 탭바 내부 전환 애니메이션을 구현하기 위한 namespace
@Namespace private var namespace
var body: some View {
tabBar
//탭바 내부 전환 애니메이션 구현을 위한 onChange.
//바인딩 selection의 변경은 탭아이템 탭제스쳐를 통해 이루어짐.
//즉 탭 아이템을 탭했을 때, 애니메이션과 함께 localSelection을 해당 탭으로 바꿔줘서
//해당 탭으로 전환 애니메이션이 자연스럽게 이루어짐.
.onChange(of: selection) { newValue in
withAnimation(.easeInOut(duration: 0.25)) {
localSelection = newValue
}
}
}
}
extension CustomTabBarView {
//MARK: 탭 아이템 구현
private func tabView(tab: TabBarItem) -> some View {
//TabBarItem 타입을 받아와서 VStack에 쌓음
VStack {
Image(systemName: tab.iconName)
.font(.subheadline)
Text(tab.title)
.font(.system(size: 10, weight: .semibold, design: .rounded))
}
//실제 콘텐츠 부분을 전환하는 부분과 따로, 탭바 내부의 컬러 변경 및 애니메이션을 위해
//@State localSelection 프로퍼티가 존재함.
//굳이 이렇게 하지 않고, 탭 선택에 따라 변하는 프로퍼티를 하나만 관리할 수도 있음
//ForEach를 통해 탭바를 그리고, 선택/미선택에 따라 색상 변경을 위한 코드
.foregroundColor(localSelection == tab ? tab.color : Color.gray)
.padding(.vertical, 8)
.frame(maxWidth: .infinity)
.background(
//선택되었을 때 확실한 표시를 위한 배경
ZStack {
if localSelection == tab {
RoundedRectangle(cornerRadius: 10)
.fill(tab.color.opacity(0.2))
//탭간 자연스러운 전환 애니메이션을 위한 matchedGeometryEffect
.matchedGeometryEffect(id: "backgroundRect", in: namespace)
}
}
)
}
//MARK: 탭바 구현
private var tabBar: some View {
//수평으로 탭아이템들이 나열된 형태 -> HStack, ForEach 활용
HStack {
//tabs는 [TabBarItem], 탭바아이템은 Hashable
ForEach(tabs, id: \.self) { tab in
tabView(tab: tab)
//다른 탭아이템을 탭했을 때 해당 탭으로 전환을 위한 코드
.onTapGesture {
switchToTab(tab: tab)
}
}
}
.padding(6)
.background(
Color.white.ignoresSafeArea(edges: .bottom)
)
.cornerRadius(10)
//그림자를 통해 입체감 추가
.shadow(color: .black.opacity(0.3), radius: 10, x: 0, y: 5)
.padding(.horizontal)
}
//MARK: 탭 간 전환을 위한 메서드
private func switchToTab(tab: TabBarItem) {
//주석 처리된 부분을 해제하면 컨텐츠 전환(내부 화면)에도 애니메이션이 걸린다.
// withAnimation(.easeInOut) {
//선택한 탭을 바인딩 selection에 할당해줌
selection = tab
// }
}
}
TabBarContainerView
이제 탭바 코드가 준비되었으니, 컨텐츠와 함께 그려보자.
기본 탭뷰의 정의를 다시 보면 아래와 같다.
public struct TabView<SelectionValue, Content> : View where SelectionValue : Hashable, Content : View {
/// Creates an instance that selects from content associated with
/// `Selection` values.
public init(selection: Binding<SelectionValue>?, @ViewBuilder content: () -> Content)
제네릭과 뷰빌더가 활용되어 있다.
SelectionValue는 Hashable 해야하고, Content는 View 타입이며
@ViewBuilder를 통해 클로저를 활용할 수 있다.(View 를 채택하는 Content를 반환해야함).
지금 우리의 커스텀 코드에서는 TabBarItem이 Hashable하기 때문에 정의 단계에서 제네릭 조건이 생략되어있음을 참고.
struct CustomTabBarContainerView<Content: View>: View {
//상위 뷰에서 초기화 할 때 TabBarItem을 받아옴
@Binding var selection: TabBarItem
//하단 탭바 초기화를 위해서는 [TabBarItem] 필요.
@State private var tabs: [TabBarItem] = []
//탭뷰 컨텐츠
let content: Content
init(selection: Binding<TabBarItem>, @ViewBuilder content: () -> Content) {
//바인딩 프로퍼티라 언더바
self._selection = selection
self.content = content()
}
var body: some View {
ZStack(alignment: .bottom) {
//화면에 꽉 차게
content.ignoresSafeArea()
//하단 탭바 초기화
CustomTabBarView(tabs: tabs, selection: $selection, localSelection: selection).ignoresSafeArea(.keyboard)
}
//탭들을 PreferenceKey로 관리함.
//배열에 추가하는 방식으로
.onPreferenceChange(TabBarItemsPreferenceKey.self) { value in
self.tabs = value
}
}
}
TabBarItemsPreferenceKey
struct TabBarItemsPreferenceKey: PreferenceKey {
//defaultValue는 빈배열
static var defaultValue: [TabBarItem] = []
//커스텀 탭뷰에서 탭아이템을 추가할 때마다 위 배열에 추가되는 방식.
static func reduce(value: inout [TabBarItem], nextValue: () -> [TabBarItem]) {
value += nextValue()
}
}
기본 탭뷰의 사용법은 아래와 같다.
이와 유사하게 사용할 수 있도록 아래와 같이 구현할 수 있다.
struct TabBarItemViewModifier: ViewModifier {
let tab: TabBarItem
@Binding var selection: TabBarItem
func body(content: Content) -> some View {
content
//선택되었을 때만 투명도가 1이 되어 컨텐츠가 보이는 형태
//다만 이 방식은 뷰 계층에 모든 탭뷰의 컨텐츠가 렌더링되어 있고, 투명도만 바꾸기 때문에
//효율적인 코드라고는 생각하지 않음.
//(간단한 뷰들은 괜찮지만 탭의 개수가 많아지거나, 컨텐츠가 무거울 경우)
.opacity(selection == tab ? 1.0 : 0.0)
//preference를 통해 배열에 탭을 추가함
.preference(key: TabBarItemsPreferenceKey.self, value: [tab])
}
}
extension View {
func tabBarItem(tab: TabBarItem, selection: Binding<TabBarItem>) -> some View {
self
.modifier(TabBarItemViewModifier(tab: tab, selection: selection))
}
}
상위뷰에서 활용한다면
struct AppTabBarView: View {
@State private var tabSelection: TabBarItem = .home
var body: some View {
CustomTabBarContainerView(selection: $tabSelection) {
Color.red
.tabBarItem(tab: .home, selection: $tabSelection)
Color.green
.tabBarItem(tab: .likes, selection: $tabSelection)
Color.blue
.tabBarItem(tab: .profile, selection: $tabSelection)
}
}
}
확실히 커스텀을 하면 예쁘고..입맛대로 바꿀 수 있어서 좋지만
너무나도 복잡해지고 공수가 많이 들기 때문에 우선은 기본 컴포넌트를 사용하는 방향으로 가고
정말 커스텀이 필요한 경우 디자이너와 협의를 보는 방향이 좋지 않을까...라는 생각이 들었다. ㅋㅋㅋ
@State, @Binding, Generic, @ViewBuilder, @PreferenceKey, matchedGeometryEffect, enum, extension...
정말 사용된 개념이 많아서 많이 복잡하고 어려웠다.. 😅
특히 그냥 구현하는 것이 아니라, 재사용성과 유지보수, 모듈화를 위한 코드는 훨씬 작성이 어려운 것 같다.
'SwiftUI > SwiftUI(Advanced)' 카테고리의 다른 글
10. UIViewRepresentable / Custom TextField (0) | 2023.06.25 |
---|---|
9. Custom NavigationView/Link (0) | 2023.06.25 |
7. PreferenceKey / Custom Navigation Bar (0) | 2023.06.23 |
6. @ViewBuilder / 뷰 빌더 (0) | 2023.06.22 |
5. Generic 제네릭 (0) | 2023.06.21 |