SwiftUI实现双列卡片网格:点击卡片平滑全屏展开
实现iOS风格的网格卡片全屏展开动画
问题描述
需要实现双列滚动网格的笔记视图,点击任意卡片时,卡片从原位置平滑全屏展开显示完整内容,类似Safari标签页或文件应用的动画效果。尝试两种方案均失败:
- 直接用
withAnimation修改卡片尺寸:卡片受限于LazyVGrid的布局约束,无法突破网格范围展开。 - 使用
matchedGeometryEffect:展开视图从左上角出现,而非原卡片位置,动画不符合预期。
错误分析
matchedGeometryEffect的ID冲突:所有卡片共用同一个"card"ID,导致系统无法识别具体触发动画的卡片,出现位置偏移。- 网格布局的约束限制:嵌套在
LazyVGrid中的视图无法突破网格布局框架,直接修改尺寸会被规则限制,无法实现全屏效果。 - 状态管理不当:单个布尔值
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
相关产品推荐
相关产品推荐

