Роби як Apple. Поради щодо проєктування відкритих до змін компонентів

💡 Усі статті, обговорення, новини про Mobile — в одному місці. Приєднуйтесь до Mobile спільноти!

Вітаю, мандрівнику! Мене звати Микола Журба, я — Senior iOS Engineer у венчур-білдері Spalah ✨ Особливість роботи в такому типі компанії полягає в тому, що нові продукти запускаються дуже часто — і бажано дуже швидко. Щоб досягти швидкості, ми наполегливо намагаємося повторно використовувати вдалі технічні рішення.

А ви вже чули, що 21 червня DOU Mobile Day?

Це цікавий виклик, адже змушує мислити не в межах одного вузького проєкту, а з перспективою нескінченного океану майбутніх продуктів. Кожен модуль, компонент чи система потенційно можуть бути використані повторно. Тому під час їх розробки застосовуються інші підходи: прагнемо зробити все зручним у використанні, з простим і зрозумілим інтерфейсом та гнучкою конфігурацією.

У цій статті я поділюся підходами, які використовую під час створення UI-компонентів за допомогою SwiftUI на прикладі розробки одного такого компонента, а також дам кілька практичних порад, корисних для iOS-розробників будь-якого рівня.

Приклад UI-компоненту

Один із кастомних компонентів, які мені потрібно було розробити, це picker з кількома сегментами. Пікер може мати власний колір вибраного варіанту та шрифт, а кожен сегмент — різний тип вхідних даних.

Наскільки мені відомо, вбудований SwiftUI Picker не надає такої гнучкості. UIKit Picker підтримує роботу з кількома сегментами, а метод делегата дозволяє створювати власні view для рядків. Тому було прийнято рішення написати обгортку навколо UIKit-компонента.

Порада 1. Починайте з кінця

Особисто я при проєктуванні компонента починаю з бажаного інтерфейсу — того, яким хочу бачити його використання. Такий інтерфейс — моя омріяна кінцева точка. А мрії, як відомо, збуваються.

Для кастомного пікера я бачив такий інтерфейс (на прикладі екрана зі зростом):

PickerView {
    PickerComponent(
        elements: viewModel.majorAllowedComponents,
        selection: $viewModel.heightMajor,
        initialValue: viewModel.initialMajor
    )
    PickerComponent(
        elements: [","],
        selection: .constant(nil),
        initialValue: nil
    )
    PickerComponent(
        elements: viewModel.minorAllowedComponents,
        selection: $viewModel.heightMinor,
        initialValue: viewModel.initialMinor
    )
    PickerComponent(
        elements: viewModel.measurementSystemComponents,
        selection: $viewModel.measurementSystem
    ) { system in
        system.height.rawValue
    }
}

Що я очікую від інтерфейсу:

  • Я хотів, щоб PickerView мав максимально декларативний інтерфейс у стилі SwiftUI.
  • PickerView приймає список компонентів (сегментів).
  • Кожен компонент — це дженерік PickerComponent<T>, де T — тип допустимих значень у сегменті. Усі значення в масиві мають бути одного типу. Наприклад, перший компонент може мати [String], другий — [Int] і так далі.
  • Компонент приймає Binding на обране значення. Потрібно підтримати як Binding<T>, так і Binding<T?> — залежно від того, чи може значення бути відсутнім.
  • Додатково можна передати початкове значення, яке буде вибрано, якщо Binding містить nil. Це дозволяє зробити обраним не перший рядок за замовчуванням.
  • Кожен компонент приймає опціональний «білдер» для рядкової репрезентації значень. Якщо його не передано, використовується дефолтна логіка для перетворення T у текст.

Що ж, початок закладено, можна приступати до реалізації.

Порада 2. Починайте з простого, поступово додаючи складність

Спершу створимо базовий UI-компонент, який поки що не приймає жодних параметрів.

import SwiftUI

public struct PickerView: UIViewRepresentable {

    public func makeUIView(context: Context) -> UIPickerView {
        let picker = UIPickerView()
        return picker
    }

    public func updateUIView(_ picker: UIPickerView, context: Context) {

    }
}

Тепер додамо підтримку передачі компонентів, які поки що працюють лише з типом Int.

public struct PickerComponent {

    let elements: [Int]
    let selection: Binding<Int?>
    let initialValue: Int?

    public init(..) { … }
}
public struct PickerView: UIViewRepresentable {

    public init(components: [PickerComponent]) {

    }
…
}

На цьому етапі такий код уже успішно компілюється:

PickerView(
    components: [
        PickerComponent(
            elements: viewModel.majorAllowedComponents,
            selection: $viewModel.heightMajor,
            initialValue: viewModel.initialMajor
        )
    ]
)

Так, виглядає поки що жахливо і нічого не робить — але baby steps, мандрівнику, baby steps.

Порада 3. Використовуйте resultBuilder для зручного та декларативного інтерфейсу

resultBuilder в Swift — це спеціальний атрибут, який дозволяє створювати DSL (Domain-Specific Language) з більш читабельним синтаксисом.

Простими словами: він дозволяє писати блоки коду без явних return-ів або масивів, а Swift сам «збирає» результат.

Для наших цілей його реалізація буде максимально простою:

@resultBuilder
public enum PickerComponentBuilder {

    public typealias Component = PickerComponent

    public static func buildBlock(_ components: Component...) -> [Component] {
        components
    }
}

Тепер можна переписати інтерфейс ініціалізації PickerView, використовуючи наш resultBuilder:

public struct PickerView: UIViewRepresentable {

    public init(
        @PickerComponentBuilder components: () -> [PickerComponent]
    ) {

    }
…
}

І тепер такий код уже є валідним:

PickerView {
    PickerComponent(
        elements: viewModel.majorAllowedComponents,
        selection: $viewModel.heightMajor,
        initialValue: viewModel.initialMajor
    )
    PickerComponent(
        elements: viewModel.minorAllowedComponents,
        selection: $viewModel.heightMinor,
        initialValue: viewModel.initialMinor
    )
}

Додамо необхідну логіку до пікера. Не буду зупинятися на цьому детально — там стандартна реалізація для UIViewRepresentable:

import SwiftUI

public struct PickerView: UIViewRepresentable {

    private let components: [PickerComponent]

    public init(
        @PickerComponentBuilder components: () -> [PickerComponent]
    ) {
        self.components = components()
    }

    public func makeUIView(context: Context) -> UIPickerView {
        let picker = UIPickerView()

        picker.dataSource = context.coordinator
        picker.delegate = context.coordinator

        for (componentIndex, component) in components.enumerated() {
            guard
                let selection = component.selection.wrappedValue ?? component.initialValue,
                let rowIndex = component.elements.firstIndex(where: { $0 == selection }) else {
                continue
            }
            picker.selectRow(rowIndex, inComponent: componentIndex, animated: false)
        }

        return picker
    }

    public func updateUIView(_ picker: UIPickerView, context: Context) {
        context.coordinator.parent = self
        DispatchQueue.main.async {
            picker.reloadAllComponents()
            for (componentIndex, component) in components.enumerated() {
                guard let selection = component.selection.wrappedValue ?? component.initialValue,
                      let rowIndex = component.elements.firstIndex(where: { $0 == selection }) else {
                    continue
                }

                guard picker.numberOfComponents > componentIndex,
                      picker.numberOfRows(inComponent: componentIndex) > rowIndex else {
                    return
                }
                picker.selectRow(rowIndex, inComponent: componentIndex, animated: true)
                picker.reloadComponent(componentIndex)
            }
        }
    }

    public func makeCoordinator() -> Coordinator {
        Coordinator(parent: self)
    }

    // MARK: - Coordinator

    public class Coordinator: NSObject, UIPickerViewDataSource, UIPickerViewDelegate {

        var parent: PickerView

        init(parent: PickerView) {
            self.parent = parent
        }

        // MARK: - UIPickerViewDataSource

        public func numberOfComponents(in pickerView: UIPickerView) -> Int {
            parent.components.count
        }

        public func pickerView(
            _ pickerView: UIPickerView,
            numberOfRowsInComponent component: Int
        ) -> Int {
            parent.components[safe: component]?.elements.count ?? 0
        }

        // MARK: - UIPickerViewDelegate

        public func pickerView(
            _ pickerView: UIPickerView,
            viewForRow row: Int,
            forComponent component: Int,
            reusing view: UIView?
        ) -> UIView {
            guard let pickerComponent = parent.components[safe: component],
                  let element = pickerComponent.elements[safe: row] else {
                return (view ?? UIView())
            }
            let label = (view as? UILabel) ?? UILabel()
            label.textAlignment = .center
            label.text = String(describing: element)
            return label
        }


        public func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {

            let selection = parent.components[component].selection
            let value = parent.components[component].elements[row]
            selection.wrappedValue = value
        }
    }
}

private extension Array {
    subscript(safe index: Int) -> Element? {
        guard indices.contains(index) else {
            return nil
        }
        return self[index]
    }
}

Пікер поступово набуває робочого вигляду.

Тепер спробуємо додати підтримку компонентів із різними типами, зробивши PickerComponent дженериком:

public struct PickerComponent<T: Hashable> {

    let elements: [T]
    let selection: Binding<T?>
    let initialValue: T?

    public init(...) { … }
}

І тут компілятор починає скаржитись. Задача виявляється не такою простою, як здавалося.

Проблема в тому, що і resultBuilder, і сам PickerView очікують конкретний тип. А ми тепер хочемо, щоб вони підтримували різні: PickerComponent, PickerComponent тощо. Умовно кажучи, нам потрібен щось на кшталт any PickerComponent.

Порада 4. Простий інтерфейс використання виправдовує складність імплементації

Щоб реалізувати задумане, довелося трохи повозитися.

Почнемо зі створення PAT (protocol with associatedtype), який описуватиме «вхідні» дані пікера — тобто сегменти, які ми передаватимемо.

public protocol PickerComponentProtocol<Element> {
    associatedtype Element: Hashable

    var elements: [Element] { get }
    var selection: Binding<Element?> { get }
    var initialValue: Element? { get }
}

Використання цього PAT дасть змогу заглушити одну з помилок компілятора в resultBuilder:

@resultBuilder
public enum PickerComponentBuilder {

    public typealias Component = any PickerComponentProtocol

    public static func buildBlock(_ components: Component...) -> [Component] {
        components
    }
}

Проте використати any PickerComponentProtocol у PickerView не вийде — компілятор видасть іншу помилку:

Member ’selection’ cannot be used on value of type ’any PickerComponentProtocol’; consider using a generic constraint instead.

Наступним кроком буде створення ще одного протоколу для «вихідних» даних — тих, з якими безпосередньо працюватиме PickerView. Цей протокол не міститиме associatedtype.

public protocol AnyPickerComponentProtocol {
    var elements: [AnyHashable] { get }
    var selection: Binding<AnyHashable?> { get }
    var initialValue: AnyHashable? { get }
}

Саме цей протокол і буде використаний у PickerView та стане результуючим типом у resultBuilder:

@resultBuilder
public enum PickerComponentBuilder {

    public static func buildBlock(_ components: any PickerComponentProtocol...) -> [AnyPickerComponentProtocol] {
        // fix
    }
}

Залишилося реалізувати конвертацію з any PickerComponentProtocol у AnyPickerComponentProtocol.

Порада 5. Використовуйте техніку type-erasure

Створимо структуру-міст між PickerComponentProtocol та AnyPickerComponentProtocol:

private struct AnyPickerComponent: AnyPickerComponentProtocol {

    let elements: [AnyHashable]
    let selection: Binding<AnyHashable?>
    let initialValue: AnyHashable?

    init<PC: PickerComponentProtocol>(_ component: PC) {
        self.elements = component.elements
        self.selection = .init(get: {
            component.selection.wrappedValue
        }, set: { newValue in
            component.selection.wrappedValue = newValue as? PC.Element
        })
        self.initialValue = component.initialValue
    }
}

Apple пропонує подібні рішення в SwiftUI — AnyView, AnyLayout тощо.

Використаємо нашу структуру у resultBuilder:

@resultBuilder
public enum PickerComponentBuilder {

    public static func buildBlock(_ components: any PickerComponentProtocol...) -> [AnyPickerComponentProtocol] {
        components.map { AnyPickerComponent($0) }
    }
}

Нарешті ми можемо передавати різні типи даних у пікер і працювати з ними.

У прикладі нижче я передаю Int, String та enum MeasurementSystem:

PickerView {
    PickerComponent(
        elements: viewModel.majorAllowedComponents,
        selection: $viewModel.heightMajor,
        initialValue: viewModel.initialMajor
    )
    PickerComponent(
        elements: [","],
        selection: .constant(nil),
        initialValue: nil
    )
    PickerComponent(
        elements: viewModel.minorAllowedComponents,
        selection: $viewModel.heightMinor,
        initialValue: viewModel.initialMinor
    )
    PickerComponent(
        elements: viewModel.measurementSystemComponents,
        selection: $viewModel.measurementSystem
    ) { system in
        system.height.rawValue
    }
}

Порада 6. Продумайте різні сценарії використання і додайте їх підтримку

Зараз PickerComponent підтримує опціональне вибране значення (selection: Binding). Тобто у ViewModel є, наприклад, проперті:

@Published var heightMajor: Int?

Це один зі сценаріїв. Але якщо у ViewModel для певного проперті значення завжди присутнє:

@Published var measurementSystem: MeasurementSystem = .metric

Тоді ініціалізатор PickerComponent видасть помилку:

Ба більше, немає сенсу передавати initialValue, оскільки він потрібен лише коли selection містить nil. Також, як я згадував раніше, для деяких типів варто дозволити передавати кастомну логіку відображення замість дефолтного String(describing:).

Не хочу змушувати робити костилі у прикладному коді, тож краще запропоную альтернативний інтерфейс для створення PickerComponent. І це досить просто — достатньо додати ще один init:

public init(
    elements: [Element],
    selection: Binding<Element>,
    stringBuilder: @escaping (Element) -> String = { element in
        String(describing: element)
    }
) {
    self.elements = elements
    self.selection = .init(get: {
        selection.wrappedValue
    }, set: { newValue in
        if let newValue {
            selection.wrappedValue = newValue
        }
    })
    self.initialValue = selection.wrappedValue
    self.stringBuilder = stringBuilder
}

Я додав stringBuilder до всіх протоколів і структур, тепер можу використовувати його у делегатському методі UIPickerViewDelegate для створення лейблів:

public func pickerView(
    _ pickerView: UIPickerView,
    viewForRow row: Int,
    forComponent component: Int,
    reusing view: UIView?
) -> UIView {
    guard let pickerComponent = parent.components[safe: component],
          let element = pickerComponent.elements[safe: row] else {
        return (view ?? UIView())
    }
    let label = (view as? UILabel) ?? UILabel()
    label.textAlignment = .center
    label.text = pickerComponent.stringBuilder(element)
    return label
}

Це, нарешті, дозволяє використовувати наш компонент у будь-яких сценаріях:

PickerComponent(
    elements: viewModel.measurementSystemComponents,
    selection: $viewModel.measurementSystem
) { system in
    system.height.rawValue
}

Отже, логіку пікера завершено, але залишилось питання стилізації. Для початкової задачі потрібно змінити колір і шрифт лейбла, який створюється у делегатському методі. Найпростіший спосіб — передати ці параметри в PickerView:

PickerView(tintColor: .green, font: .system(ofSize: 16, height: .bold)) {
…
}

Але на практиці це не найкраще рішення. По-перше, якщо на екрані або в ієрархії є кілька пікерів з однаковою стилізацією, доведеться передавати ці параметри для кожного з них. По-друге, ми обмежуємося лише зміною кольору і шрифту, тоді як прикладний код може потребувати інших додаткових модифікацій.

Першу проблему вирішити досить просто, тож почнемо з неї.

Порада 7. Використовуйте Environment для передачі конфігурації SwiftUI Views

@Environment у SwiftUI дозволяє отримувати значення з оточення, задані вище по ієрархії в’ю. Це зручно для передачі даних вниз без прямого прокидання через параметри. Ми можемо створювати власні EnvironmentValues і використовувати їх у наших компонентах.

extension EnvironmentValues {

    @Entry var pickerTintColor: UIColor = .label
    @Entry var pickerFont: UIFont = .systemFont(ofSize: 14, height: .regular)
}

extension View {

    func pickerTintColor(_ color: UIColor) -> some View {
        environment(\.pickerTintColor, color)
    }

    func pickerFont(_ font: UIFont) -> some View {
        environment(\.pickerFont, font)
    }
}
PickerView {
    …
}
.pickerTintColor(.green)
.pickerFont(.systemFont(ofSize: 16, height: .bold))

Проблема в тому, що, по-перше, ми засмічуємо глобальний namespace, а по-друге — не позбавляємося обмежень у параметрах конфігурації. Тут варто подивитися, як Apple вирішує це у своїх компонентах: якщо не знаєш, які саме параметри потрібні або як реалізувати метод — то і не треба! Проксуй реалізацію на прикладний код.

Порада 8. Використовуйте стилі для конфігурації UI-компонентів

Apple застосовує ButtonStyle для кнопок, ToggleStyle для тоглів, TextFieldStyle для текстових полів тощо. Це прості протоколи з конкретними реалізаціями, які передаються через environment і використовуються UI-елементами. Для PickerView можна зробити так само.

public protocol PickerViewStyle {

    func pickerView(
        _ pickerView: UIPickerView,
        viewForRow row: Int,
        forComponent component: Int,
        forStringRepresentation string: String,
        reusing view: UIView?
    ) -> UIView

    func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int)
}

За допомогою цього стилю я проксуватиму логіку делегатських методів і одночасно передаватиму додаткову інформацію про рядкову репрезентацію елемента (через forStringRepresentation string: String).

Створю дві конкретні реалізації: дефолтну — яка майже нічого не змінює, та primary — з потрібною мені стилізацією для початкової задачі.

public struct DefaultPickerViewStyle: PickerViewStyle {

    public func pickerView(
        _ pickerView: UIPickerView,
        viewForRow row: Int,
        forComponent component: Int,
        forStringRepresentation string: String,
        reusing view: UIView?
    ) -> UIView {
        let label = (view as? UILabel) ?? UILabel()
        label.textAlignment = .center
        label.text = string
        return label
    }

    public func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {

    }
}

public extension PickerViewStyle where Self == DefaultPickerViewStyle {

    static var `default`: Self { Self() }
}

public struct PrimaryPickerViewStyle: PickerViewStyle {

    let tintColor: UIColor

    public func pickerView(
        _ pickerView: UIPickerView,
        viewForRow row: Int,
        forComponent component: Int,
        forStringRepresentation string: String,
        reusing view: UIView?
    ) -> UIView {
        let isSelected = pickerView.selectedRow(inComponent: component) == row

        let label = (view as? UILabel) ?? UILabel()
        label.textAlignment = .center
        label.text = string
        label.font = .systemFont(ofSize: 16, height: .bold)
        label.textColor = isSelected ? tintColor : .gray
        return label
    }

    public func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
        pickerView.reloadComponent(component)
    }
}

public extension PickerViewStyle where Self == PrimaryPickerViewStyle {

    static var primary: Self {
        Self(tintColor: .green)
    }

    static func primary(tintColor: UIColor) -> Self {
        Self(tintColor: tintColor)
    }
}

public extension EnvironmentValues {
    @Entry var pickerViewStyle: PickerViewStyle = DefaultPickerViewStyle()
}

public extension View {

    func pickerViewStyle(_ style: PickerViewStyle) -> some View {
        environment(\.pickerViewStyle, style)
    }
}

У самому PickerView я отримаю стиль через environment і викличу його методи:

public struct PickerView: UIViewRepresentable {

    @Environment(\.pickerViewStyle) var pickerViewStyle

    …
 
    public class Coordinator: NSObject, UIPickerViewDataSource, UIPickerViewDelegate {

        …

        public func pickerView(
            _ pickerView: UIPickerView,
            viewForRow row: Int,
            forComponent component: Int,
            reusing view: UIView?
        ) -> UIView {
            guard let pickerComponent = parent.components[safe: component],
                  let element = pickerComponent.elements[safe: row] else {
                return (view ?? UIView())
            }
            return parent.pickerViewStyle.pickerView(
                pickerView,
                viewForRow: row,
                forComponent: component,
                forStringRepresentation: pickerComponent.stringBuilder(element),
                reusing: view
            )
        }

        public func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
            parent.pickerViewStyle.pickerView(pickerView, didSelectRow: row, inComponent: component)
            let selection = parent.components[component].selection
            let value = parent.components[component].elements[row]
            selection.wrappedValue = value
        }
    }
}

І нарешті, я можу застосувати стиль до пікера:

PickerView {
 …
}
.pickerViewStyle(.primary)

Прикладний код може створювати власні стилі за потреби, що робить компонент відкритим для розширень. При цьому інтерфейс використання залишається простим і зручним. Як і казав — мрії здійснюються.

Отже, які висновки?

Починайте розробку нових компонентів із визначення того, як ви хочете їх використовувати. Як реалізувати задумане — то вже технічна проблема, а майже для кожної технічної проблеми є технічне рішення. Створюйте компоненти зручними — інакше ними просто не будуть користуватися. Внутрішню логіку можна робити будь-якою, головне — забезпечити простий і зрозумілий інтерфейс. Використовуйте підхід Apple зі стилями для конфігурації і передавайте їх через environment.

На цьому все. Фінальну версію коду я розмістив у репозиторії. Сподіваюся, мандрівнику, ти знайшов щось корисне. Якщо є питання — пиши, з радістю допоможу. А якщо маєш свої лайфхаки чи підходи — не соромся поділитися, мені буде цікаво!

👍ПодобаєтьсяСподобалось5
До обраногоВ обраному0
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

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