Одним из наиболее интересных аспектов SwiftUI, по крайней мере с архитектурной точки зрения, является то, что по сути он трактует вью как данные. В конце концов, вью SwiftUI - это не прямое представление пикселей, которые отображаются на экране, а скорее описание того, как должен работать, выглядеть и вести себя данный элемент UI.
Такой подход, определяемый данными, дает нам огромную гибкость в отношении того, как мы структурируем наш код вью - до такой степени, что можно даже начать задаваться вопросом, в чем разница между определением элемента UI в качестве типа вью и реализацией того же кода как модификатора.
В качестве примера возьмем следующее вью FeaturedLabel - оно добавляет изображение в виде звездочки слева от заданного текста и также применяет определенный цвет переднего плана и шрифт, чтобы этот текст выделялся как «характерный»(*рекомендуемый, избранный):
struct FeaturedLabel: View {
var text: String
var body: some View {
HStack {
Image(systemName: "star")
Text(text)
}
.foregroundColor(.orange)
.font(.headline)
}
}
Хотя вышеописанное может выглядеть как типичное пользовательское вью, точно такой же UI можно легко достичь, используя “подобное модификатору” расширение протокола View, вот так:
extension View {
func featured() -> some View {
HStack {
Image(systemName: "star")
self
}
.foregroundColor(.orange)
.font(.headline)
}
}
Вот как будут выглядеть эти два разных решения рядом, помещенные в пример ContentView:
struct ContentView: View {
var body: some View {
VStack {
// View-based version:
FeaturedLabel(text: "Hello, world!")
// Modifier-based version:
Text("Hello, world!").featured()
}
}
}
Одно ключевое отличие между нашими двумя решениями заключается в том, что последнее можно применять к любому вью, в то время как первое позволяет создавать характерные метки только на основе строк. Однако мы можем решить эту проблему, превратив нашу FeaturedLabel в пользовательский контейнер-вью, который принимает любое содержимое, соответствующее View, а не только простые строки:
struct FeaturedLabel
@ViewBuilder var content: () -> Content
var body: some View {
HStack {
Image(systemName: "star")
content()
}
.foregroundColor(.orange)
.font(.headline)
}
}
Здесь, к нашему замыканию content , мы добавляем атрибут ViewBuilder, чтобы на каждом месте вызова разрешить использование всей мощи API построения вью в SwiftUI (что, например, позволяет использовать операторы if и switch при построении содержимого для каждой FeaturedLabel).
Однако, нам все еще может быть нужно облегчить инициализацию экземпляра FeaturedLabel с помощью строки, а не всегда передавать замыкание, содержащее вью Text. К счастью, мы можем легко сделать это, используя ограниченное по типу расширение:
extension FeaturedLabel where Content == Text {
init(_ text: String) {
self.init {
Text(text)
}
}
}
Здесь мы используем символ подчеркивания, чтобы убрать внешнюю метку параметра для text, чтобы имитировать работу собственных удобных API, встроенных в SwiftUI, для таких типов, как Button и NavigationLink.
С этими изменениями оба наших решения теперь имеют точно такой же уровень гибкости и могут легко использоваться для создания меток как на основе текста, так и на основе любого SwiftUI-вью, которое мы хотим:
struct ContentView: View {
@State private var isToggleOn = false
var body: some View {
VStack {
// Using texts:
Group {
// View-based version:
FeaturedLabel("Hello, world!")
// Modifier-based version:
Text("Hello, world!").featured()
}
// Using toggles:
Group {
// View-based version:
FeaturedLabel {
Toggle("Toggle", isOn: $isToggleOn)
}
// Modifier-based version:
Toggle("Toggle", isOn: $isToggleOn).featured()
}
}
}
}
На данном этапе мы действительно можем задаться вопросом: что именно отличает определение части UI как View от Modifiers? Действительно ли существует практическая разница, кроме стиля и структуры кода?
Что насчет состояния? Допустим, мы хотим, чтобы наши новые характерные метки автоматически появлялись с эффектом затухания при первом появлении. Для этого нам потребуется определить свойство opacity, помеченное как @State, которое мы затем будем анимировать с помощью замыкания onAppear, например, так:
struct FeaturedLabel
@ViewBuilder var content: () -> Content
@State private var opacity = 0.0
var body: some View {
HStack {
Image(systemName: "star")
content()
}
.foregroundColor(.orange)
.font(.headline)
.opacity(opacity)
.onAppear {
withAnimation {
opacity = 1
}
}
}
}
Поначалу участие в системе управления состоянием SwiftUI может показаться чем-то таким, что могут делать только соответствующие типы вью, но оказывается, что модификаторы обладают точно такой же возможностью, при условии, что мы определим такой модификатор как тип, соответствующий протоколу ViewModifier, а не просто используем расширение протокола View:
struct FeaturedModifier: ViewModifier {
@State private var opacity = 0.0
func body(content: Content) -> some View {
HStack {
Image(systemName: "star")
content
}
.foregroundColor(.orange)
.font(.headline)
.opacity(opacity)
.onAppear {
withAnimation {
opacity = 1
}
}
}
}
С учетом вышеизложенного, теперь мы можем заменить нашу предыдущую реализацию метода featured вызовом, добавляющим наш новый FeaturedModifier к текущему вью, и оба наших подхода к созданию характерных меток вновь будут иметь точно такой же результат:
extension View {
func featured() -> some View {
modifier(FeaturedModifier())
}
}
Также стоит отметить, что при обертывании нашего кода в тип ViewModifier, этот код лениво вычисляется при необходимости, а не выполняется заранее при первом добавлении модификатора, что может сыграть роль с точки зрения производительности в определенных ситуациях.
Независимо от того, хотим ли мы изменить стили или структуру вью или ввести новый элемент состояния, становится ясно, что SwiftUI вью и модификаторы имеют одинаковые возможности. Но тогда возникает следующий вопрос: если между этими двумя подходами нет практических различий, - что выбрать?
По моему мнению, это зависит от структуры итоговой иерархии вью. Хотя мы, технически, меняли иерархию вью, обернув одну из наших характерных меток в HStack, чтобы добавить изображение звездочки, концептуально это было скорее о стилизации, чем о структуре. При применении модификатора featured ко вью, его макет или расположение в иерархии вью не меняется значимым образом - оно остается одним вью с тем же макетом, но с дополнительной стилизацией или функциональностью.
Однако это не всегда так. Давайте рассмотрим другой пример, который более ясно иллюстрирует потенциальные структурные различия между вью и модификаторами.
Здесь мы написали контейнер SplitView, который принимает два вью - одно ведущее и одно следующее - и затем отображает их бок о бок с разделителем между ними, одновременно максимизируя их рамки, чтобы они равномерно распределяли доступное пространство.
struct SplitView
@ViewBuilder var leading: () -> Leading
@ViewBuilder var trailing: () -> Trailing
var body: some View {
HStack {
prepareSubview(leading())
Divider()
prepareSubview(trailing())
}
}
private func prepareSubview(_ view: some View) -> some View {
view.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
Как и раньше, мы определенно можем достичь того же результата с помощью подхода на основе модификаторов - это может выглядеть так:
extension View {
func split(with trailingView: some View) -> some View {
HStack {
maximize()
Divider()
trailingView.maximize()
}
}
func maximize() -> some View {
frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
Однако, если мы снова поместим наши два решения рядом в рамках того же примера ContentView, то сможем увидеть, что на этот раз два подхода выглядят довольно по-разному в плане структуры и ясности:
struct ContentView: View {
var body: some View {
VStack {
// View-based version:
SplitView(leading: {
Text("Leading")
}, trailing: {
Text("Trailing")
})
// Modifier-based version:
Text("Leading").split(with: Text("Trailing"))
}
}
}
Просматривая вариант выше, с вызовом на основе вью, легко понять, что наши два текста обернуты в контейнер, и также легко понять, какой из этих двух текстов окажется в ведущем, а какой в последующем вью.
Однако, нельзя сказать то же самое о версии с модификаторами, которая в действительности требует от нас знания того, что вью, к которому мы применяем модификатор, окажется в ведущем слоте. Кроме того, мы не можем определенно сказать, что эти два текста будут вообще обернуты в какой-либо контейнер. Это больше похоже на стилизацию ведущей метки с использованием последующей метки, что на самом деле не так.
Хотя мы могли бы попытаться решить эту проблему ясности с помощью более подробного именования API, основная проблема все равно останется - версия с модификаторами не показывает должным образом, какой в этом случае будет итоговая иерархия вью. Поэтому в ситуациях, когда мы оборачиваем несколько соседних элементов в родительский контейнер, выбор решения на основе вью будет часто давать нам более ясный конечный результат.
С другой стороны, если все, что мы делаем, - это применяем набор стилей к одному вью, то наиболее часто подходящим способом будет реализация этого как расширения "подобного модификатору”, или с использованием соответствующего типа ViewModifier. А для всего, что находится между ними - например, для нашего предыдущего примера "featured label" - все зависит от стиля кода и личных предпочтений, какое решение будет наилучшим для каждого конкретного проекта.
Просто посмотрите, как было разработано встроенное API SwiftUI - контейнеры (такие как HStack и VStack) являются вью, в то время как API-стилизации (например, padding и foregroundColor) реализуются в виде модификаторов. Таким образом, если мы следуем этому же подходу насколько это возможно в наших собственных проектах, то мы, вероятно, получим код UI, который будет согласованным и схожим с самим SwiftUI.
Я надеюсь, что эта статья была для вас интересной и полезной. Если у вас есть какие-либо вопросы, комментарии или отзывы, не стесняйтесь, ищите меня на Mastodon или связывайтесь по email.
Спасибо за чтение!