상세 컨텐츠

본문 제목

[SwiftUI] SwiftUI + TabView + Coordinator Pattern

IOS/SwiftUI

by 카키IOS 2024. 7. 24. 13:52

본문

타겟

💡 iOS 16 이상을 기준으로 작성된 코드입니다


준비

이전 포스팅 기본편과 큰 차이는 없습니다

  • 화면 전환 기능을 담당하게 될 Coordinator 함수를 Protocol로 작성합니다 (Coordinator.swift)
  • 전환할 모든 화면을 enum 타입으로 작성합니다 (Destination.swift)
  • TabView가 위치할 RootView를 만듭니다
  • 전환할 View를 만듭니다
  • 재사용할 View를 만듭니다(ReuseableView.swift)

Coordinator/Common/Coordinator.swift

import SwiftUI

protocol CoordinatorProtocol {
    associatedtype T: Hashable

    func push(_ path: T)
    func pop()
    func popToView(_ to: T)
    func popToRoot()
}

final class Coordinator<T: Hashable>: ObservableObject {

    @Published var paths: [T] = []

}

extension Coordinator: CoordinatorProtocol {
    func push(_ path: T) {
        print("PATH: ", paths)
        print("추가할 PATH: ", path)
        paths.append(path)
        print("Result PATH: ", paths)
    }

    func pop() {
        print("PATH: ", paths)
        paths.removeLast()
        print("Result PATH: ", paths.count)
    }

    func popToView(_ to: T) {
        print("PATH: ", paths)
        guard let found = paths.firstIndex(where: { $0 == to }) else {
            return
        }

        let numToPop = (found..<paths.endIndex).count - 1
        paths.removeLast(numToPop)
        print("Result PATH: ", paths)
    }

    func popToRoot() {
        print("PATH: ", paths)
        paths.removeAll()
        print("Result PATH: ", paths)
    }
}

0. RootView (TabView가 위치한 화면)

import SwiftUI

struct RootView: View {
    init() {
        UITabBar.appearance().backgroundColor = UIColor.white
    }
    var body: some View {
        TabView {
            ViewTwo()
                .tabItem {
                    VStack {
                        Image(systemName: "1.square.fill")
                        Text("Views")
                    }
                }

            ProductView()
                .tabItem {
                    VStack {
                        Image(systemName: "2.square.fill")
                        Text("Products")
                    }
                }
        }
        .background(Color.white)
    }
}

1. Tab1 → Views

  • 해당 Flow는 Views로 네이밍을 정했습니다
  • 해당 Flow에서 이동할 화면들을 Destination.swift에 작성했습니다

Coordinator/Views/ViewsDestination.swift

enum ViewsDestination: Hashable {
    case view3
    case view4
    case view5
    case reuseable(type: DestinationType) //재사용할 화면
}

ViewTwo.swift

  • 첫 번째 탭의 Root가 되는 화면입니다
import SwiftUI

struct ViewTwo: View {
    @ObservedObject private var coordinator = Coordinator<ViewsDestination>()

    var body: some View {
        NavigationStack(path: $coordinator.paths) {
            VStack {
                Text("View Two")

                Button {
                    coordinator.push(.view3)
                } label: {
                    Text("To View Three")
                }
                .buttonStyle(.borderedProminent)
            }
            .navigationDestination(for: ViewsDestination.self) { destination in
                switch destination {
                case .view3:
                    ViewThree()
                case .view4:
                    ViewFour()
                case .view5:
                    ViewFive()
                case .reuseable:
                    ReuseableView(type: .views, text: "Views", coordinator: coordinator)
                }
            }
        }
        .environmentObject(coordinator)
    }
}

ViewThree.swift … ViewFive.swift

import SwiftUI

struct ViewThree: View {
    @EnvironmentObject private var coordinator: Coordinator<ViewsDestination>

    var body: some View {
        VStack {
            Text("View Three")

            Button {
                coordinator.push(.view4)
            } label: {
                Text("To View Four")
            }
            .buttonStyle(.borderedProminent)
        }
    }
}
/*
ViewThree와 ViewFour는 동일합니다
*/

struct ViewFive: View {
    @EnvironmentObject private var coordinator: Coordinator<ViewsDestination>
    var body: some View {
        ZStack {
            Color.white.ignoresSafeArea()
            VStack {
                Text("ViewFive")

                Button {
                    coordinator.push(.reuseable(type: .views))
                } label: {
                    Text("Reuseable View로 이동")
                }
                .buttonStyle(.borderedProminent)
            }
        }
    }
}

View/Reuseable/ReuseableView.swift

  • 두 탭에서 공통으로 사용하는 화면이 있다고 가정한 재사용 뷰 입니다
import SwiftUI

struct ReuseableView<T>: View where T: Hashable {
    var type: DestinationType
    var text: String
    var coordinator: Coordinator<T>

    init(type: DestinationType, text: String, coordinator: Coordinator<T>) {
        self.type = type
        self.text = text
        self.coordinator = coordinator
    }

    var body: some View {
        VStack {
            Text(text)

            Button {
                coordinator.popToRoot() //Flow에 맞는 popToRoot 함수를 실행합니다
            } label: {
                Text("PopToRoot")
            }
            .buttonStyle(.borderedProminent)
        }
    }
}

2. Tab2 → Product

  • 해당 Flow는 Product로 네이밍을 정했습니다
  • 해당 Flow에서 이동할 화면들을 ProductDestination.swift에 작성했습니다

Models/Product.swift

struct Product: Hashable {
    let name: String
    let price: Int
}

Coordinator/Product/ProductDestination.swift

enum ProductsDestination: Hashable {
    // MARK: Product
    case detail(data: Product)
    case reuseable(type: DestinationType) //재사용할 뷰
}

View/Product/ProductView.swift

  • Product Flow의 Root가 될 ProductView 입니다
import SwiftUI

struct ProductView: View {
    @ObservedObject private var coordinator = Coordinator<ProductsDestination>()
    @StateObject private var viewModel = ProductViewModel()

    var body: some View {
        NavigationStack(path: $coordinator.paths) {
            ZStack {
                Color.white.ignoresSafeArea()
                VStack {
                    Button {
                        coordinator.push(.reuseable(type: .product))
                    } label: {
                        Text("Reuseable View로 이동")
                    }

                    List {
                        ForEach(viewModel.productData, id: \.self) { data in
                            ProductCell(product: Product(name: data.name, price: data.price))
                                .background(Color.white)
                                .onTapGesture {
                                    coordinator.push(.detail(data: data))
                                }
                        }
                    }
                }
            }
            .navigationDestination(for: ProductsDestination.self) { destination in
                switch destination {
                case .detail(let data):
                    ProductDetail(product: data)
                case .reuseable:
                    ReuseableView(type: .product, text: "Products", coordinator: coordinator)
                }
            }
        }
        .environmentObject(coordinator)
    }
}

View/Product/ProductViewModel.swift

import Foundation

final class ProductViewModel: ObservableObject {
    @Published var productData: [Product] = [
        Product(name: "레고", price: 12000),
        Product(name: "카메라", price: 112000),
        Product(name: "핸드폰", price: 1512000),
        Product(name: "거치대", price: 2000),
        Product(name: "테블릿", price: 1312000),
        Product(name: "노트북", price: 3212000),
        Product(name: "음료수", price: 2000),
        Product(name: "냉장고", price: 5112000),
        Product(name: "책상", price: 612000),
        Product(name: "선반", price: 112000),
        Product(name: "쓰레기봉투", price: 500),
        Product(name: "선풍기", price: 52000),
        Product(name: "텀블러", price: 42000),
        Product(name: "키보드", price: 212000),
        Product(name: "마우스", price: 112000),
    ]
}

View/Product/ProductDetail.swift

  • Product 상세페이지 입니다
import SwiftUI

struct ProductDetail: View {
    @EnvironmentObject private var coordinator: Coordinator<ProductsDestination>
    var product: Product

    var body: some View {
        VStack {
            Text(product.name)
                .font(.largeTitle)

            Text("\(product.price)₩")
        }
    }
}

View/Product/ProductCell.swift

  • Product 셀 입니다
import SwiftUI

struct ProductCell: View {
    var product: Product
    var body: some View {
        VStack(alignment: .leading) {
            Text(product.name)
                .frame(maxWidth: .infinity, alignment: .leading)
            Text("가격: \(product.price) 원")
                .frame(maxWidth: .infinity, alignment: .leading)
        }
        .padding(.all, 4)
    }
}
728x90
반응형

관련글 더보기