SwiftUI - 상단탭바 구현하기

영점·2022년 11월 4일
0
post-thumbnail

그림처럼 쇼핑몰앱의 상단탭바를 구현하고 싶었다..
UIKit에서는 그냥 Tabman 라이브러리를 사용하여 구현했기에 SwiftUI에서는 어떻게 구현할지 고민되었다. ( 코드로 구현해보고 싶었다. )

일단 변경된 값을 화면에 보여줘야 하므로 @State를 사용해야 한다는건 알았는데 이후를 몰랐다..
( 질문하기 전에 바보같이 Segmented Control이 있다는걸 잊고있었다. )

아무튼 상황과 이미지(코드,완성예시)를 적어서 질문을 했는데 두가지 답변이 왔다.

  1. 그냥 간단하게 Picker를 사용해서 구현해보세요.
  1. @namespace 사용하시면 그림처럼 만들 수 있어요.

해서 둘 다 구현해보기로 했다!

들어가기 전에..

만약 위의 두가지 방법을 듣고 구현할 수 있다면 뒤로가기를 하시면 됩니다..ㅎㅎ

해당 코드가 정답은 아닙니다. 명심해주세요ㅠㅠ..
저는 이렇게 했지만 더 깔끔하게 구현할 수도 있습니다.. 저도 배우는 입장이니까요.

피드백 환영합니다. 코드 리뷰 완전 환영합니다.

1. Picker 사용하여 구현하기

먼저, 첫번째로 답변을 받은 Picker로 상단탭바를 구현해보기로 했다.

제일먼저 Picker에 들어갈 항목을 만들었다.

CaseIterable ( -> case들을 배열처럼 순회할 수 있도록 하는 프로토콜 )
https://0urtrees.tistory.com/197

enum tapInfo : String, CaseIterable {
    case info = "정보"
    case size = "사이즈"
    case review = "리뷰"
    case call = "문의"
}

배열로 구현해도 상관은 없다.
하지만 ForEach에서 tapInfo.indices로 순회해야한다. 참고해주세요!

그 다음 ForEach를 사용하여 열거형에 있는 정보,사이즈,리뷰,문의가 모두 뜨도록 해주었다.

Picker("Flavor", selection: $selectedPicker) {
    ForEach(tapInfo.allCases, id: \.self) {
        Text($0.rawValue)
    }
}
.pickerStyle(.segmented)
.padding()

이후 뷰에 VStack을 주어 상단에는 Picker를 두고 하단에는 뷰를 두어 상단탭바처럼 보이게 했다.

struct InfoView: View {

    @State private var selectedPicker: tapInfo = .info
    
    var body: some View {
        VStack {
            Picker("Pick", selection: $selectedPicker) {
                ForEach(tapInfo.allCases, id: \.self) {
                    Text($0.rawValue)
                }
            }
            .pickerStyle(.segmented)
            .padding()
            
            testView(tests: selectedPicker)
        }
    }
}

여기서 testView는 Picker를 클릭했을때 그 Picker에 맞는 뷰를 띄워주어야한다.

var tests : tapInfo

해서 해당 코드를 추가하고 switch-case를 사용하여 맞는 뷰를 띄워주게끔 했다.

switch tests {
    case .info:
        //Write UI here.
    case .size:
        //Write UI here.
    case .review:
        //Write UI here.
    case .call:
        //Write UI here.
}

그럼 이렇게 상단탭바가 완성된다.

전체코드
( 참고만 해주세요 )

import SwiftUI

enum tapInfo : String, CaseIterable {
    case info = "정보"
    case size = "사이즈"
    case review = "리뷰"
    case call = "문의"
}

struct InfoView: View {

    @State private var selectedPicker: tapInfo = .info
    
    var body: some View {
        VStack {
            Picker("Pick", selection: $selectedPicker) {
                ForEach(tapInfo.allCases, id: \.self) {
                    Text($0.rawValue)
                }
            }
            .pickerStyle(.segmented)
            .padding()
            
            testView(tests: selectedPicker)
        }
    }
}

struct testView : View {
    
    var tests : tapInfo
    
    var body: some View {
        ScrollView(.vertical, showsIndicators: false) {
            switch tests {
            case .info:
                ForEach(0..<5) { _ in
                    Text("블랙컬러")
                        .padding()
                    Image("shoes")
                        .resizable()
                        .frame(maxWidth: 350, minHeight: 500)
                }
            case .size:
                Text("사이즈 참고해주세요")
                    .font(.system(size: 15, weight: .bold, design: .monospaced))
                    .frame(width: 300, height: 20, alignment: .center)
                Text("발폭 넓으신분 -> 한사이즈 up!")
                    .padding()
            case .review:
                ScrollView(.horizontal, showsIndicators: false) {
                    ForEach(0..<10) { _ in
                        LazyHStack {
                            ForEach(0..<2) { _ in
                                NavigationLink(destination: ReviewView()){
                                    VStack(spacing: 5) {
                                        Image("shoes")
                                            .resizable()
                                            .frame(width: 160, height: 200, alignment: .center)
                                        Text("실착용 솔직 한달 후기 입니다")
                                            .font(.system(size: 15, weight: .bold, design: .monospaced))
                                            .frame(width: 160, height: 20, alignment: .leading)
                                            .foregroundColor(.black)
                                        Text("Sky Blue")
                                            .font(.system(size: 13, weight: .medium, design: .monospaced))
                                            .frame(width: 160, height: 20, alignment: .leading)
                                            .foregroundColor(.black)
                                        Text("평발인데 너무편해요 공간도 넉넉해서 걸을때 불편하지 않아요 최고입니다 ㅋㅋ 재구매의사 100%")
                                            .font(.system(size: 13, weight: .medium, design: .default))
                                            .frame(width: 160, height: 50, alignment: .leading)
                                            .foregroundColor(.black)
                                    }
                                    .padding(15)
                                }
                            }
                        }
                    }
                }
            case .call:
                VStack {
                    Text("별도의 커뮤니티를 운영하지 않습니다.")
                    Text("자세한 문의는 여기로 부탁드립니다")
                    Text("02-xxx-xxxx")
                        .padding()
                }.padding()
            }
        }
    }
}

2. @namespace 사용하기

먼저 완성된 상단탭바는 이렇다.

처음 namespace를 접했을때는 이해가 안갔는데 질문을 하고 답변을 받으니.. 이해가갔다.
시작하기 전에 앞서 사용했던 열거형과 @State는 그대로 사용한다.
( 어짜피 상단탭바에 들어갈 내용도 같고, 각각 맞는 뷰를 띄워줘야 하기 때문 )

대신 우리는 Picker와 다르게 namespace를 사용하여 만들 것이기 때문에
@Namespace를 추가해준다.

enum tapInfo : String, CaseIterable {
    case info = "정보"
    case size = "사이즈"
    case review = "리뷰"
    case call = "문의"
}

struct InfoView: View {

    @State private var selectedPicker: tapInfo = .info
    @Namespace private var animation
    
    var body: some View {
		//MARK : Code here.
    }
}

그 다음 사진과 같은 뷰를 만드려면 VStack으로 tapInfo에 있는 문자열을 정렬해주고
HStack으로 tapInfo의 문자열과 언더바를 정렬해야한다.
tapInfo에 있는 문자열을 정렬하는 것은 Picker때와 같이 ForEach를 돌려준다.

여기서 자연스러운 제스처를 위해 matchedGeometryEffect도 사용해준다.
( 구현해봤는데 정확하진 않지만 잘 작동되었기에.. 들어가는 id는 뭘하든 상관 없는 것 같다. )

@ViewBuilder
private func animate() -> some View {
    HStack {
        ForEach(tapInfo.allCases, id: \.self) { item in
            VStack {
                Text(item.rawValue)
                    .font(.title3)
                    .frame(maxWidth: .infinity/4, minHeight: 50)
                    .foregroundColor(selectedPicker == item ? .black : .gray)

                if selectedPicker == item {
                    Capsule()
                        .foregroundColor(.black)
                        .frame(height: 3)
                        .matchedGeometryEffect(id: "info", in: animation)
                }
                    
            }
            .onTapGesture {
                withAnimation(.easeInOut) {
                    self.selectedPicker = item
                }
            }
        }
    }
}

그럼 사진처럼 탭바가 완성될 것이다.
필자는 ViewBuilder로 따로 빼서 구현했지만 그냥 구조체뷰에 만들어도 상관없다.
그냥 개인차이..

뷰빌더를 간략하게나마 알고 싶다면 해당 포스트를 참고!

https://velog.io/@budlebee/SwiftUI-ViewBuilder

그다음 만들었던 탭바와 뷰를 VStack으로 정렬시키면 완성이다.

struct InfoView: View {

    @State private var selectedPicker: tapInfo = .info
    @Namespace private var animation
    
    var body: some View {
		VStack {
            animate()
            testView(tests: selectedPicker)
        }
    }
}

완성 화면

전체코드
( 참고만 해주세요 )

//InfoView.swift
enum tapInfo : String, CaseIterable {
    case info = "정보"
    case size = "사이즈"
    case review = "리뷰"
    case call = "문의"
}

struct InfoView: View {

    @State private var selectedPicker: tapInfo = .info
    @Namespace private var animation
    
    var body: some View {
        VStack {
            animate()
            testView(tests: selectedPicker)
        }
    }
    
    @ViewBuilder
    private func animate() -> some View {
        HStack {
            ForEach(tapInfo.allCases, id: \.self) { item in
                VStack {
                    Text(item.rawValue)
                        .font(.title3)
                        .frame(maxWidth: .infinity/4, minHeight: 50)
                        .foregroundColor(selectedPicker == item ? .black : .gray)

                    if selectedPicker == item {
                        Capsule()
                            .foregroundColor(.black)
                            .frame(height: 3)
                            .matchedGeometryEffect(id: "info", in: animation)
                    }
                    
                }
                .onTapGesture {
                    withAnimation(.easeInOut) {
                        self.selectedPicker = item
                    }
                }
            }
        }
    }
}

struct testView : View {
    
    var tests : tapInfo
    
    var body: some View {
        ScrollView(.vertical, showsIndicators: false) {
            switch tests {
            case .info:
                ForEach(0..<5) { _ in
                    Text("블랙컬러")
                        .padding()
                    Image("shoes")
                        .resizable()
                        .frame(maxWidth: 350, minHeight: 500)
                }
            case .size:
                Text("사이즈 참고해주세요")
                    .font(.system(size: 15, weight: .bold, design: .monospaced))
                    .frame(width: 300, height: 20, alignment: .center)
                Text("발폭 넓으신분 -> 한사이즈 up!")
                    .padding()
            case .review:
                ScrollView(.horizontal, showsIndicators: false) {
                    ForEach(0..<10) { _ in
                        LazyHStack {
                            ForEach(0..<2) { _ in
                                NavigationLink(destination: ReviewView()){
                                    VStack(spacing: 5) {
                                        Image("shoes")
                                            .resizable()
                                            .frame(width: 160, height: 200, alignment: .center)
                                        Text("실착용 솔직 한달 후기 입니다")
                                            .font(.system(size: 15, weight: .bold, design: .monospaced))
                                            .frame(width: 160, height: 20, alignment: .leading)
                                            .foregroundColor(.black)
                                        Text("Sky Blue")
                                            .font(.system(size: 13, weight: .medium, design: .monospaced))
                                            .frame(width: 160, height: 20, alignment: .leading)
                                            .foregroundColor(.black)
                                        Text("평발인데 너무편해요 공간도 넉넉해서 걸을때 불편하지 않아요 최고입니다 ㅋㅋ 재구매의사 100%")
                                            .font(.system(size: 13, weight: .medium, design: .default))
                                            .frame(width: 160, height: 50, alignment: .leading)
                                            .foregroundColor(.black)
                                    }
                                    .padding(15)
                                }
                            }
                        }
                    }
                }
            case .call:
                VStack {
                    Text("별도의 커뮤니티를 운영하지 않습니다.")
                    Text("자세한 문의는 여기로 부탁드립니다")
                    Text("02-xxx-xxxx")
                        .padding()
                }.padding()
            }
        }
    }
}
profile
일단 배운내용은 적어두기

0개의 댓글