Data flow у SwiftUI. Або чому не все так просто, як здається
Усі статті, обговорення, новини про Mobile — в одному місці. Підписуйтеся на телеграм-канал!
Привіт, мене звати Влад, я iOS Developer. Уже близько чотирьох років я займаюсь мобільною розробкою та хочу поділитись доволі важливими та корисними, на мою думку, знаннями у роботі з SwiftUI.
Зараз тема переходу у SwiftUI дуже актуальна для iOS-розробників. Компанія Apple на своїй щорічній конференції WWDC представила багато нових інструментів для SwiftUI, які надають розробникам ще більше простору для втілення нових ідей.
Крім цього, навіть у своєму оновленні Figma додала SwiftUI inspector, який дозволить легко переносити дизайн у код, а це зайвий раз підтверджує його актуальність у наші дні. Тож, розбираймось, як саме працює data flow у SwiftUI.
Вступ
SwiftUI — відносно новий UI-фреймоворк, який компанія Apple представила для своїх програмних платформ на WWDC 2019. Він заснований на принципах декларативного програмування, що означає написання інтерфейсу у вигляді коду.
У SwiftUI майже все фокусується на поточному стані (state) певних даних або обʼєктів, тому забудьте про інші архітектури, окрім MVVM, або ж мішайте всю логіку в одному файлі, як рекомендують деякі знавці (той же підхід MV — привіт MVC , але ще в занедбанішому вигляді, на мою думку).
І тому, щоб не було суцільної мішанини в голові і в проєкті, я вирішив написати статтю, де постараюся пояснити data flow та data managment у SwiftUI.
Розбираємося з інструментами, які вам надаються із SwiftUI
Керування, або ж менеджмент, стану та потоку даних (data and flow management) є важливим процесом при написанні будь-якого застосунку. Якщо ж ви відкриєте офіційну документацію чи схеми, які пропонує сам Apple, то у голові може виникнути дві думки: або все дуже просто і ви все зразу зрозуміли, або ви нічого не зрозуміли і йдете далі гуглити, як з цим справитися.
Вище наведений приклад схеми, яку подає сама компанія Apple. Особисто мені, ця схема мало що говорить, адже описує доволі загальні і здається всім зрозумілі речі. Вона аж ніяк не торкається жодних модифікаторів чи обгорток для змінних та обʼєктів, чи будь-яких конкретних механізмів, які нам надає SwiftUI.
Якщо добре придивитися і стерти надпис «SwiftUI», то таку ж саму діаграму можна застосувати й до звичайного застосунку, який записаний за допомогою UIKit, або ж взагалі будь-якою іншою мовою та для зовсім іншої платформи.
Схема, наведена вище, була запропонована Крісом Ейдзофом (Chris Eidhof) і це та схема, яку ви побачите у більшості статей на цю тему. Вона доволі проста, адже дасть вам одразу ж зрозуміти, що слід було б вибрати у певних ситуаціях.
Крім цього, схема дає вам конкретику по тому, що може запропонувати SwiftUI — на відміну від тієї, яку подає Apple. Але рано чи пізно цієї схеми вам не буде достатньо, адже вона створить більше питань ніж відповідей.
Коли ви проєктуєте чи пишете застосунок, ви не можете себе обмежити лише вибором між тим, чи керуватимете Value Type або Object. Вам ще треба якось інкапсулювати ваші дані, щоб знову не виник Masive View Controller, але в іншому форматі та з новими засобами.
Для початківців та загального розуміння ця схема достатньо хороша, але переважно застосунки містять багато даних, які можуть чи перевикористовуватися, чи від їх зміни даних потрібно буде оновлювати вигляд екрана чи навіть декількох та багато інших прикладів. Тому, на мою думку, тут потрібно більше конкретики.
А ось ця схема, яку запропонував Маттео Манфердіні (Matteo Manferdini), на мою думку, є найвичерпнішою з усіх, які я бачив. Він пропонує оперувати не поняттями, хто і що оновлює певний обʼєкт і значення та відмінністю між останніми двома, а поняттям SSOT (Single source of truth).
SSOT, або ж українською «єдиний ресурс даних» — це практика структурування інформаційних моделей і повʼязаних даних таким чином, щоб до кожного елемента даних мався доступ на читання або редагування лише з одного місця.
Тобто кожна частинка інформації, яку застосунок потребує, повинна зберігатися на своєму конкретному місці. Слова «лише з одного місця» чи «single source» не повинні вас наштовхувати на те, що вся інформація, яку потребує ваш застосунок, повинна зберігатися в одному місці і лише з централізованим доступом. Ідея єдиного ресурсу даних застосовується до кожної унікальної частинки даних.
Саме це показано на схемі, яку запропонував Маттео — перш за все вам потрібно визначити, де і якими даними ви оперуєте, і тоді стане зрозуміло, що саме вам потрібно використати.
Окрім цього, оскільки ми уже ввели поняття SSOT, то усі обгортки властивостей, які нам надає SwiftUI, можна розділити на дві категорії. В першій категорії — ті, які відповідають за єдиний ресурс даних, а в другій категорії — ті, які не відповідають, тобто значення таких властивостей беруться з інших місць.
А тепер розберемося, що ці всі незрозумілі на перших погляд слова чи словосполучення означають.
Основні property wrapers/обгортки властивостей
@State
@State змінні знаходяться у власності View. SwiftUI забезпечує оновлення View кожного разу, як тільки змінюється значення з цією приставкою. Оскільки ці змінні стосуються лише View і ніяк за його область не виходять — їх слід позначати private, як і радить Apple.
@State змінні працюють шляхом повторного обчислення змінної body, в якій і міститься по суті весь інтерфейс вашого View, як тільки відбувається оновлення цих змін. Тому вони і зберігаються в памʼяті протягом усього життєвого циклу View.
struct PersonalInformationView: View { // MARK: - States @State private var firstName = "" @State private var lastName = "" @State private var isTermsAccepted = false // MARK: - Body var body: some View { VStack { // first name textfield TextField("First name", text: $firstName) .disabled(!isTermsAccepted) // last name text field TextField("Last name", text: $lastName) .disabled(!isTermsAccepted) // toggle for terms & conditions Toggle(isOn: $isTermsAccepted) { Text("Agree to Terms & Conditions") } } } }
@StateObject
Загалом @StateObject використовується для декларування типу, який зберігається за посиланням у View і який не буде залежати від його циклу життя та навіть інших, з якими ви його можете ділити.
Використання @StateObject гарантує, що посилання на обʼєкт буде належати View в якому воно оголошене, на відміну від @ObsevedObject, про який ми поговоримо пізніше.
Якщо архітектура MVVM лежить в основі вашого застосунку, то обгортка @StateObject для ваших ViewModel — це саме те, що вам потрібно. Якщо ви хочете, щоб ваш обʼєкт можна було використати як @StateObject, слід переконатися, що цей тип відповідає протоколу ObsevableObject.
Крім цього, змінні у об’єкті, які відповідають за те, що потрібно відобразити на View, повинні мати обгортку @Published, для того, щоб View реагував на зміну даних у моделі даних.
final class FollowersListViewModel: ObservableObject { // MARK: - Properties(Published) @Published var followersList: [FollowerModel] = [] @Published var followersAmount = 0 // MARK: - Init init() { getFollowersList() } // MARK: - Methods(Private) private func getFollowersList() { // getting followers list } } struct FollowersListView: View { // MARK: - StateObjects @StateObject private var viewModel = FollowersListViewModel() // MARK: - Body var body: some View { VStack { // followers amount Text("Followers: \(viewModel.followersAmount)") // followers list ScrollView { LazyVStack(spacing: 10) { ForEach(viewModel.followersList, id: \.self) { user in FollowerView(avatarURL: user.avatarURL, username: user.username) } } } } } }
@Binding
@Binding дозволяє оголосити значення, яке фактично походить з іншого місця, але має бути спільним в обох місцях. View, яке отримує @Binding змінну може читати це значення, реагувати на його зміни у батьківському View, і навіть має дозвіл на редагування такої змінної, тобто утворює двосторонній зв’язок між батьківською та дочірньою змінною.
Також слід зауважити, що цей тип обгортки використовується тільки для типів, які зберігаються за значенням.
struct ButtonView: View { // MARK: - Bindings @Binding var isEnabled: Bool // MARK: - Body var body: some View { VStack { Text("Next") .foregroundColor(isEnabled ? .blue : .gray) // other configurations } } } struct SignInView: View { // MARK: - States @State private var username = "" @State private var password = "" @State private var isTermsAccepted = false // MARK: - Body var body: some View { VStack(alignment: .leading) { // first name textfield TextField("Username", text: $username) .disabled(!isTermsAccepted) // last name text field SecureField("Psword", text: $password) .disabled(!isTermsAccepted) // toggle for Toggle(isOn: $isTermsAccepted) { Text("Agree to Terms & Conditions") } // next button ButtonView(isEnabled: $isTermsAccepted) } } }
@ObservedObject
@ObservedObject дозволяє View отримувати обʼєкт за допомогою dependency injectoin від одного з його предків. Ця обгортка даних підійде для випадків, коли у вас є ObservableObject, який уже оголошений як @StateObject і вам потрібно поділитися ним.
Ми можемо ділитися цим обʼєктом з різними незалежними View, які можуть підписатися та спостерігати за змінами в цьому обʼєкті, і як тільки зʼявляться якісь зміни — SwiftUI перебудує всі View, які повʼязані з цим обʼєктом.
final class UserInfoObservableObject: ObservableObject { // MARK: - Properties(Public) @Published var totalCoinsAmount = 0 } struct CoinsView: View { // MARK: - ObservedObject @ObservedObject var userInfoObject: UserInfoObservableObject // MARK: - Body var body: some View { VStack(spacing: 10) { // desciption text Text("You can increase your score\nby tapping this button") .multilineTextAlignment(.center) // button to increase score Button("Add coins") { userInfoObject.totalCoinsAmount += 1 } } } } struct ContainerView: View { // MARK: - StateObjects @StateObject var userInfoObject = UserInfoObservableObject() // MARK: - Body var body: some View { VStack { // score text Text("\(userInfoObject.totalCoinsAmount)") // spacer Spacer() // coins view CoinsView(userInfoObject: userInfoObject) } } }
@EnvironmentObject
Обгортка @EnvironmentObject доволі подібна за своєю суттю до @StateObject та @ObservedObject, але відмінна від них у специфіці використання. Вона потрібна для тих випадків, коли необхідно слідкувати за змінами ObservableObject, але View, які такий обʼєкт використовують, не є напряму залежні за принципом батько-нащадки.
Тобто вам не потрібно створювати обʼєкт у View A, потім передавати у View B, потім C і т.д., щоб нарешті ним скористатися у View F — а просто створити такий обʼєкт у View A, а усі інші View будуть автоматично мати доступ до нього.
Щоб скористатися EnvironmentObject вам необхідно мати обʼєкт, який наслідує протокол ObservableObject, і який був попередньо створений у View, і для подальшого використання у інших View скористатися модифікатором environmentObject(), на відміну від @ObservedObject, де ObservableObject передавався б напряму.
final class UserInfoObservableObject: ObservableObject { // MARK: - Properties(Published) @Published var firstName = "John" @Published var lastName = "Doe" @Published var coinsAmount = 0 } struct DetailsView: View { // MARK: - EnvironmentObjects @EnvironmentObject var userInfoObject: UserInfoObservableObject // MARK: - Body var body: some View { VStack { Text("\(userInfoObject.firstName) \(userInfoObject.lastName)") Text("Coins amount: \(userInfoObject.coinsAmount)") } } } struct SettingsViewView: View { // MARK: - StateObjects @StateObject var userInfoObject = UserInfoObservableObject() // MARK: - Body var body: some View { // navigation stack NavigationStack { // container view VStack(spacing: 10) { // button to increase coins amount Button("Increase coins amount") { userInfoObject.coinsAmount += 1 } // navigation link NavigationLink { DetailsView() } label: { Text("Show Details") } } .frame(height: 200) } .environmentObject(userInfoObject) } }
@Environment
Тут краще почати з пояснення відмінності між @Environment та @EnvironmentObject, хоча, знову ж таки, принцип роботи майже однаковий: @EnvironmentObject дозволяє вводити довільні значення в середовище застосунку, а от Environment спеціально створений для роботи з даними SwifUI, доступ до яких здійснюється за попередньо визначеними ключами.
@Environemnt дає доступ до таких значень як користувацькі налаштування (колірна схема, орієнтація пристрою і т.д.) або властивостей, таких як EditMode або PresentationMode.
struct ContentView: View { // MARK: - Environment @Environment(\.sizeCategory) var sizeCategory // MARK: - Body var body: some View { VStack { if sizeCategory == .accessibilityExtraExtraExtraLarge { VStack { // extra large buttons } } else { HStack { // default buttons } } } } }
Додаткові property wrapers/обгортки властивостей
На цьому статтю можна було б і закінчити, але я вирішив дати також додаткові обгортки властивостей, які надає SwiftUI. Вони доволі специфічні, але моя практика показала, що більшість з них вартують вашої уваги.
@FocusState
Обгортка @FocusState відносно нова та доступна з iOS 15, проте, на мою думку, надзвичайно важлива. Вона дозволяє відслідковувати, яке з поточних view зараз отримує вхідні дані від користувача.
Крім цього, ви самі можете вирішувати, чи потрібно щоб, наприклад, ваш TextField був активний в цей момент, або ні.
struct SignInView: View { // MARK: - Internal types private enum FocusedField { case username case password } // MARK: - States @State private var username = "" @State private var password = "" // MARK: - FocusStates @FocusState private var focusedField: FocusedField? // MARK: - Body var body: some View { VStack { // username textfield TextField("Username", text: $username) .focused($focusedField, equals: .username) // password textfield SecureField("Password", text: $password) .focused($focusedField, equals: .password) } .onSubmit { if focusedField == .username { focusedField = .password } else { focusedField = nil } } } }
@FocusedBinding та @FocusedValue
Ці дві обгортки властивостей використовуються для доступу до стану поточного активного view. Вони зазвичай використовуються в програмах macOS для зв’язку пунктів меню з даними у поточному вікні.
Вони також доступні в iOS і можуть використовуватися в програмах для iPadOS із кількома вікнами.
struct BookCommands: Commands { // MARK: - FocusedBinding @FocusedBinding(\.selectedBook) private var selectedBook: Book? // MARK: - Body var body: some Commands { CommandMenu("Book") { Section { // update progress button Button("Update Progress...", action: updateProgress) .keyboardShortcut("u") // mark complete button Button("Mark Completed", action: markCompleted) .keyboardShortcut("C") } .disabled(selectedBook == nil) } } // MARK: - Methods(Private) private func updateProgress() { selectedBook?.updateProgress() } private func markCompleted() { selectedBook?.markCompleted() } }
@AppStorage
SwiftUI отримав окрему обгортку для читання даних з UserDefaults, яка автоматично змінить вигляд View, як тільки дані у UserDefaults оновляться.
Окрім того, якщо ви оголосили змінну як @AppStorage, надавши ключ та store (опційно), ви можете не тільки читати, а й змінювати ці дані.
struct SignInView: View { // MARK: - Internal types private enum FocusedField { case username case password } // MARK: - States @State private var username = "" @State private var password = "" // MARK: - AppStorage @AppStorage("firstName") var firstName: String = "Stranger" // MARK: - Body var body: some View { VStack { // welcoming description Text("Hi, \(firstName)") // username textfield TextField("Username", text: $username) // password textfield SecureField("Password", text: $password) // sign in button Button("Sign in", action: handleSignIn) } } // MARK: - Methods(Private) private func handleSignIn() { // getting info firstName = "John" } }
@SceneStorage
SceneStorage дозволяє View отримати доступ до постійного сховища для кожної сцени вашого застосунку.
Цим можна скористатися для відновлення стану інтерфейсу користувача, щоб ваш застосунок зміг перезапуститися з того ж місця, на якому користувач залишив його останній раз.
struct ContentView: View { // MARK: - SceneStorage @SceneStorage("text") var text = "" // MARK: - Body var body: some View { NavigationStack { TextEditor(text: $text) } } }
@UIApplicationDelegateAdaptor та @NSApplicationDelegateAdaptor
Якщо дуже коротко — то це модифікатор, який дозволяє вам створити начебто свій AppDelegate, який буде містити властиві йому функції, але який не буде вхідною точкою в застосунок.
Тобто, ви можете створити клас, який наслідує NSObject та UIApplicationDelegate, головною точкою входу досі залишається ваша структура, яка наслідується від App, і в цій же структурі ви маєте змінну типу AppDelegate, за змінами якої можете слідкувати.
В цьому випадку ви збережете святу чистоту вашого SwiftUI підходу.
final class AppDelegate: NSObject, UIApplicationDelegate { func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil ) -> Bool { // code here return true } } @main struct Example_App: App { // MARK: - UIApplicationDelegateAdaptor @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate // MARK: - Body var body: some Scene { WindowGroup { MainContentView() } } }
@FetchRequest
FetchRequest — це спеціальна обгортка властивостей для роботи із запитами на отримання даних з CoreData. З її допомогою можна отримувати дані напряму у View, без написання додаткової логіки.
Вам необхідно надати принаймні одне значення, яке є масивом дескрипторів сортування для впорядкування ваших даних. Ви також можете надати предикат для фільтрування даних.
Проте перед використанням цього вам слід спершу ввести контекст керованого обʼєкта CoreData у ваше середовище, тому спершу добре почитайте інші статті про те, як цим правильно користуватися.
@FetchRequest( sortDescriptors: [ SortDescriptor(\.name, order: .reverse) ] ) var users: FetchedResults<User>
@GestureState
Якщо вам необхідно відслідковувати стан жестів, вам допоможе @GestureState. Слідкувати за жестами ви можете і за допомогою звичайної обгортки @State, проте @GestureState має додаткову можливість: автоматично повертати ваш елемент в початковий стан після завершення жесту.
struct ContentView: View { // MARK: - GestureState @GestureState private var dragAmount = CGSize.zero // MARK: - Body var body: some View { VStack { // yellow square rectangle Rectangle() .frame(width: 100, height: 100) .foregroundColor(.yellow) .offset(dragAmount) .gesture( DragGesture().updating($dragAmount) { value, state, transaction in state = value.translation } ) } } }
@ScaledMetric
Не забули в Apple і за функціонал для кращої доступності SwiftUI-застосунків. За допомогою @ScaledMetric ви можете визначити числові значення, які будуть автоматично масштабуватися відповідно до користувацьких налаштувань Dynamic Type.
struct ContentView: View { // MARK: - ScaledMetric @ScaledMetric var rectangleSize = 100.0 // MARK: - Body var body: some View { Rectangle() .foregroundColor(.yellow) .frame(width: rectangleSize, height: rectangleSize) } }
@NameSpace
Дана обгортка властивостей створює простір імен анімації, щоб синхронізувати анімації від одного View до іншого.
struct ContentView: View { // MARK: - State @State private var isGroupExpanded = false // MARK: - Namespace @Namespace private var namespace // MARK: - Body var body: some View { Group() { if isGroupExpanded { VerticalView(namespace: namespace) } else { HorizontalView(namespace: namespace) } } } } struct VerticalView: View { // MARK: - Properties(Public) var namespace: Namespace.ID // MARK: - Body var body: some View { VStack { // yellow rectangle Rectangle() .foregroundColor(.blue) .frame(width: 50, height: 50) .matchedGeometryEffect(id: "yellow_rectangle", in: namespace, properties: .frame) // blue rectangle Rectangle() .foregroundColor(.yellow) .frame(width: 50, height: 50) .matchedGeometryEffect(id: "blue_rectangle", in: namespace, properties: .frame) } } } struct HorizontalView: View { // MARK: - Properties(Public) var namespace: Namespace.ID // MARK: - Body var body: some View { HStack { // yellow rectangle Rectangle() .foregroundColor(.blue) .frame(width: 50, height: 50) .matchedGeometryEffect(id: "yellow_rectangle", in: namespace, properties: .frame) // blue rectangle Rectangle() .foregroundColor(.yellow) .frame(width: 50, height: 50) .matchedGeometryEffect(id: "blue_rectangle", in: namespace, properties: .frame) } } }
Підсумки
В цій статті ми розглянули data management/ керування потоком даних у SwiftUI. При оперуванні даними у SwiftUI слід памʼятати про поняття SSOT (єдиний ресурс даних) та вміти визначати, де ж цей єдиний ресурс даних є.
Також слід зважати, що не всі ці обгортки властивостей доступні з iOS 14, якщо ви ще підтримуєте застосунок з такою версію операційної системи.
І головне, памʼятайте, що вам не обовʼязково використати всі обгортки властивостей, які тільки є — важливо застосовувати їх там, де вони необхідні, там, де вони принесуть користь реалізації вашого проєкту.
1 коментар
Додати коментар Підписатись на коментаріВідписатись від коментарів