Внедрение SwiftUI (далее — SUI) в уже существующее приложение, написанное на UIKit, в середине 2022 г. уже не является вопросом времени, а скорее, определяется наличием соответствующих навыков. Перевод приложения Утконоса – одного из лидеров e-commerce на российском рынке – на SUI мы начали в конце 2020 года, когда подняли минимальную поддерживаемую версию iOS до 13-ой (и, да, мы не стали ждать 14-ой). Этому же способствовала поставленная долгосрочная задача полного редизайна приложения. На текущий момент из пяти главных экранов на SUI у нас реализованы два.

Одна из главных задач, стоящих перед разработчиками — проектирование навигации в приложении. Сейчас уже редко можно встретить одностраничное приложение. Панель вкладок (или таб-бар) позволяет реализовать пользовательский интерфейс, в котором доступ к нескольким экранам не выполняется строго в определенном порядке. Если приложение пишется с нуля на SUI, то типичным сценарием разработки все еще является следующий: экраны верстаются на SUI, а таб-бар на UIKit. С ростом кодовой базы на SUI в Утконосе мы стали постепенно отказываться от навигации на UIKit, большим шагом в этом направлении стало внедрение TabView взамен UITabBarController.

Всем привет! Меня зовут Краев Александр и ниже хочу поделиться опытом перевода UIKit-вого таб-бара на TabView со всеми подводными камнями: когда у вас есть экраны, написанные как на SUI, так и на UIKit. Сразу оговорюсь, что данная статья не рассчитана на тех, кто только начал знакомиться со SUI: внедрение нового фреймворка я советовал бы начать с какого-нибудь небольшого уже существующего экрана или новой продуктовой фичи. Больше всяких фишек и интересных разборов вы сможете найти на моем telegram-канале, посвященном iOS-разработке на SwiftUI.

Часть 1. Подготавливаем инфраструктуру

В нашей команде мы работаем по Trunk Based Development (TBD). Если вы не знакомы с данной моделью ветвления, то советую вам посмотреть это выступление или прочитать эту статью. Если коротко, то разработка новых фичей идет через Feature Flags. 

Заведем флаг для нового таб-бара на SUI:

struct SwiftUI {
    struct TabView {
        static var isActive: Bool = true
    }
}

В той части кода, где создается корневой view controller у главного window, уже можно написать:

var main: UIWindow?
func createMainWindow(windowScene: UIWindowScene) {
    main = UIWindow(windowScene: windowScene)
    let mainTab = FeatureFlags.SwiftUI.TabView.isActive ? 
                        UIHostingController(rootView: RootTabView()) : 
                        setupTabBarController()
    main?.rootViewController = mainTab
}

где setupTabBarController() – это функция создания прежнего таб-бара на UIKit, а RootTabView() – это view нового таб-бара на SUI, проброшенная через UIHostingController

Иерархия в прежнем таб-баре весьма привычная: для каждого экрана создается его navigation controller с корневым view controller-ом:

let profileVC: ProfileViewController = .init()
let profileNav: NavigationController = .init(rootViewController: profileVC)

После инициализируется сам tab bar controller, у которого view controller-ы это созданные на предыдущем шаге navigation controller-ы:

private func setupTabBarController() -> UIViewController {
    ...
    let profileVC: ProfileViewController = .init()
    let profileNav: NavigationController = .init(rootViewController: profileVC)
    ...
    let tabbarController: TabBarController = .init()
    tabbarController.viewControllers = [..., profileNav, ...]
    return tabbarController
}

Здесь стоит пояснить, что NavigationController – это класс, наследуемый от UINavigationController, с кастомным поведением навигационной панели, в том числе ее внешнего вида, кнопки назад, но не более.

Вернемся к новому таб-бару, очевидно, что в RootTabView() будут располагаться view главных экранов. Самое время начать писать SUI-обертки на UIViewControllerRepresentable для UIKit-экранов, приведу пример одной такой для экрана профиля пользователя:

import SwiftUI

struct ProfileSUIView: UIViewControllerRepresentable {
    func makeUIViewController(context: Context) -> NavigationController {
        let profileVC: ProfileViewController = .init()
        let profileNav: NavigationController = .init(rootViewController: profileVC)
        return profileNav
    }

    func updateUIViewController(_ uiViewController: NavigationController, 
                                context: Context) {
    }
}

Как уже сказал ранее, у нас есть два экрана, реализованных целиком на SUI. Чтобы не ломать роутинг на этих экранах ввиду других legacy экранов на UIKit, решено было их также обернуть через UIViewControllerRepresentable в NavigationController:

struct CartSUIView: UIViewControllerRepresentable {
    func makeUIViewController(context: Context) -> NavigationController {

        let cartScreen = CartScreen()
            .environmentObject(...)
        let suiCartVC = UIHostingController(rootView: cartScreen)
        
        let cartNav: NavigationController = .init(rootViewController: suiCartVC)
        return cartNav
    }

    func updateUIViewController(_ uiViewController: NavigationController, 
                                context: Context) {
    }
}

Дизайном нового таб-бара пока не стоит забивать голову, мы к этому еще вернемся, сначала необходимо добиться работоспособности текущих конструкций. RootTabView приведем к максимально простому виду. Объявим enum с экранами:

enum TabType: Int {
    case main
    case catalog
    case search
    case profile
    case cart
}

Далее соберем RootTabView {...}, используя иконки из SF Symbols:

struct RootTabView: View {

    @State private var selectedTab: TabType = .main

    var body: some View {
        TabView(selection: $selectedTab) {
            main.tag(TabType.main)
            catalog.tag(TabType.catalog)
            search.tag(TabType.search)
            profile.tag(TabType.profile)
            cart.tag(TabType.cart)
        }
    }

    private var main: some View {
        MainSUIView()
            .tabItem {
                Label("Catalog", systemImage: "house")
            }
    }
		...
    private var profile: some View {
        ProfileSUIView()
            .tabItem {
                Label("Profile", systemImage: "person")
            }
    }

    private var cart: some View {
        CartSUIView()
            .tabItem {
                Label("Cart", systemImage: "cart")
            }
    }
}

Запускаем проект, видим, что переключение между табами работает:

Вместе с тем, сломалась навигация на экранах SUI: любой дочерний экран открывается модально, появилась белая полоса в области safe area. Разберемся по порядку.

Если коротко и просто, то роутинг, доставшийся нам в наследство как легаси, представляет из себя enum из списка экранов для навигации и фабрику, где этот enum разруливается:

enum Route {
    case trackOrder
    case qrAccessCode
    case safari(String)
    ...
}
...
func route(to direction: Route, 
           from viewController: UIViewController? = nil, 
           previousScreen: AmplitudeScreenNames? = nil) {
    let viewController = previousScreen == .bottomSheet ? 
  					UIApplication.topViewController() : viewController

    switch direction {
    case .trackOrder(let id):
        self.trackOrder(id: id, from: viewController)
    case .qrAccessCode:
        self.showQRAccessCode(from: viewController)
    case .safari(let url):
        routeToSafari(url: url)
    ....
}

Если явно не указан view controller – экран, с которого переходишь, то по умолчанию берем top view controller (код показывать не буду, он легко гуглится). Как раз в этом и причина модального открытия любого окна. Top view controller в нашей схеме это уже не UINavigationController или UITabBarController, а hosting view controller:

po topViewController
▿ Optional<UIViewController>
  ▿ some : <_TtGC7SwiftUI19UIHostingControllerV7Utkonos11RootTabView_: 0x139f2f640>

Раньше до navigation controllera-а можно было добраться следующим образом:

po (topViewController as? UITabBarController)?.selectedViewController
▿ Optional<UIViewController>
  ▿ some : <Utkonos.NavigationController: 0x12f882600>

Таким образом, в SUI-экраны теперь явно надо передавать ссылку на navigation controller, чтобы ее использовать при роутинге. Одним из способов это сделать является создание EnvironmentKey

struct NavigationControllerKey: EnvironmentKey {
    static let defaultValue: UINavigationController? = nil
}

extension EnvironmentValues {
    var navigationController: NavigationControllerKey.Value {
        get {
            return self[NavigationControllerKey.self]
        }
        set {
            self[NavigationControllerKey.self] = newValue
        }
    }
}

Далее объявим @Environment переменную в SUI-экране:

struct CartScreen: View {
    ...
    @Environment(\.navigationController) var navigationController
    ...
  
    var body: some View {
    ...
    }
}

Заинжектим navigation controller непосредственно в момент создания hosting view controller-а экрана, таким образом код CartSUIView преобразуется к виду:

struct CartSUIView: UIViewControllerRepresentable {
    func makeUIViewController(context: Context) -> NavigationController {
        let cartNav: NavigationController
        
        let emptyView: UIViewController = UIHostingController(rootView: EmptyView())
        cartNav = NavigationController.init(rootViewController: emptyView)
        
        let cartScreen = CartScreen()
            .environment(\.navigationController, cartNav)
            .environmentObject(...)
        let suiCartVC = UIHostingController(rootView: cartScreen)
        
        cartNav.addChild(suiCartVC) // child here is a root
        cartNav.setNavigationBarHidden(true, animated: false)
        
        return cartNav
    }

    func updateUIViewController(_ uiViewController: NavigationController, 
                                context: Context) {
    }
}

Здесь стоит пояснить, что для инжектирования .environment(.navigationController, cartNav) необходим экземпляр объекта navigation controller-а cartNav, создадим его используя  проксирующий UIHostingController c пустым EmptyView. Далее мы добавляем как child основной экран: cartNav.addChild(suiCartVC), но при этом надо «заглушить» navigation bar от пустого view: cartNav.setNavigationBarHidden(true, animated: false).

Кроме этого, необходимо принудительно скрыть кнопку назад (на пустое view) на экране:

Сделать это весьма просто, применив следующий модификатор:

struct CartScreen: View {
    
    ...
    var body: some View {
        content
            .navigationBarBackButtonHidden(true)
    }
    ...
}

Далее прокинем зависимость во все дочерние View экрана: 

@Environment(\.navigationController) var navigationController

Здесь стоит отметить, что если одно и то же view используется на экранах с разными экземплярами navigation controller-а (например, у нас переход на товар может быть как с главного экрана, так и с экрана корзины), то благодаря дереву зависимостей SwiftUI @Enviroment значение у view будет браться именно родительского view, то есть ошибки не будет. 

Пример роутинга во view:

Button {
		Router.injected.routeToGoodsItem(goodsItemID: goods.id, 
                                 from: navigationController)
} label: { ... }

Теперь вернемся к белой полосе в области safe area, исправляется это весьма легко. Определим следующий модификатор:

public extension View {
    @ViewBuilder
    func expandViewOutOfSafeArea(_ edges: Edge.Set = .all) -> some View {
        if #available(iOS 14, *) {
            self.ignoresSafeArea(edges: edges)
        } else {
            self.edgesIgnoringSafeArea(edges) // deprecated
        }
    }
}

Применим его к контенту tabItem-ов:

private var main: some View {
    MainSUIView()
        .expandViewOutOfSafeArea()
        .tabItem {
            Label("Catalog", systemImage: "house")
        }
}

Запускаем приложение, видим, что проблемы ушли:

Часть 2. Верстаем таб-бар

Теперь можно приступить к верстке таб-бара. Модификатор tabItem(_:), доступный из коробки, имеет весьма ограниченный функционал в верстке, поэтому если чего-то не хватает, надо кастомизировать. К счастью, SUI позволяет это сделать весьма легко: 

struct RootTabView: View {

    @State private var selectedTab: TabType = .main

    var body: some View {
        
        ZStack(alignment: Alignment.bottom) {
            TabView(selection: $selectedTab) {
                main.tag(TabType.main)
                catalog.tag(TabType.catalog)
                search.tag(TabType.search)
                profile.tag(TabType.profile)
                cart.tag(TabType.cart)
            }
            
            HStack(spacing: 0) {
                /*
                 Здесь будем верстать кнопки
                 таб-бара
                 */
            }
        }
    }
}

Как выглядят кнопки тап-бара в различных состояниях:

кнопка не активна
кнопка не активна
кнопка активна
кнопка активна
активная кнопка с бэйджем
активная кнопка с бэйджем

Так как дизайн весьма простой, просто приведу код:

struct TabBarItem: View {
    
    @Environment(\.colorScheme) var colorScheme
    
    let icon: Image
    let title: String
    let badgeCount: Int
    let isSelected: Bool
    let itemWidth: CGFloat
    let onTap: () -> ()
    
    var body: some View {
        Button {
            onTap()
        } label: {
            VStack(alignment: .center, spacing: 2.0) {
                ZStack(alignment: .bottomLeading) {
                    Circle()
                        .foregroundColor(colorScheme == .dark ? ... )
                        .frame(width: 20.0, height: 20.0)
                        .opacity(isSelected ? 1.0 : 0.0)
                    ZStack {
                        icon
                            .resizable()
                            .renderingMode(.template)
                            .frame(width: 28.0, height: 28.0)
                            .foregroundColor(isSelected ? (colorScheme == .dark ? ...) : ...)
                        Text("\(badgeCount > 99 ? "99+" : "\(badgeCount)")")
                            .kerning(0.3)
                            .lineLimit(1)
                            .truncationMode(.tail)
                            .foregroundColor(Color.white)
                            .boldFont(11)
                            .padding(.horizontal, 4)
                            .background(Color.Button.primary)
                            .cornerRadius(50)
                            .opacity(badgeCount == 0 ? 0.0 : 1.0)
                            .offset(x: 16.0, y: -8.0)
                    }
                }
                Text(title)
                    .boldFont(12.0)
                    .foregroundColor(isSelected ? (colorScheme == .dark ? ...) : ... )
            }
            .frame(width: itemWidth)
        }
        .buttonStyle(.plain)
    }
}

Комментировать особо нечего, кроме того, что boldFont – кастомный модификатор для шрифта и что сознательно в свойства не вынесены цвета для модификаторов foregroundColor, background, так как других таких же кнопок, но с другими цветами, в приложении не будет, в ином случае, конечно, я рекомендовал бы это делать. Дополню, что в нашем проекте элементы дизайн-системы, к которым безусловно относится и кнопка таб-бара, вынесены в отдельный package. Данный подход советую применить и у вас.

Посмотрим, как изменится RootTabView:

struct RootTabView: View {
    @Environment(\.colorScheme) var colorScheme
    @State private var cartCount: Int = 0
    @State private var cartTitle: String = "Shopping cart".localized
    
    @State private var selectedTab: TabType = .main

    var body: some View {
        GeometryReader { geometry in
            ZStack(alignment: Alignment.bottom) {
                TabView(selection: $selectedTab) {
                    main.tag(TabType.main)
                    catalog.tag(TabType.catalog)
                    search.tag(TabType.search)
                    profile.tag(TabType.profile)
                    cart.tag(TabType.cart)
                }

                HStack(spacing: 0) {
                    TabBarItem(icon: Image.TabBar.home,
                               title: "Utkonos".localized,
                               badgeCount: 0,
                               isSelected: selectedTab ==  .main,
                               itemWidth: geometry.size.width / 5) {
                        selectedTab = .main
                    }
										...
                    TabBarItem(icon: Image.TabBar.cart,
                               title: cartTitle,
                               badgeCount: cartCount,
                               isSelected: selectedTab == .cart,
                               itemWidth: geometry.size.width / 5) {
                        selectedTab = .cart
                    }
                }
            }
        }
        .onCartChanged { count, price in
            ...
            cartTitle = price == 0 ? "Shopping cart".localized : price.stringCurrency
	    			cartCount = count
            ...
        }
    }

    private var main: some View {
        MainSUIView()
            .expandViewOutOfSafeArea()
    }
  	...
}

Дополнительно скажу, что к этому view применен модификатор onCartChanged, который отлавливает события изменения корзины, реализация крайне проста: все строится вокруг отслеживания onReceive нужного события в NotificationCenter. В этом модификаторе и происходит изменение заголовка у кнопки с экраном корзины и бэйджа.

Запускаем проект:

Видим, что кнопки отрисованы правильно, изменение бэйджа и тайтла работает. Баг с поднятием кнопок таб-бара вместе с клавиатурой исправляем модификатором: ignoresSafeArea(.keyboard):

struct RootTabView: View {
    ...
    var body: some View {
        GeometryReader { geometry in
            ZStack(alignment: Alignment.bottom) {
                TabView(selection: $selectedTab) {
                	...
                }

                HStack(spacing: 0) {
		   						...
                }
            }
        }.ignoresSafeArea(.keyboard)
    }
}

Часть 3. Добавляем анимацию

Редко, когда дизайнер ограничивает себя только отрисовкой макетов кнопок, забыв об анимации. И действительно, ведь удачная анимация делает приложение удобным и привлекает внимание, но не отвлекает пользователя. Одна из задач анимации — повысить отзывчивость приложения.

Цель одной из предложенных к реализации анимаций в нашем таб-баре — дать визуальный фидбэк пользователю о том, что изменился состав корзины при добавлении новых товаров. 

Выглядит весьма стильно, давайте реализуем. Для начала нам необходим массив координат – смещений иконки и текущий индекс смещения в этом массиве:

struct RootTabView: View {
    ...
    private let offsets: [CGPoint] = [.init(x: 0, y: 0),
                                      .init(x: 0, y: 4),
                                      .init(x: 0, y: 0)]
    @State private var currentOffsetIndex: Int = 0

    var body: some View {
        ...
    }
}

В описанном выше модификаторе onCartChanged, отслеживающем изменение состояния корзины будем изменять currentOffsetIndex в цикле по всему массиву offsets:

struct RootTabView: View {
    ...
    private let offsets: [CGPoint] = [.init(x: 0, y: 0),
                                      .init(x: 0, y: 4),
                                      .init(x: 0, y: 0)]
    @State private var currentOffsetIndex: Int = 0

    var body: some View {
        content
        ...
            .onCartChanged { count, price in
                ...
                withAnimation {
                    for index in 1..<offsets.count {
                        Task.delayed(byTimeInterval: Double(index)/10) {
                            await MainActor.run {
                                currentOffsetIndex = index
                                
                                if index == 1 {
                                    cartCount = count
                                    cartTitle = price == 0 ? "Shopping cart".localized : price.stringCurrency
                                }
                            }
                        }
                    }
                }
                ...
            }
        ...
    }
    ...
}

Поясню, Task.delayed(byTimeInterval: ..)— это по сути то же, что и asyncAfter(deadline:execute:) только в New Concurrency Model.

public extension Task where Failure == Error {
    @discardableResult
    public static func delayed(
        byTimeInterval delayInterval: TimeInterval,
        priority: TaskPriority? = nil,
        operation: @escaping @Sendable () async throws -> Success
    ) -> Task {
        Task(priority: priority) {
            let delay = UInt64(delayInterval * 1_000_000_000)
            try await Task<Never, Never>.sleep(nanoseconds: delay)
            return try await operation()
        }
    }
}

Внутри неизолированного контекста Task.delayed {…} мы оборачиваем в await MainActor.run {…}, потому что получить доступ к @State свойствам можно только изнутри актора.

Теперь приступим к самому интересному – модификатору .offset в сочетании с spring-анимацией.

.offset(x: offsets[currentOffsetIndex].x, 
        y: offsets[currentOffsetIndex].y)
    .animation(.spring(response: 0.15, 
                       dampingFraction: 0.75, 
                       blendDuration: 0), 
               value: currentOffsetIndex)

Где его применить? Очевидно, что смещаться должна сама иконка с бэйджем, то есть в TabBarItem:

public struct TabBarItem: View {
    
    ...
    
    public var body: some View {
        Button {
            ...
        } label: {
            VStack(...) {
                ZStack(...) {
                    ...
                    ZStack {
                        icon
                            ...
                        Text(...)
                            ...
                    }
                    .offset(x: offsets[currentOffsetIndex].x,
                            y: offsets[currentOffsetIndex].y)
                    .animation(.spring(response: 0.15,
                                       dampingFraction: 0.75,
                                       blendDuration: 0),
                               value: currentOffsetIndex)
                }
                ...
            }
            ...
        }
        ...
    }
}

Но здесь есть нюанс, а что если потом дизайнер предложит добавить еще одну анимацию, уже не связанную со смещением. Давайте вынесем модификатор для анимации в параметр TabBarItem, обернем в дженерик:

public struct TabBarItem<VModifier>: View where VModifier: ViewModifier {
    
    ...
    let animation: VModifier
    ...
    
    public init(...,
         animation: VModifier,
            ...) {
      	...
        self.animation = animation
      	...
    }
    
    public var body: some View {
        Button {
            onTap()
        } label: {
            VStack(alignment: .center, spacing: 2.0) {
                ZStack(alignment: .bottomLeading) {
                    ...
                    ZStack {
                        icon
                            .resizable()
                            ...
                        Text("\(badgeCount > 99 ? "99+" : "\(badgeCount)")")
                            ...
                    }
                    .modifier(animation)
                }
                ...
            }
            ...
        }
        ...
    }
}

Чтобы не пришлось ничего менять в коде тех tab item-ов, у которых не будет анимации, напишем extension:

public extension TabBarItem where VModifier == EmptyModifier {
    public init(icon: Image,
         title: String,
         badgeCount: Int,
         isSelected: Bool,
         itemWidth: CGFloat,
         onTap: @escaping () -> ()) {
        self.icon = icon
        self.title = title
        self.badgeCount = badgeCount
        self.isSelected = isSelected
        self.itemWidth = itemWidth
        self.onTap = onTap
        self.animation = EmptyModifier()
    }
}

Теперь нам нужен модификатор, реагирующий на анимацию извне, чтобы передать его как параметр в  TabBarItem. Раньше для таких целей был протокол AnimatableModifier, который Apple, недавно выпустив, немногим после назвала его устаревшим, взамен предложив использовать Animatable:

public struct OffsetAnimation<V>: Animatable, ViewModifier where V: Equatable {
    
    private var offset: CGPoint
    private var value: V
    
    public init(offset: CGPoint,
                value: V) {
        self.offset = offset
        self.value = value
    }
    
    public var animatableData: CGPoint {
        get { offset }
        set { offset = newValue }
    }
    
    public func body(content: Content) -> some View {
        content
            .offset(x: offset.x, y: offset.y)
            .animation(.spring(response: 0.15, 
                               dampingFraction: 0.75, 
                               blendDuration: 0), 
                       value: value)
    }
 
}

Стоит пояснить, что animatableData – это данные для анимации, в нашем случае как раз точка смещения.

Важный нюанс, Apple назвала устаревшим модификатор .animation(_:), который подарил разработчикам много багов с анимацией, взамен предложив использовать animation(_:value:). Основный смысл последнего в том, чтобы проигрывать анимацию тогда, когда меняется конкретный value. Поэтому наш OffsetAnimation и является дженериком, чтобы передавать этот value, как параметр.

Таким образом RootTabView с анимированной кнопкой выглядит так:

struct RootTabView: View {
    ...
    private let offsets: [CGPoint] = [.init(x: 0, y: 0),
                                      .init(x: 0, y: 4),
                                      .init(x: 0, y: 0)]
    @State private var currentOffsetIndex: Int = 0

    var body: some View {
        GeometryReader { geometry in
            ZStack(alignment: Alignment.bottom) {
                TabView(selection: $selectedTab) {
                    ...
                    cart.tag(TabType.cart)
                }
                
                HStack(spacing: 0) {
                    ...
                    TabBarItem(icon: Image.TabBar.cart,
                               title: cartTitle,
                               badgeCount: cartCount,
                               isSelected: selectedTab == .cart,
                               itemWidth: geometry.size.width / 5,
                               animation: OffsetAnimation(offset: offsets[currentOffsetIndex],
                                                          value: currentOffsetIndex)) {
                        selectedTab = .cart
                    }
                }
            }
        }
        ...
            .onCartChanged { count, price in
                ...
                withAnimation {
                    for index in 1..<offsets.count {
                        Task.delayed(byTimeInterval: Double(index)/10) {
                            await MainActor.run {
                                currentOffsetIndex = index
                                
                                if index == 1 {
                                    cartCount = count
                                    cartTitle = price == 0 ? "Shopping cart".localized : price.stringCurrency
                                }
                            }
                        }
                    }
                }
                ...
            }
        ...
    }
    ...
}

Запустим проект, видим, что задумка дизайнера осуществлена:

Часть 4. Заключение

Давайте теперь вынесем из RootTabView @State свойство selectedTab - выбранного экрана на таб баре:

struct RootTabView: View {
    ...
    @State private var selectedTab : TabType = .main
    ...
}

У нас в проекте мы придерживаемся архитектуры MVVM-S, где за роутинг отвечает соответствующий сервис, перенесем selectedTab в него:

final class Router : ObservableObject {
    ...
    @Published public var selectedTab: TabType = .main
    ...
    
    func openTabCart() {
        selectedTab = .cart
    }
    ...
}

RootTabView преобразуется к виду:

struct RootTabView: View {
    ...
    @ObservedObject private var router: Router
    ...
    
    var body: some View {
        TabView(selection: $router.selectedTab) {
            ...
            cart.tag(TabType.cart)
        }
        
        ...
        TabBarItem(...) {
            router.openTabCart()
        }
        ...
    }
}

На этом у меня все, спасибо, что дочитали до конца!

Подписывайтесь на мой Telegram-канал, посвященный iOS-разработке на SwiftUI.

Комментарии (0)