Привет, Хабр!

Недавно у меня было собеседование, на котором мы довольно много времени обсуждали одну из фундаментальных концепций SwiftUI — View.

На первый взгляд вопрос кажется простым: что такое View?

Но очень быстро этот вопрос приводит к более интересным темам. Почему body может возвращать разные представления в зависимости от условия, хотя в случае с обычным some Protocol мы, как правило, не можем возвращать разные конкретные типы из разных веток?

Спойлер: View — это протокол. Но не совсем обычный.

Мы также обсуждали, что на самом деле происходит в SwiftUI при изменении состояния. Перерисовывает ли SwiftUI весь экран? Создаёт ли заново все элементы интерфейса? Или под капотом происходит что-то более тонкое?

В этой статье я хочу шаг за шагом разобрать эти вопросы и объяснить — или хотя бы попытаться объяснить, — что на самом деле представляет собой SwiftUI.View, как работает some View, почему важен ViewBuilder и каким образом SwiftUI обновляет интерфейс при изменении состояния. Я также добавила небольшую визуальную шпаргалку с ключевыми идеями из статьи, чтобы позже можно было быстро освежить основные моменты.

Давайте разберёмся во всём по порядку.

Что такое View?

В SwiftUI View — это протокол. На первый взгляд всё кажется довольно просто: если тип соответствует протоколу View, значит его можно использовать в качестве представления SwiftUI. Но View — это не совсем обычный протокол. Это протокол с ассоциированным типом (Protocol with Associated Type, или PAT).

Если упростить его объявление, оно будет выглядеть примерно так:

public protocol View {
    associatedtype Body: View

    @ViewBuilder
    var body: Self.Body { get }
}

Самое важное здесь — вот эта строка:

associatedtype Body: View

Это означает, что каждый тип, соответствующий протоколу View, должен описывать, из чего состоит его body.

Например:

struct MyView: View {
    var body: some View {
        Text("Hello")
    }
}

MyView соответствует протоколу View, но само представление, которое будет отображено на экране, определяется его свойством body.

В данном случае body возвращает Text. Поэтому SwiftUI-представление можно воспринимать как легковесное значение, которое описывает другое представление.

View — это не обязательно реальный UI-элемент, уже находящийся на экране. Скорее, это описание того, как интерфейс должен выглядеть.

В этом и заключается одна из ключевых идей SwiftUI:

View — это описание интерфейса, а не конечный отрисованный UI-объект.

Но подождите… почему тогда в body работает if/else?

Теперь мы знаем, что body возвращает some View.

И мы также знаем, что some View не означает «любое представление». Это означает: один конкретный тип, соответствующий протоколу View, но скрытый от нас.

И здесь может возникнуть закономерный вопрос:

Подождите. Но мы ведь постоянно пишем такой код:

struct MyView: View {
    let isLoggedIn: Bool
     var body: some View {
        if isLoggedIn {
            Text("Welcome back!")
        } else {
            Button("Log in") {
                print("Login tapped")
            }
        }
    }
}

Примечание - тут я не пытаюсь писать идеальный продуктовый код, тут я пишу такой код, который наглядно продемонстрирует суть рассматриваемой проблемы :)

Это явно разные типы. Но разве мы только что не сказали, что some View должен представлять один конкретный тип? Чтобы лучше увидеть проблему, попробуем реализовать ту же идею вне SwiftUI:

protocol Animal {
    func makeSound()
}

struct Dog: Animal {
    func makeSound() {
        print("Woof")
    }
}
struct Cat: Animal {
    func makeSound() {
        print("Meow")
    }
}

func makeAnimal(_ isDog: Bool) -> some Animal {
    if isDog {
        Dog()
    } else {
        Cat()
    }
}

Этот код не скомпилируется

Причина в том, что some Animal — это непрозрачный возвращаемый тип. Он позволяет скрыть конкретный тип, но во всех ветках этот тип всё равно должен оставаться одним и тем же. Поэтому такой код недопустим:

true  branch -> Dog
false branch -> Cat

Потому что Dog и Cat — это разные конкретные типы. Но тогда почему аналогичный пример в SwiftUI работает?

Ответ - @ViewBuilder

body — это не просто обычное свойство, возвращающее some View.

В SwiftUI body вычисляется в специальном контексте result builder. Если упростить объявление протокола View, оно будет выглядеть примерно так:

public protocol View {
    associatedtype Body: View

    @ViewBuilder
    var body: Self.Body { get }
}

В более новых интерфейсах SwiftUI это также можно встретить в виде @ContentBuilder, где ContentBuilder — это typealias для ViewBuilder. Именно этот builder и меняет всё.

ViewBuilder — это result builder. Он преобразует код внутри body в один итоговый тип представления.

Поэтому, когда мы пишем:

var body: some View {
    if isLoggedIn {
        Text("Welcome back!")
    } else {
        Button("Log in") {}
    }
}

SwiftUI не возвращает напрямую ни Text, ни Button.

Вместо этого ViewBuilder преобразует обе ветки в один конкретный тип «под капотом».

Концептуально это можно представить примерно так:

_ConditionalContent<Text, Button<Text>>

Обычно мы не пишем этот тип вручную и даже не задумываемся о нём. Но здесь важно понять главное:

Text и Button — это разные конкретные типы. Однако ViewBuilder оборачивает их в единый результирующий тип, который тоже соответствует протоколу View.

Поэтому body по-прежнему удовлетворяет требованиям some View: он возвращает один конкретный тип. Просто это уже не Text или Button напрямую, а сгенерированный ViewBuilder тип-обёртка.

Тот же самый принцип работает и тогда, когда мы объявляем сразу несколько дочерних представлений:

var body: some View {
    Text("Title")
    Text("Subtitle")
}

На первый взгляд кажется, что мы возвращаем сразу два представления.

Но ViewBuilder объединяет их в один результирующий тип представления. Исторически для этого использовались такие типы, как TupleView.

Именно здесь кроется ключевая идея:

Сам по себе some View не позволяет возвращать разные типы. Это делает @ViewBuilder.

Или, если быть точнее, @ViewBuilder преобразует разные ветки условий и несколько дочерних представлений в один конкретный тип View, который затем и возвращается как some View.


Эта статья получилась немного длиннее, чем я планировала, поэтому на этом пока остановимся.

Мы разобрали:

  • что такое View в SwiftUI;

  • почему some View не означает «любой View»;

  • как ViewBuilder позволяет SwiftUI сохранять один конкретный возвращаемый тип, даже если мы используем условные конструкции или несколько дочерних представлений.

Но осталась ещё одна важная тема — дерево представлений (rendering tree) и то, что именно SwiftUI обновляет при изменении состояния. Об этом я подробнее расскажу в следующей статье.

А если вы хотите глубже разобраться в теме уже сейчас, ниже я оставлю ссылки на официальную документацию Apple.

https://developer.apple.com/documentation/swiftui/view?source=-----45160a184041---------------------------------------

https://developer.apple.com/documentation/swiftui/view/body-8kl5o?source=-----45160a184041---------------------------------------