Внедрение 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.