궁금한 내용을 검색해보세요!
이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
서근 개발노트
티스토리에 팔로잉
PROJECT/Simple

SwiftUI Project9 : CustomTabView (geometryReader)

서근
QUOTE THE DAY

-
Written by SeogunSEOGUN

반응형


CustomTabView 

우선 MyView 라는 SwiftUI 파일을 생성해서 위에 보이는 메인 배경 뷰를 만들어 주겠습니다.

//  MyView.swift
import SwiftUI

struct MyView: View {
    
    //타이틀과 배경색을 변수로 지정
    var title: String
    var bgColor: Color
    
    var body: some View {
        
        ZStack {
            bgColor
               //safeArea 부분까지 채워줌
                .edgesIgnoringSafeArea(.all)
            
            Text(title)
                .font(.largeTitle)
                .foregroundColor(.white)
                .fontWeight(.bold)
        }
    }
}

struct MyView_Previews: PreviewProvider {
    static var previews: some View {
    
       //미리보기를위해 임시로 아래와같이 기본값을 정해줘야함
        MyView(title: "서근개발블로그", bgColor: Color.red)
    }
}

TabView 구현

이제 tabView를 직접 만들어서 구현하려고 합니다. enum을 사용하여 tabBar에 보여줄 텍스트를 작성합니다.

struct MyCustomView: View {
    enum TabIndex {
        case home, photo, profile
    }

이제 이 열거형을 매개변수매개값으로 사용해야 합니다.

 

열거형 아래에  @State var tabIndex: tabIndex 를 넣어줍니다.

 

MyViewswitch 문으로 가져오려고 하는데 먼저 함수를 작성해야 합니다.  작성해줬던 TabIndex를 매개변수로 가져오고 MyView를 반환해주겠습니다. default 부분은 기본값이 되므로 홈에 있는 값을 넣어줍니다.

 func showMyView(tabIndex: TabIndex) -> MyView {
        switch tabIndex {
        case .home:
            return MyView(title: "서근개발블로그", bgColor: Color.yellow)
        case .photo:
            return MyView(title: "사진첩", bgColor: Color.red)
        case .profile:
            return MyView(title: "사용자 계정", bgColor: Color.blue)
        default:
            return MyView(title: "서근개발블로그", bgColor: Color.yellow)
        }
    }

그리고 TabBar를 꾸며줄 건데 GeometryReader를 사용하여 동일한 width를 만들어줘야 합니다.  ZStack아래에 우리가 만들어둔 MyView도 호출해줍니다.

 var body: some View {
        GeometryReader { geo in
            ZStack {
                self.showMyView(tabIndex: self.tabIndex)
                
                HStack {
                    Button(action: {
                        print("홈을 선택했습니다.")
                    }) {
                       Image(systemName: "house.fill")
                            .frame(width: geo.size.width / 3, height: 50)
                            .font(.title2)
                            .foregroundColor(.blue)
                    }
                    .background(Color.white)
                }
            }
        }.edgesIgnoringSafeArea(.all)

저희는 한 화면에 3개의 tabBar를 만들어 주려고 하기 때문에 전체 가로에서 나누기 3을 했습니다. 그리고 Xcode를 보면 Preview쪽에 에러가 나는데 이것은 기본값이 없기 때문입니다.

 

기본값은 MyCustomView(tabIndex: .home)이라고 넣어줍니다.

 

그런데 아이콘이 화면 위쪽에 배치되어 있죠? ZStackalignmentbottom으로 설정해줍니다. ZStack(alignment: .bottom)

 

HStack에 있는 버튼을 복사해서 탭에 총 3개가 되도록 붙여 넣기 합니다. 

 

TIP
 
 

 Stack 사용 시 주의사항
SwiftUI의 모든 Stack 에는 자동으로 spacing이 적용되어있습니다.
이것을 해결하기 위해서는 HStack(spacing: 0) 으로 설정해줘야 합니다.

 

현재까지 코드

더보기
struct MyCustomView: View {
    enum TabIndex {
        case home, photo, profile
    }
    @State var tabIndex: TabIndex
    
    func showMyView(tabIndex: TabIndex) -> MyView {
        switch tabIndex {
        case .home:
            return MyView(title: "서근개발블로그", bgColor: Color.yellow)
        case .photo:
            return MyView(title: "사진첩", bgColor: Color.red)
        case .profile:
            return MyView(title: "사용자 계정", bgColor: Color.blue)
        default:
            return MyView(title: "서근개발블로그", bgColor: Color.yellow)
        }
    }
    
    var body: some View {
        GeometryReader { geo in
            ZStack(alignment: .bottom){
                self.showMyView(tabIndex: self.tabIndex)
                
                HStack(spacing: 0) {
                    Button(action: {
                        print("홈을 선택했습니다.")
                    }) {
                        Image(systemName: "house.fill")
                            .frame(width: geo.size.width / 3, height: 50)
                            .font(.title2)
                            .foregroundColor(.blue)
                    }
                    .background(Color.white)
                    
                    Button(action: {
                        print("홈을 선택했습니다.")
                    }) {
                        Image(systemName: "photo.fill")
                            .frame(width: geo.size.width / 3, height: 50)
                            .font(.title2)
                            .foregroundColor(.blue)
                    }
                    .background(Color.white)
                    
                    Button(action: {
                        print("홈을 선택했습니다.")
                    }) {
                        Image(systemName: "person.circle.fill")
                            .frame(width: geo.size.width / 3, height: 50)
                            .font(.title2)
                            .foregroundColor(.blue)
                    }
                    .background(Color.white)
                }
            }
        }
        .edgesIgnoringSafeArea(.all)
        
    }
}

struct MyCustomView_Previews: PreviewProvider {
    static var previews: some View {
        MyCustomView(tabIndex: .home)
    }
}

 

버튼 클릭시 뷰 전환

버튼을 눌렀을 때 지정된 View가 나오도록 ButtonAction쪽에 아래와 같이 코드를 작성합니다.

Button(action: {

     self.tabIndex = .home
     print("홈을 선택했습니다.")
     
  }) { }

활성화 된 버튼과 비활성화 버튼 색 효과

한 가지 재미난 기능을 추가해주려고 하는데 예를 들어 홈버튼을 눌렀을 때는 나머지 버튼들이 회색으로 변하고 원하는 버튼을 눌렀을 땐 지정된 색이 되도록 하려고 합니다. 이 기능을 사용하기 위해서는 함수를 작성해줘야 합니다.

    func changeColor(tabIndex: TabIndex) -> Color {
        switch tabIndex {
        case .home:
            return Color.yellow
        case .photo:
            return Color.red
        case .profile:
            return Color.blue
        default:
            return Color.yellow
        }
    }

함수를 호출해보도록 하겠습니다. Button ImageforegroundColor쪽을 수정합니다.

//tabIndex가 home이 선택되었다면, changeColor를 실행하고, 그게 아니라면 gray색을 부여해라
.foregroundColor(self.tabIndex == .home ? self.changeColor(tabIndex: tabIndex): Color.gray)
Button(action: {
 self.tabIndex = .home
 print("홈을 선택했습니다.")
}) {
 Image(systemName: "house.fill")
     .frame(width: geo.size.width / 3, height: 50)
     .font(.title2)
     .foregroundColor(self.tabIndex == .home ? self.changeColor(tabIndex: tabIndex): Color.gray)
}
.background(Color.white)

버튼 클릭시 아이콘 확대 효과

버튼의 색에 변화를 주었으니 이제 배운 것을 활용하여 버튼을 누를 때마다 사이즈가 커지는 방법도 할 수 있습니다.

.scaleEffect를 사용해야 합니다.

 @State var ImageSize: CGFloat = 1.2
Image(systemName: "house.fill")
//만약 tabIndex가 home이라면 ImageSize를 실행하고 그게 아니라면 사이즈는 1.0 이다.
.scaleEffect(self.tabIndex == .home ? self.ImageSize : 1.0)

버튼 이미지 크기가 변할때 animation효과를 주려면 withAnimation 을 적용시키면 됩니다.

Button(action: {
    withAnimation {
        self.tabIndex = .home
        print("홈을 선택했습니다.")
    }
}) {

아이콘을 보면 자연스럽게 애니메이션 효과가 들어갔지만, 한 가지 거슬리는 점은 텍스트까지 애니메이션 효과가 들어갔다는 것입니다. View의 애니메이션 효과를 주지 않기 위해서는 MyView로 이동해서 아래와 같이 코드를 넣어줘야 합니다.

//  MyView.swift

struct MyView: View {
      ...
    var body: some View {  
        ZStack {
            ...
        }
        .animation(.none)
    }
}

현재까지 코드

더보기
struct MyCustomView: View {
    enum TabIndex {
        case home, photo, profile
    }
    @State var tabIndex: TabIndex
    @State var ImageSize: CGFloat = 1.2
    
    func showMyView(tabIndex: TabIndex) -> MyView {
        switch tabIndex {
        case .home:
            return MyView(title: "서근개발블로그", bgColor: Color.yellow)
        case .photo:
            return MyView(title: "사진첩", bgColor: Color.red)
        case .profile:
            return MyView(title: "사용자 계정", bgColor: Color.blue)
        default:
            return MyView(title: "서근개발블로그", bgColor: Color.yellow)
        }
    }
    
    func changeColor(tabIndex: TabIndex) -> Color {
        switch tabIndex {
        case .home:
            return Color.yellow
        case .photo:
            return Color.red
        case .profile:
            return Color.blue
        default:
            return Color.yellow
        }
    }
    
    var body: some View {
        GeometryReader { geo in
            ZStack(alignment: .bottom){
                self.showMyView(tabIndex: self.tabIndex)
                
                HStack(spacing: 0) {
                    Button(action: {
                        withAnimation {
                            self.tabIndex = .home
                            print("홈을 선택했습니다.")
                        }
                    }) {
                        Image(systemName: "house.fill")
                            .scaleEffect(self.tabIndex == .home ? self.ImageSize : 1.0)
                            .frame(width: geo.size.width / 3, height: 50)
                            .font(.title2)
                            .foregroundColor(self.tabIndex == .home ? self.changeColor(tabIndex: tabIndex): Color.gray)
                    }
                    .background(Color.white)
                    
                    Button(action: {
                        withAnimation {
                            self.tabIndex = .photo
                            print("홈을 선택했습니다.")
                        }
                    }) {
                        Image(systemName: "photo.fill")
                            .scaleEffect(self.tabIndex == .photo ? self.ImageSize : 1.0)
                            .frame(width: geo.size.width / 3, height: 50)
                            .font(.title2)
                            .foregroundColor(self.tabIndex == .photo ? self.changeColor(tabIndex: tabIndex): Color.gray)
                    }
                    .background(Color.white)
                    
                    Button(action: {
                        withAnimation {
                            self.tabIndex = .profile
                            print("홈을 선택했습니다.")
                        }
                    }) {
                        Image(systemName: "person.circle.fill")
                            .scaleEffect(self.tabIndex == .profile ? self.ImageSize : 1.0)
                            .frame(width: geo.size.width / 3, height: 50)
                            .font(.title2)
                            .foregroundColor(self.tabIndex == .profile ? self.changeColor(tabIndex: tabIndex): Color.gray)
                    }
                    .background(Color.white)
                }
            }
        }
        .edgesIgnoringSafeArea(.all)
        
    }
}

탭바 아이콘 클릭시 반원 효과주기

tabBar의 버튼이 눌리면 뒤에 반원의 도형이 따라오도록 만들어줍니다. ZStack안에 Circle을 추가합니다.

ZStack(alignment: .bottom){
                self.showMyView(tabIndex: self.tabIndex)
               
               Circle()
                    .foregroundColor(.white)
                    .frame(width: 70, height: 70)

지금은 도형이 가운데에 가있죠? 하지만 우리는 버튼이 누르는 곳으로 이 도형을 이동시켜야 합니다. offset수정자가 필요하고 이것의 위치를 계산하는 함수도 만들어줘야 합니다. .offset(x: geo.size.width / 3, y: 0)

home쪽으로 이동시키려면 .offset(x: -(geo.size.width / 3), y: 0)

자, 어느 정도 위치를 표현하는 법은 알게 되었습니다. 이제 함수를 작성해줄게요.

func CalculatePosition(tabIndex: TabIndex, geo: GeometryProxy) -> CGFloat {
        switch tabIndex {
        case .home:
            return -(geo.size.width / 3)
        case .photo:
            return 0
        case .profile:
            return (geo.size.width / 3)
        default:
            return -(geo.size.width / 3)
        }
    }

Geometry매개변수로 가져올 때는 GeometryProxy를 사용하고 CGFloat반환해야 합니다.

Circle()
        ...
    
    .offset(x: self.CalculatePosition(tabIndex: self.tabIndex, geo: geo),y: 0)

버튼 클릭시 위로 떠오르는 효과

다음으로는 특정 버튼을 눌렀을 때 아이콘이 위로 떠오르기 offset 효과를 주겠습니다.

Button(action: {
    withAnimation {
        ...
    }
}) {
    Image(systemName: "photo.fill")
        ...
        //tabIndex가 photo라면 y축은 -12 그게아니라면 0
        .offset(y: self.tabIndex == .photo ? -12 : 0)

시뮬레이터 메인화면 지정

우리는 MyCustomView라는 파일을 새로 만들어서 그곳에 코드를 작성했지만 Xcode의 기본 ViewContentView로 되어있습니다. 이것을 MyCustomView로 바꿔주겠습니다.

 

SwiftUI_TTTApp 으로 들어가서 ContentView()부분을 MyCustomView()로 수정합니다.

import SwiftUI

@main
struct SwiftUI_TTTApp: App {
    var body: some Scene {
        WindowGroup {
            MyCustomView()
        }
    }
}

위처럼 오류가 나는 이유는 기본값이 설정되어있지 않기 때문입니다. Fix를 눌러주세요. 기본값은 home으로 해주겠습니다.

import SwiftUI

@main
struct SwiftUI_TTTApp: App {
    var body: some Scene {
        WindowGroup {
            MyCustomView(tabIndex: .home)
        }
    }
}

그리고 + R 을 눌러 실행해보면 정상적으로 화면이 보여집니다.

베젤이 없는 기종과 있는 기기에 SafeArea 각각 적용

한 가지 문제가 있습니다. 위 이미지처럼 베젤이 없는 iPhone X이후 시리즈에서는 컨트롤바 때문에 버튼이 가려집니다. tabBar 아래쪽에 Rectangel을 추가하여 높이를 좀 추가해주도록 하겠습니다. 그러기 위해선 HStack위에 VStack으로 한번 더 감싸줘야 합니다.

Stackspacing이 자동으로 적용되어있어서 마찬가지로 VStack에도 sapcing0으로 설정해줍니다.

var body: some View {
        GeometryReader { geo in
            ZStack(alignment: .bottom){
            
                ...
                .offset(x: self.CalculatePosition(tabIndex: self.tabIndex, geo: geo),y: -20)
                
                VStack(spacing: 0) {
                    HStack(spacing: 0) { 
                           ... 
                     }
                     
                    Rectangle()
                        .foregroundColor(.white)
                        .frame(height: 20)
                    
                }
            }
            .edgesIgnoringSafeArea(.all)
            
        }
    }
}

아까보다 훨씬 좋아졌어요 그렇죠? 하지만! 한 가지 더 문제점이 있죠.. 베젤이 있는 iPhone 8 으로 실행해보도록 하겠습니다.

굉장히 보기 안 좋습니다. 이럴 때는 frame에 코드를 작성해 주면 되는데 자세한 코드 설명은 아래 링크를 클릭해주세요.

 

 

Rectangle()
    .foregroundColor(.white)
    .frame(height: UIApplication.shared.windows.first?.safeAreaInsets.bottom == 0 ? 0 : 20)

ZStack Circleoffset부분도 수정해줘야겠죠?

.offset(x: self.CalculatePosition(tabIndex: self.tabIndex, geo: geo), y: UIApplication.shared.windows.first?.safeAreaInsets.bottom == 0 ? 0 : -20)

전체코드

<hide/>

import SwiftUI

struct MyCustomView: View {
    enum TabIndex {
        case home, photo, profile
    }
    @State var tabIndex: TabIndex
    @State var ImageSize: CGFloat = 1.2
    
    func showMyView(tabIndex: TabIndex) -> MyView {
        switch tabIndex {
        case .home:
            return MyView(title: "서근개발블로그", bgColor: Color.yellow)
        case .photo:
            return MyView(title: "사진첩", bgColor: Color.red)
        case .profile:
            return MyView(title: "사용자 계정", bgColor: Color.blue)
        default:
            return MyView(title: "서근개발블로그", bgColor: Color.yellow)
        }
    }
    
    func changeColor(tabIndex: TabIndex) -> Color {
        switch tabIndex {
        case .home:
            return Color.yellow
        case .photo:
            return Color.red
        case .profile:
            return Color.blue
        default:
            return Color.yellow
        }
    }
    
    func CalculatePosition(tabIndex: TabIndex, geo: GeometryProxy) -> CGFloat {
        switch tabIndex {
        case .home:
            return -(geo.size.width / 3)
        case .photo:
            return 0
        case .profile:
            return (geo.size.width / 3)
        default:
            return -(geo.size.width / 3)
        }
    }
    
    var body: some View {
        GeometryReader { geo in
            ZStack(alignment: .bottom){
                self.showMyView(tabIndex: self.tabIndex)
                
                Circle()
                    .foregroundColor(.white)
                    .frame(width:70, height: 70)
                    .offset(x: self.CalculatePosition(tabIndex: self.tabIndex, geo: geo), y: UIApplication.shared.windows.first?.safeAreaInsets.bottom == 0 ? 0 : -20)
                VStack(spacing: 0) {
                    HStack(spacing: 0) {
                        Button(action: {
                            withAnimation {
                                self.tabIndex = .home
                                print("홈을 선택했습니다.")
                            }
                        }) {
                            Image(systemName: "house.fill")
                                .scaleEffect(self.tabIndex == .home ? self.ImageSize : 1.0)
                                .frame(width: geo.size.width / 3, height: 50)
                                .font(.title2)
                                .foregroundColor(self.tabIndex == .home ? self.changeColor(tabIndex: tabIndex): Color.gray)
                                .offset(y: self.tabIndex == .home ? -12 : 0 )
                            
                        }
                        .background(Color.white)
                        
                        Button(action: {
                            withAnimation {
                                self.tabIndex = .photo
                                print("홈을 선택했습니다.")
                            }
                        }) {
                            Image(systemName: "photo.fill")
                                .scaleEffect(self.tabIndex == .photo ? self.ImageSize : 1.0)
                                .frame(width: geo.size.width / 3, height: 50)
                                .font(.title2)
                                .foregroundColor(self.tabIndex == .photo ? self.changeColor(tabIndex: tabIndex): Color.gray)
                                .offset(y: self.tabIndex == .photo ? -12 : 0)
                        }
                        .background(Color.white)
                        
                        Button(action: {
                            withAnimation {
                                self.tabIndex = .profile
                                print("홈을 선택했습니다.")
                            }
                        }) {
                            Image(systemName: "person.circle.fill")
                                .scaleEffect(self.tabIndex == .profile ? self.ImageSize : 1.0)
                                .frame(width: geo.size.width / 3, height: 50)
                                .font(.title2)
                                .foregroundColor(self.tabIndex == .profile ? self.changeColor(tabIndex: tabIndex): Color.gray)
                                .offset(y: self.tabIndex == .profile ? -12 : 0)
                        }
                        .background(Color.white)
                    }//HStack
                    Rectangle()
                        .foregroundColor(.white)
                        .frame(height: UIApplication.shared.windows.first?.safeAreaInsets.bottom == 0 ? 0 : 20)
                    
                }
            }
            .edgesIgnoringSafeArea(.all)
            
        }
    }
}

struct MyCustomView_Previews: PreviewProvider {
    static var previews: some View {
        MyCustomView(tabIndex: .home)
    }
}

 

 

읽어주셔서 감사합니다🤟

 

본 게시글의 전체코드 GitHub 👇🏻

 

Seogun95/SwiftUI_CustomTabView_TUT

SwiftUI_CustomTabView_TUT. Contribute to Seogun95/SwiftUI_CustomTabView_TUT development by creating an account on GitHub.

github.com


잘못된 내용이 있으면 언제든 피드백 부탁드립니다.


서근


위처럼 이미지 와 함께 댓글을 작성할 수 있습니다.