3 способа стилизации представлений в SwiftUI

14 июня 2021

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

В этой статье мы рассмотрим три подхода к стилизации SwiftUI.View:

1. Конфигурация на основе инициализатора
2. Цепочка методов с использованием return-self
3. Стили в окружающей среде

Все подходы жизнеспособны. Вы можете выбрать любой, подходящий лично вам.

1. Конфигурация на основе инициализатора

Это довольно простой подход, который можно быстро презентовать на примере:

struct InitializerBasedConfigurationView: View {

    let backgroundColor: Color
    let textColor: Color

    var body: some View {
        Text("Hello, world!")
            .padding()
            .background(backgroundColor)
            .foregroundColor(textColor)
            .cornerRadius(10)
    }
}

InitializerBasedConfigurationView(backgroundColor: .green, textColor: .white)

Это представление принимает два параметра backgroundColor и textColor, которые требуются при создании экземпляра структуры. Параметры объявлены, как константы, так как предполагается, что после иницилаизации представления они не должны менять своих значений.

Так как это представление является структурой, то для него доступен встроенный инициализатор для всех неинициализированных свойств прямо "из коробки". При этом в случае необходимости мы так же можем реализвать инициализатор самостоятельно:

struct InitializerBasedConfigurationView: View {

    let backgroundColor: Color
    let textColor: Color

    init(backgroundColor: Color, textColor: Color) {
        self.backgroundColor = backgroundColor
        self.textColor = textColor
    }

    var body: some View {
        Text("Hello, world!")
            .padding()
            .background(backgroundColor)
            .foregroundColor(textColor)
            .cornerRadius(10)
    }
}

InitializerBasedConfigurationView(backgroundColor: .green, textColor: .white)

Заметка

Xcode также позволяет сгенерерировать почленный инициализатор автоматически при помощи соответствующей функции. Для этого нужно вызывать конетекстное меню из названия структуры CMD (⌘) + левая кнопка мыши и выбрать пункт "Generate Memberwise Initializer".

Кастомный инициализатор может понадобиться, если представление является частью пакета и должно быть публичным. Так как Swift генерирует только внутренние инициализаторы, приложение, использующее пакет, не сможет найти или создать экземпляр представления без такого кастомного инициализатора.

При использовании кастомного инициализатора для параметров можно задавать значения по умолчанию:

struct InitializerBasedConfigurationView: View {

    let backgroundColor: Color
    let textColor: Color

    init(backgroundColor: Color = .green, textColor: Color = .white) {
        self.backgroundColor = backgroundColor
        self.textColor = textColor
    }

    // ... rest of view
}

С другой стороны, если это представление используется исключительно внутри вашего приложения, то компилятору можно позволить сделать эту работу за вас. Все, что необходимо, - это изменить let на var и напрямую установить значения по умолчанию в свойствах экземпляра:

struct InitializerBasedConfigurationView: View {

    var backgroundColor: Color = .green
    var textColor: Color = .white

    // ... rest of view ...
}

// these are all valid now:
InitializerBasedConfigurationView()
InitializerBasedConfigurationView(backgroundColor: .blue)
InitializerBasedConfigurationView(backgroundColor: .black, textColor: .red)

2. Цепочка методов с использованием return-self

Когда представления разрастаются и начинают требовать все большее количество параметров для инициализации, это влияет непосредственно на сами инициализаторы. От этого они становятся объемными.

struct MethodChainingView: View {

    var actionA: () -> Void = {}
    var actionB: () -> Void = {}
    var actionC: () -> Void = {}
    var actionD: () -> Void = {}
    var actionE: () -> Void = {}

    var body: some View {
        HStack {
            Button(action: actionA) {
                Text("Button A")
            }
            Button(action: actionB) {
                Text("Button B")
            }
            Button(action: actionC) {
                Text("Button C")
            }
            Button(action: actionD) {
                Text("Button D")
            }
            Button(action: actionE) {
                Text("Button E")
            }
        }
    }
}

// Usage:
MethodChainingView(actionA: {
    print("do something")
}, actionB: {
    print("do something different")
}, actionC: {
    print("do something very different")
}, actionD: {
    print("do nothing")
}, actionE: {
    print("what are you doing?")
})

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

В этом случае такие большие иницилазаторы со значениями по умолчанию можно разбить на последовательный вызов цепочки return-self методов:

struct MethodChainingView: View {

    private var actionA: () -> Void = {}
    private var actionB: () -> Void = {}

    // ... rest of viwe
    func actionA(_ action: @escaping () -> Void) -> Self {
        // You can't edit view directly, as it is immutable
        var view = self
        view.actionA = action
        return view
    }

    func actionB(_ action: @escaping () -> Void) -> Self {
        // You can't edit view directly, as it is immutable
        var view = self
        view.actionB = action
        return view
    }
}

// Usage:
MethodChainingView()
    .actionA {
        print("do something")
    }
    .actionB {
        print("do something different")
    }

Поскольку само представление неизменяемо и состоит из чистых данных (структуры не являются объектами), мы можем создать локальную копию представления: var view = self. Так как теперь это локальная переменная, то можно изменить ее и установить действие, прежде чем вернуть её, как результат работы метода.

3. Стили в окружающей среде

Помимо ручной настройки каждого отдельного представления, мы можем определить глобальный стиль доступный в пределах всего приложения. Пример может выглядеть следующим образом:

enum Style {

    enum Text {
      
        static let headlineColor = Color.black
        static let subheadlineColor = Color.gray
      
    }
}

struct EnvironmentStylesheetsView: View {

    var body: some View {
        VStack {
            Text("Headline")
                .foregroundColor(Style.Text.headlineColor)
            Text("Subheadline")
                .foregroundColor(Style.Text.subheadlineColor)
        }
    }
}

Но данный подход имеет один большой недостаток: Значения глобальных статических переменных не отображаются в окне предварительного просмотра Xcode 😕

В этом случае нам достаточно будет отказаться от статиков:

struct Style {

    struct Text {

        var headlineColor = Color.black
        var subheadlineColor = Color.gray

    }

    var text = Text()

}

struct EnvironmentStylesheetsView: View {

    let style: Style

    var body: some View {
        VStack {
            Text("Headline")
                .foregroundColor(style.text.headlineColor)
            Text("Subheadline")
                .foregroundColor(style.text.subheadlineColor)
        }
    }
}

Это выглядит многообещающе, поскольку теперь мы можем передавать конфигурацию стиля в представление из любого места, где нам это нужно, в том числе и вструктуре для предварительного просмотра:

struct ContentView: View {

    var body: some View {
        // uses the default style
        EnvironmentStylesheetsView(style: Style())
    }
}

struct ContentView_Previews: PreviewProvider {

    static var previews: some View {
        // uses the customized style
        EnvironmentStylesheetsView(style: customizedStyle)
    }

    static var customizedStyle: Style {
        var style = Style()
        style.text.headlineColor = .green
        return style
    }
}

На первый взгляд такое решение выглядит довольно чисто. Но вы возможно усомнитесь в том, что это решение действительно является глобальным. И будете правы, т.к. данный подход требует, чтобы мы передали стиль каждому представлению, как в следующем фрагменте кода:

struct ContentView: View {

    var body: some View {
        // can this Style instance truely be considered "global"??
        Foo(style: Style())
    }
}

struct Foo: View {

    let style: Style

    var body: some View {
        Bar(style: style)
    }
}

struct Bar: View {

    let style: Style

    var body: some View {
        FooBar(style: style)
    }
}

struct FooBar: View {

    let style: Style

    var body: some View {
        Text("Content")
            .foregroundColor(style.text.headlineColor)
    }
}

Здесь потребовалось три прохода только для того, чтобы передать объект «глобального» стиля во вложенное представление FooBar. Это неприемлемо. Нам не нужно столько лишнего кода, т.к. лишний код - это плохой код!

Хорошо, что мы можем сделать в этом случае? А как насчет сочетания статиков и решения с использованием экземпляров?

Все, что нам нужно, это статический объект, в котором мы можем установить стиль из Foo и прочесть его из FooBar ... звучит как некая общая среда💡

Для этих целей SwiftUI представляет нам оболочку над свойствами @Environment, которая позволяет считывать значение из общей среды нашего представления🥳

В качестве первого шага надо создать новую структуру и подписать её под протокол EnvironmentKey, который требует обязательной реализации свойства defaultValue:

struct StyleEnvironmentKey: EnvironmentKey {
    static var defaultValue = Style()
}

Далее в расширении для типа EnvironmentValues необходимо добавить новый ключ среды, чтобы к нему можно было получить доступ из оболочки свойства:

extension EnvironmentValues {

    var style: Style {
        get { self[StyleEnvironmentKey.self] }
        set { self[StyleEnvironmentKey.self] = newValue }
    }
}

Это позволит устанавливать значения при помощи .environment(\.style, ...) в родительском представлении и считывать их в дочерних, используя для этого привычный синтаксис @Environment (\\.style):

struct ContentView: View {

    var body: some View {
        Foo()
            .environment(\.style, customizedStyle)
    }

    var customizedStyle: Style {
        var style = Style()
        style.text.headlineColor = .green
        return style
    }
}

struct Foo: View {

    var body: some View {
        Bar()
    }
}

struct Bar: View {

    var body: some View {
        FooBar()
    }
}

struct FooBar: View {

    @Environment(\.style) var style

    var body: some View {
        Text("Content")
            .foregroundColor(style.text.headlineColor)
    }
}

Отлично! Теперь нам не нужно передавать данные через через всю цепочку дочерних объектов. Достаточно обратиться к данным из того места, где это нужно.

Бонус: Пользовательские обертки свойств

Уже хорошо работает, но можно ведь лучше!

struct FooBar: View {

    @Theme(\.text.headlineColor) var headlineColor

    var body: some View {
        Text("Content")
            .foregroundColor(headlineColor)
    }
}

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

@propertyWrapper struct Theme {

    @Environment(\.style) private var style
    private let keyPath: KeyPath

    init(_ keyPath: KeyPath<Style, Value>) {
        self.keyPath = keyPath
    }

    public var wrappedValue: Value {
        style[keyPath: keyPath]
    }
}</code></pre></div>

Более того, использование расширения View позволяет нам полностью скрыть использование Environment!

extension View {

    func theme(_ theme: Style) -> some View {
        self.environment(\.style, theme)
    }
}

struct ContentView: View {

    var body: some View {
        Foo().theme(customizedStyle)
    }

    var customizedStyle: Style {
        var style = Style()
        style.text.headlineColor = .green
        return style
    }
}

Заметка

Причина, по которой стиль теперь называется theme - это, честно говоря, просто конфликт имен оболочки свойства @Style с struct Style. Если вы переименуете структуру стиля, вы также можете использовать это имя для оболочки свойства.

Выводы

SwiftUI предлагает несколько различных способов построения иерархии view, и мы рассмотрели лишь некоторые из них. Дополнительные параметры, например, ViewModifier уже существует, и в будущем появится еще больше.

На момент написания туториала лучшего варианта так и нет, так как технология все еще довольно новая. Вместо этого у нас есть разные передовые методы на выбор, и мы можем сосредоточиться на повторном использовании, настраиваемости и чистоте нашего кода.

Оригинал статьи

Содержание