MatchedGeometryEffect
애니메이션 효과가 적용된 보다 세련된 커스텀 탭바Generic
타입ViewBuilder
, View Modifier
PreferenceKey
MatchedGeometryEffect
enum
사용private func tabView(tab: TabBarItem) -> some View {
VStack {
Image(systemName: tab.iconName)
.font(.subheadline)
Text(tab.title)
.font(.system(size: 10, weight: .semibold, design: .rounded))
}
.foregroundColor(localSelection == tab ? tab.color : Color.gray)
.padding(.vertical, 8)
.frame(maxWidth: .infinity)
.background(localSelection == tab ? tab.color.opacity(0.2) : .clear)
.cornerRadius(10)
}
selection
상태에 따라서 텍스트 컬러, 백그라운드 컬러 변경된 뷰를 렌더링struct CustomTabBarContainerView<Content: View>: View {
let content: Content
@Binding var selection: TabBarItem
@State private var tabs: [TabBarItem] = [.home, .favorites, .profile]
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, localSelection: selection, selection: $selection)
}
.onPreferenceChange(TabBarItemPreferenceKey.self) { value in
self.tabs = value
}
}
}
View
프로토콜을 준수하는 Content
, 즉 서브 뷰를 클로저 형태로 받기 위해 @ViewBuilder
를 받음selection
을 부모 탭뷰의 selection
과 바인딩PreferenceKey
의 onPreferenceChange
연결이동하는 것처럼
보이게 만들기import SwiftUI
enum TabBarItem: Hashable {
case home, favorites, profile, messages
var iconName: String {
switch self {
case .home:
return "house"
case .favorites:
return "heart"
case .profile:
return "person"
case .messages:
return "message"
}
}
var title: String {
switch self {
case .home:
return "HOME"
case .favorites:
return "FAVORITES"
case .profile:
return "PROFILE"
case .messages:
return "MESSAGES"
}
}
var color: Color {
switch self {
case .home:
return Color.red
case .favorites:
return Color.blue
case .profile:
return Color.green
case .messages:
return Color.orange
}
}
}
enum
으로 선언, 고정되어 있는 형태이기 때문에 구조체보다 사용 효율적import SwiftUI
struct CustomTabBarView: View {
let tabs: [TabBarItem]
@Namespace private var namespace
@State var localSelection: TabBarItem
@Binding var selection: TabBarItem
var body: some View {
tabBarVersion2
.onChange(of: selection) { newValue in
withAnimation(.easeInOut) {
localSelection = newValue
}
}
}
}
extension CustomTabBarView {
private func tabView(tab: TabBarItem) -> some View {
VStack {
Image(systemName: tab.iconName)
.font(.subheadline)
Text(tab.title)
.font(.system(size: 10, weight: .semibold, design: .rounded))
}
.foregroundColor(localSelection == tab ? tab.color : Color.gray)
.padding(.vertical, 8)
.frame(maxWidth: .infinity)
.background(localSelection == tab ? tab.color.opacity(0.2) : .clear)
.cornerRadius(10)
}
private func switchToTab(tab: TabBarItem) {
selection = tab
}
private func switchToTabWithAnimation(tab: TabBarItem) {
withAnimation(.easeInOut) {
selection = tab
}
}
private var tabBarVersion1: some View {
HStack {
ForEach(tabs, id:\.self) { tab in
tabView(tab: tab)
.onTapGesture {
switchToTab(tab: tab)
}
}
}
.padding(6)
.background(
Color.white.ignoresSafeArea(edges: .bottom)
)
}
}
extension CustomTabBarView {
private func tabView2(tab: TabBarItem) -> some View {
VStack {
Image(systemName: tab.iconName)
.font(.subheadline)
Text(tab.title)
.font(.system(size: 10, weight: .semibold, design: .rounded))
}
.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(id: "background_rectangle", in: namespace)
}
}
)
}
private var tabBarVersion2: some View {
HStack {
ForEach(tabs, id:\.self) { tab in
tabView2(tab: tab)
.onTapGesture {
switchToTab(tab: tab)
}
}
}
.padding(6)
.background(
Color.white.ignoresSafeArea(edges: .bottom)
)
.cornerRadius(10)
.shadow(color: Color.black.opacity(0.3), radius: 10, x: 0, y: 5)
.padding(.horizontal)
}
}
selection
을 상위 부모 탭뷰의 selection
과 바인딩 → 현재 선택된 탭 아이템을 알아낼 수 있음MatchedGeometryEffect
→ namespace
을 통해 각 탭을 구성하는 서로 다른 직사각형을 하나의 직사각형으로 인식. localSelection
과 selection
을 분리, selection
은 컨테이너 뷰의 즉각적 변화(switchToTab
함수에서 애니메이션이 아닌 곧바로 값을 주고 있음)를 위해, localSelection
은 탭뷰의 애니메이션 변화(onChange
부분에서 애니메이션으로 값을 줌)를 위해 별도로 선언tabBarVersion1
은 SwiftUI 기본 탭뷰와 동일, tabBarVersion2
는 커스텀 플로팅 탭뷰import SwiftUI
struct CustomTabBarContainerView<Content: View>: View {
let content: Content
@Binding var selection: TabBarItem
@State private var tabs: [TabBarItem] = [.home, .favorites, .profile]
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, localSelection: selection, selection: $selection)
}
.onPreferenceChange(TabBarItemPreferenceKey.self) { value in
self.tabs = value
}
}
}
@ViewBuilder
프로토콜을 따르도록 선언, 클로저 자체를 받아 제네릭하게 그리도록 구현onPreferenceChange
를 통해 현재 어떤 값이 선택되었는지 전달import SwiftUI
struct TabBarItemPreferenceKey: PreferenceKey {
static var defaultValue = [TabBarItem]()
static func reduce(value: inout [TabBarItem], nextValue: () -> [TabBarItem]) {
value += nextValue()
// appending not change
}
}
struct TabBarItemViewModifier: ViewModifier {
let tab: TabBarItem
@Binding var selection: TabBarItem
func body(content: Content) -> some View {
content
.opacity(selection == tab ? 1.0 : 0.0)
.preference(key: TabBarItemPreferenceKey.self, value: [tab])
}
}
extension View {
func tabBarItem(tab: TabBarItem, selection: Binding<TabBarItem>) -> some View {
modifier(TabBarItemViewModifier(tab: tab, selection: selection))
}
}
TapBarItemPreferenceKey
는 탭 바 아이템 이넘 배열을 기본 값으로 가지고, 새로운 값을 기존 값에 추가tabBarItem
뷰 모디파이어는 부모 탭 뷰에서 컨테이너 바 클로저에 컨테이너 뷰에 대한 탭 바 아이템을 달기 위한 모디파이어import SwiftUI
struct AppTabBarView: View {
@State private var tabSelection: TabBarItem = .home
var body: some View {
CustomTabBarContainerView(selection: $tabSelection) {
Color.red
.tabBarItem(tab: .home, selection: $tabSelection)
Color.blue
.tabBarItem(tab: .favorites, selection: $tabSelection)
Color.green
.tabBarItem(tab: .profile, selection: $tabSelection)
Color.orange
.tabBarItem(tab: .messages, selection: $tabSelection)
}
}
}
tabSelection
을 통해 현재 선택한 탭이 어떤 탭인지 tabBarItem
에게 전달, 매칭이 되지 않는다면 색을 주지 않고 회색을 주는 효과 적용CustomTabBarContainerView
단일 자식 뷰만을 호출, content
를 클로저 형태로 전달(@ViewBuilder
준수)하기 때문에 여러 개의 뷰 전달 가능 → ZStack으로 쌓이고 현재 택한 탭에 따라 컨테이너 뷰, 탭 바 중 '선택'과 '비선택' 값이 구별tabBarItem
은 컨테이너 뷰의 내용인 각 컬러 값의 뷰 모디파이어로 달려 있기 때문에 opacity
, preference
줄 수 있음extension AppTabBarView {
private var defaultTabView: some View {
TabView(selection: $tabSelection) {
Color.red
.tabItem {
Image(systemName: "house")
Text("HOME")
}
Color.blue
.tabItem {
Image(systemName: "heart")
Text("FAVORITES")
}
Color.orange
.tabItem {
Image(systemName: "person")
Text("PROFILE")
}
}
}
}
CustomTabBarContainerView
와 마찬가지로 단일 클로저 내에 탭뷰의 여러 개 컨텐츠가 전달 → 여러 개의 자식 뷰보다 선택한 탭 아이템에 따라서 해당 탭 뷰로 '이동하는' 듯한 효과를 줄 것이라 추측 가능SwiftUI 부트 캠프 내용 중에 가장 어렵고 까다로웠던 파트.
PreferenceKey
를 통해 부모-자식 뷰 간의 값이 이동, 변화하고 ViewBuilder
를 그리는 클로저 전달, 뷰를 쉽게 그리기 위한 뷰 모디파이어를 익스텐션으로 적용한 함수 사용 등 지금까지 배웠던 모든 내용을 종합적으로 적용하는 문제였다.PreferenceKey
의 onPreferenceChange
와 커스텀 PreferenceKey
의 내부 함수인 reduce
간의 상관관계를 파악하는 데 힘이 든다.