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

07 марта 2023

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

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

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

Содержание