You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

SwiftUI实现双列卡片网格:点击卡片平滑全屏展开

实现iOS风格的网格卡片全屏展开动画

问题描述

需要实现双列滚动网格的笔记视图,点击任意卡片时,卡片从原位置平滑全屏展开显示完整内容,类似Safari标签页或文件应用的动画效果。尝试两种方案均失败:

  • 直接用withAnimation修改卡片尺寸:卡片受限于LazyVGrid的布局约束,无法突破网格范围展开。
  • 使用matchedGeometryEffect:展开视图从左上角出现,而非原卡片位置,动画不符合预期。

错误分析

  1. matchedGeometryEffect的ID冲突:所有卡片共用同一个"card" ID,导致系统无法识别具体触发动画的卡片,出现位置偏移。
  2. 网格布局的约束限制:嵌套在LazyVGrid中的视图无法突破网格布局框架,直接修改尺寸会被规则限制,无法实现全屏效果。
  3. 状态管理不当:单个布尔值cardExpanded无法区分多个卡片的选中状态,无法对应具体的展开视图。

正确实现方案

以下是完整的可运行代码,实现符合预期的展开动画:

数据模型定义

struct Note: Identifiable {
    let id = UUID()
    let title: String
    let content: String
    let color: Color
}

核心视图实现

struct NoteCardView: View {
    let note: Note
    let namespace: Namespace.ID
    var isSelected: Bool
    
    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text(note.title)
                .font(.headline)
                .foregroundColor(.white)
            Text(note.content)
                .font(.caption)
                .foregroundColor(.white.opacity(0.8))
                .lineLimit(2)
        }
        .padding(16)
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(note.color)
        .cornerRadius(12)
        .matchedGeometryEffect(id: note.id, in: namespace, properties: .frame)
        .opacity(isSelected ? 0 : 1) // 选中时隐藏原卡片,避免重叠
    }
}

struct ExpandedNoteView: View {
    let note: Note
    let namespace: Namespace.ID
    let onDismiss: () -> Void
    
    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 16) {
                Text(note.title)
                    .font(.title)
                    .foregroundColor(.white)
                Text(note.content)
                    .font(.body)
                    .foregroundColor(.white.opacity(0.9))
            }
            .padding(24)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(note.color)
        .matchedGeometryEffect(id: note.id, in: namespace, properties: .frame)
        .onTapGesture(perform: onDismiss)
    }
}

struct NotesGridView: View {
    @State private var selectedNote: Note?
    @Namespace private var animationNamespace
    
    // 模拟笔记数据
    private let notes: [Note] = [
        Note(title: "笔记标题1", content: "这是第一条笔记的完整内容,包含多行文本,可以滚动查看。", color: .blue),
        Note(title: "笔记标题2", content: "这是第二条笔记的完整内容,测试长文本展示效果。", color: .green),
        Note(title: "笔记标题3", content: "第三条笔记内容,用于测试网格布局和动画效果。", color: .orange),
        Note(title: "笔记标题4", content: "第四条笔记,验证多个卡片的展开动画是否正常。", color: .purple),
        Note(title: "笔记标题5", content: "第五条笔记内容,确保动画的平滑过渡。", color: .pink),
        Note(title: "笔记标题6", content: "第六条笔记,测试全屏展开后的滚动功能。", color: .teal)
    ]
    
    private let columns = [
        GridItem(.flexible(), spacing: 16),
        GridItem(.flexible(), spacing: 16)
    ]
    
    var body: some View {
        ZStack {
            // 网格视图
            ScrollView {
                LazyVGrid(columns: columns, spacing: 16) {
                    ForEach(notes) { note in
                        NoteCardView(
                            note: note,
                            namespace: animationNamespace,
                            isSelected: selectedNote?.id == note.id
                        )
                        .onTapGesture {
                            withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) {
                                selectedNote = note
                            }
                        }
                    }
                }
                .padding(16)
            }
            .disabled(selectedNote != nil) // 展开时禁用滚动
            
            // 全屏展开视图
            if let selectedNote = selectedNote {
                ExpandedNoteView(
                    note: selectedNote,
                    namespace: animationNamespace,
                    onDismiss: {
                        withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) {
                            self.selectedNote = nil
                        }
                    }
                )
                .zIndex(1)
            }
        }
    }
}

关键实现要点

  • 唯一的动画ID:每个卡片使用note.id作为matchedGeometryEffect的ID,确保系统能准确识别动画对应的视图。
  • 脱离网格约束:展开视图放在ZStack的顶层,不嵌套在LazyVGrid中,避免被网格布局限制尺寸。
  • 坐标空间一致性:默认使用全局坐标空间,确保动画时视图的位置计算准确,避免偏移。
  • 状态管理:用可选类型selectedNote跟踪选中的卡片,支持多个卡片的展开/收起操作。
  • 交互控制:展开时禁用滚动视图的交互,防止用户在动画过程中触发滚动,影响体验。
  • 动画曲线:使用spring动画曲线,模拟iOS原生应用的弹性效果,更贴近系统风格。

内容的提问来源于stack exchange,提问作者Seeker

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.02 02:07:27