Стилизация представления - самая важная часть создания красивых пользовательских интерфейсов. И когда дело доходит до кода, важно что бы он был грамотным, чистым и переиспользуемым.
В этой статье мы рассмотрим три подхода к стилизации 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