SwiftUIMiddleTechnical

Что такое matchedGeometryEffect и как он используется?

matchedGeometryEffect синхронизирует позицию и размер двух View с одинаковым id в одном @Namespace, создавая плавный shared-element переход — карточка «летит» в детальный экран и обратно.

matchedGeometryEffect в SwiftUI

matchedGeometryEffect — модификатор, который синхронизирует геометрию (позицию и размер) двух View в разных частях иерархии. SwiftUI интерполирует фрейм одного View к фрейму другого, создавая плавный shared-element transition — переход, при котором элемент «летит» из одного места в другое.

Как это работает

Модификатор требует два параметра:

  • id — уникальный идентификатор пары View;
  • in — пространство имён (@Namespace), которое связывает пару вместе.

Пространство имён создаётся как @Namespace private var animation и передаётся в оба View. SwiftUI находит View с одинаковыми id в одном пространстве имён и анимирует переход между ними.

Пример: карточка, раскрывающаяся в детальный экран

struct Item: Identifiable {
    let id: Int
    let title: String
    let color: Color
}

struct HeroTransitionView: View {
    @Namespace private var animation
    @State private var selectedItem: Item? = nil

    let items: [Item] = [
        Item(id: 1, title: "Swift", color: .orange),
        Item(id: 2, title: "Python", color: .blue),
        Item(id: 3, title: "Kotlin", color: .purple)
    ]

    var body: some View {
        ZStack {
            // Сетка карточек
            ScrollView {
                LazyVGrid(columns: [GridItem(.adaptive(minimum: 150))]) {
                    ForEach(items) { item in
                        if selectedItem?.id != item.id {
                            CardView(item: item)
                                .matchedGeometryEffect(id: item.id, in: animation)
                                .onTapGesture {
                                    withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
                                        selectedItem = item
                                    }
                                }
                        }
                    }
                }
                .padding()
            }

            // Детальный экран
            if let item = selectedItem {
                DetailView(item: item)
                    .matchedGeometryEffect(id: item.id, in: animation)
                    .ignoresSafeArea()
                    .onTapGesture {
                        withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
                            selectedItem = nil
                        }
                    }
            }
        }
    }
}

struct CardView: View {
    let item: Item
    var body: some View {
        item.color
            .frame(height: 100)
            .cornerRadius(12)
            .overlay(Text(item.title).foregroundColor(.white).bold())
    }
}

struct DetailView: View {
    let item: Item
    var body: some View {
        item.color
            .overlay(
                VStack {
                    Text(item.title)
                        .font(.largeTitle.bold())
                        .foregroundColor(.white)
                    Text("Нажмите, чтобы закрыть")
                        .foregroundColor(.white.opacity(0.7))
                }
            )
    }
}

Параметр isSource

По умолчанию оба View являются источником геометрии. Параметр isSource: false говорит SwiftUI, что данный View должен принять геометрию от парного View, а не отдавать свою:

// View-источник определяет итоговую позицию
Text("Hero")
    .matchedGeometryEffect(id: "hero", in: ns, isSource: true)

// View-получатель адаптируется под позицию источника
Text("Hero")
    .matchedGeometryEffect(id: "hero", in: ns, isSource: false)

Анимация табов

struct TabSelector: View {
    @Namespace private var tabAnimation
    @State private var activeTab = 0
    let tabs = ["Работа", "Компании", "Профиль"]

    var body: some View {
        HStack {
            ForEach(tabs.indices, id: \.self) { index in
                Button(tabs[index]) {
                    withAnimation(.easeInOut(duration: 0.2)) {
                        activeTab = index
                    }
                }
                .padding(.vertical, 8)
                .padding(.horizontal, 16)
                .background {
                    if activeTab == index {
                        Capsule()
                            .fill(Color.accentColor)
                            .matchedGeometryEffect(id: "tab-indicator", in: tabAnimation)
                    }
                }
                .foregroundColor(activeTab == index ? .white : .primary)
            }
        }
        .padding(4)
        .background(Color(.systemGray6), in: Capsule())
    }
}

Подводные камни

  • Оба View должны существовать одновременно в иерархии — если оба View скрыты или только один из них рендерится, анимация не произойдёт; используйте opacity(0) вместо условного исключения из иерархии.
  • @Namespace должен принадлежать общему родителю — нельзя передавать Namespace между независимыми экранами через NavigationStack; для этого нужно поднять состояние выше.
  • Конфликт нескольких View с одинаковым id — два View с одинаковым id в одном namespace вызывают предупреждение и непредсказуемое поведение анимации.
  • Не работает через NavigationStack без дополнительных усилий — стандартный push-transition перекрывает matchedGeometryEffect; нужен кастомный контейнер через ZStack.
  • Производительность при большом числе пар — каждая пара с одинаковым namespace участвует в layout pass; при десятках пар это заметно нагружает рендер.
  • withAnimation обязателен — без обёртки изменений в withAnimation переход произойдёт мгновенно без интерполяции геометрии.
  • Clip-контейнеры обрезают анимацию — если родительский View имеет .clipped() или .cornerRadius() без .clipShape, анимирующийся View будет обрезан по границе родителя.

Common mistakes

  • Сводить «matchedGeometryEffect и как он используется» к синтаксису и не объяснять environment.
  • Игнорировать жизненный цикл, основной поток или момент освобождения ресурсов в сценарии swiftui-13.
  • Выбирать API по привычке, не проверяя состояние, ошибки, доступность и платформенные ограничения.

What the interviewer is testing

  • Формулирует точную модель для «matchedGeometryEffect и как он используется» и подтверждает ее корректным примером.
  • Умеет связать ответ с main actor, тестированием и отладкой на устройстве.
  • Называет ограничения подхода swiftui-13, включая производительность, память и сопровождение.

Sources

Related topics