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, якщо ви ще підтримуєте застосунок з такою версію операційної системи.

І головне, памʼятайте, що вам не обовʼязково використати всі обгортки властивостей, які тільки є — важливо застосовувати їх там, де вони необхідні, там, де вони принесуть користь реалізації вашого проєкту.

👍ПодобаєтьсяСподобалось13
До обраногоВ обраному8
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Дуже дякую. Знайшла для себе кілька корисних інструментів про які не знала 🙏🏻

Підписатись на коментарі