Концепция идентификации (identity) в SwiftUI не так проста, как могло бы показаться на первый взгляд. Один из лучших способов разобраться в ней — понять роль идентичности в переходах (transitions).

В SwiftUI идентификация является ключом к пониманию работы системы рендеринга. Идентификатор View говорит SwiftUI о том, какие из значений этого View соответствуют одному и тому же отрисованному представлению на протяжении его жизненного цикла. Поэтому идентификация сильно влияет на корректность, производительность и, как мы увидим, на переходы.

Типы идентификаций

Как мы узнали в демистификации SwiftUI, в SwiftUI существует два подхода к понимания идентификации:

  • структурная идентификация (structural identity), которая неявно связана с тем, как структурирован наш код. Она статически выводится системой типов.

  • явная идентификация (explicit identity), которая может быть указана с помощью модификатора (.id()), и дает нам полный контроль над идентификацией во время выполнения.

Идентификация и переходы

Переходы в значительной степени опираются на идентификацию: всякий раз, когда значение View имеет новый идентификатор, это значение будет рассматриваться как вставка, а предыдущее значение — как удаление. Если возымеет эффект какая-нибудь анимация, то переход будет анимирован.

Структурная идентификация

В простейшем случае при структурной идентификации с течением времени не происходит никакого изменения идентификатора. В приведенном ниже коде ValueView всегда является "тем же самым" представлением, даже если value меняется. Хотя при каждом нажатии кнопки формируются различные значения ValueView, идентификатор всех этих значений остается неизменным. А если идентификатор значений остается неизменным, то в дереве представлений не будет вставки или удаления, а значит, не будет и перехода (код):

Код всех примеров можно посмотреть в этом репозитории.

ValueView(value: value)
    // Этот переход не возымеет эффекта.
    // Система типов всегда рассматривает приведенное представление как как одно и то же.
    .transition(transition)

// где-то в другом месте в иерархии представлений:
Button {
    withAnimation {
        value += 1
    }
}

Переходы ортогональны анимациям. Анимации определяются внутри представления (здесь — ValueView) и происходят при изменении размера, положения, цвета (и т.д.) его дочерних элементов. Эти анимации не имеют прямого отношения к идентификатору (identity). На видео выше наблюдается небольшая анимация при изменении размера текста внутри ValueView, наиболее заметно между 0 и 1, поскольку мы используем пропорциональный шрифт.

Но мы можем инициировать переход, используя только структурную идентификацию. Ниже мы порождаем различные ValueViews в каждой из ветвей блока if. Поэтому при каждом изменении условия мы провоцируем переход. В данном случае это произойдет при первом нажатии, когда value изменится с 0 на 1. (Мы здесь опустили код кнопки, потому что он остается прежним)(код):

Group {
    if value > 0 {
        ValueView(value: value)
    } else {
        ValueView(value: value)
    }
}
.transition(transition)

Но построение переходов с использованием структурной идентификации не является ни очевидным, ни масштабируемым решением. Поэтому лучше воспользуемся явной идентификацией.

Явная идентификация

Явная идентификация задается с помощью .id(someMeaningfulId).

Если мы зададим представлению ValueView явный идентификатор с помощью .id(value), то тем самым мы сообщим SwiftUI, что для каждого нового значения нужно отображать новое представление, и таким образом мы будем вызывать переход (код):

 ValueView(value: value)
    .id(value)
    .transition(transition)

Стоит отметить, что тот факт, что параметр ValueViewValueView(value: value)) меняется, не имеет никакого отношения к переходу. У нас был бы такой же переход, если бы мы передавали константу, а не value, но при этом присваивали бы ее динамическому id:

 ValueView(value: 1000)
    .id(value)
    .transition(transition)

Или, наоборот, мы можем управлять идентификацией более грубо. Теперь переход будет происходить только один раз при изменении value с 3 на 4:

 ValueView(value: value)
    .id(value > 3 ? 1 : 0)
    .transition(transition)

Основная идея заключается в том, что при явной идентификации родительский объект получает возможность контролировать момент изменения идентификатора представления с помощью модификатора id(), что дает возможность управлять переходами.

Явная идентификация — это именно то, почему такие представления, как ForEach и List, требуют, чтобы их элементы соответствовали Identifiable. Это позволяет этим представлениям внутренне обеспечивать стабильный идентификатор для каждого элемента представления. Если идентификатор неверен, то при изменении списка элементов переходы будут выглядеть некорректно. Чаще всего такое некорректное поведение происходит, если в качестве идентификатора используется просто индекс коллекции.

Более сложный пример

В Memorize, моем приложении для работы с флэш-картами, я хотел, чтобы анимации переворота и перелистывания были органичной частью учебного взаимодействия:

CardView отображает текст для каждой стороны: вопроса и ответа. Мне нужно было сделать переворот между вопросом и ответом, а затем переместиться со стороны в ответом на одной карточке на лицевую сторону следующей карточки.

В оставшейся части этой статьи описаны два подхода, с помощью которых я пытался добиться этого эффекта.

Анимация и переход

(код)

Один из способов достижения этой цели — анимация для переворота и переход для перелистывания.

Переворот будет являться внутренней анимацией CardView (и не имеющей отношения к идентификаторам и переходам).

struct CardView: View {
    let card: Card
    let side: Card.Side

    func sideView(side: Card.Side) -> some View {
        // Различные цвета лицевой и задней поверхностей
        // ...
    }

    var body: some View {
        ZStack {
            sideView(side: .front)
                .rotation3DEffect(.degrees(side == .front ? 0 : 180), axis: (0, 1, 0))
                .opacity(side == .front ? 1 : 0)
            sideView(side: .back)
                .rotation3DEffect(.degrees(side == .back ? 0 : -180), axis: (0, 1, 0))
                .opacity(side == .back ? 1 : 0)
        }
    }
}

// Содержится в другом представлении, которое управляет анимацией:

struct FlipAndSlideAnimationPlusTransition: View {
    @State var side = Card.Side.front

    var body: some View {
        CardView(card: card, side: side)
            .onTapGesture {
                withAnimation(animation) {
                    side = .back
                }
            }
    }
}

Обе стороны отображаются вместе в ZStack. Представление текущей стороны непрозрачно и обращено вперед, в то время как другая сторона перевернута на 180° и прозрачна. CardView содержится в контейнерном представлении, которое управляет поворотом сторон  по жесту касания. В результате мы получаем анимацию переворачивания благодаря эффекту rotation3DEffect и анимации прозрачности.

Опять же, перехода пока нет, только анимация.

Давайте спровоцируем переход: он будет представлять из себя перелистывание к следующей карточке. Для этого мы явно укажем идентификатор в виде card.id. Это означает, что при смене карточки мы хотим получить переходы вставки и удаления. Это вполне логично и легко в реализации:

CardView(card: card, side: side)
    .id(card.id)
    .transition(transition)

где переход:

var transition: AnyTransition {
    .asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))
}

Только переход

(код)

Но (и в этом заключается контроль, который дает вам идентификация) мы можем удалить анимацию переворота из внутренних компонентов CardView и выполнять и переворот, и перелистывание как часть перехода, управляемого родительским представлением.

Итак, сначала мы удалим из CardView представления сторон, чтобы не было ничего, что можно было бы анимировать. Он просто отображает текущую сторону, которая ему задана:

CardViewWithoutAnimations {
    let side: Card.Side

    var body: some View {
        sideView(side: side)
    }
}

Наиболее важным моментом является то, что нам необходимо изменить id на что-то такое, что будет меняться каждый раз, когда меняется сторона карточки и когда меняется id карты:

CardViewWithoutAnimations(card: card, side: side)
    // в идеале мы должны использовать уникальный хэш
    // для каждой комбинации карточки и стороны. Но для примера достаточно и +.
    .id(card.id + side.id)

Затем мы изменим переход от родительского представления на нечто более сложное:

struct FlipAndSlideOnlyTransitionsView: View {
    let side: Card.Side
    
    var body: some View {
        // ...
        CardViewWithoutAnimations(card: card, side: side)
            .id(card.id + side.id)
            .transition(transition)
        // ...
    }

    var fullTransition: AnyTransition {
        if side == .front {
            return .asymmetric(
                insertion: .move(edge: .trailing),
                removal: .flip(direction: 1).combined(with: .opacity)
            )
        } else {
            return .asymmetric(
                insertion: .flip(direction: -1).combined(with: .opacity),
                removal: .move(edge: .leading)
            )
        }
    }
}

(здесь .flip — это пользовательский переход, использующий исходный эффект rotation3DEffect из первого примера).

Когда текущей стороной является .front:

  • вставка означает, что мы вводим новую карточку, поэтому мы вводим карточку с края экрана

  • удаление означает, что мы переворачиваем карточку с передней на заднюю сторону

А когда текущей стороной является .back:

  • вставка означает переворачивание с передней на заднюю сторону

  • удаление означает, что мы удаляем текущую карточку

Идентификация — это ключ

Идентификация является ключевым моментом для правильной реализации переходов. Но понимание самой концепции identity имеет более фундаментальное значение. В некотором смысле работа с переходами дала мне гораздо более четкое представление о том, что означает идентификация, особенно явная идентификация, и какие манипуляции с ее помощью можно проворачивать.

Статья подготовлена в рамках курса "iOS Developer. Professional".

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