SwiftUI вью против модификаторов

Одним из наиболее интересных аспектов 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: View {     @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: View {     @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: View {     @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.

Спасибо за чтение!

Подписывайся на наши соцсети: Telegram / VKontakte

Вступай в открытый чат для iOS-разработчиков: t.me/swiftbook_chat

11
Начать дискуссию