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

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

Комментарии (0)