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

SwiftUI堆叠卡片视图点击失效问题:仅淡出状态可交互

堆叠卡片视图按钮无法响应点击的问题分析

问题描述

我实现了一个类似iPhone通知交互逻辑的堆叠卡片视图(Stacked Cards View),每个元素仅在淡出(未完全缩放、未完全隐藏)状态下可被点击。当前视图无覆盖层,但正常状态下无法与视图交互,请问为何每个矩形按钮无法响应点击?

可复现代码

import SwiftUI

struct HomeMain2: View {
    var body: some View {
        StackedCardsNew(items: itemsC, stackedDisplayCount: 1, opacityDisplayCount: 1, itemHeight: 70) { item in
            RoundedRectangle(cornerRadius: 25.0)
                .foregroundStyle(.blue)
        }
        .safeAreaPadding(.bottom, 80)
        .safeAreaPadding(15)
    }
}

struct StackedCardsNew<Content: View, Data: RandomAccessCollection>: View where Data.Element: Identifiable {
    var items: Data
    var stackedDisplayCount: Int = 2
    var opacityDisplayCount: Int = 2
    var disablesOpacityEffect: Bool = false
    var spacing: CGFloat = 5
    var itemHeight: CGFloat
    @ViewBuilder var content: (Data.Element) -> Content
    var body: some View {
        GeometryReader {
            let size = $0.size
            let topPadding: CGFloat = size.height - itemHeight
            
            ScrollView(.vertical) {
                VStack(spacing: spacing) {
                    ForEach(items) { item in
                        Button {
                            //can't click this button
                        } label: {
                            content(item)
                        }
                        .frame(height: itemHeight)
                        .visualEffect { content, geometryProxy in
                            content
                                .opacity(disablesOpacityEffect ? 1 : opacity(geometryProxy))
                                .scaleEffect(scale(geometryProxy), anchor: .bottom)
                                .offset(y: offset(geometryProxy))
                        }
                        .zIndex(zIndex(item))
                    }
                }
                .scrollTargetLayout()
            }
            .scrollIndicators(.hidden)
            .safeAreaPadding(.top, topPadding)
        }
    }

    func headerOffset(_ proxy: GeometryProxy, _ topPadding: CGFloat) -> CGFloat {
        let minY = proxy.frame(in: .scrollView(axis: .vertical)).minY
        let viewSize = proxy.size.height - itemHeight
        
        return -minY > (topPadding - viewSize) ? -viewSize : -minY - topPadding
    }
    func zIndex(_ item: Data.Element) -> Double {
        if let index = items.firstIndex(where: { $0.id == item.id }) as? Int {
            return Double(items.count) - Double(index)
        }
        
        return 0
    }
    func offset(_ proxy: GeometryProxy) -> CGFloat {
        let minY = proxy.frame(in: .scrollView(axis: .vertical)).minY
        let progress = minY / itemHeight
        let maxOffset = CGFloat(stackedDisplayCount) * offsetForEachItem
        let offset = max(min(progress * offsetForEachItem, maxOffset), 0)
        
        return minY < 0 ? 0 : -minY + offset
    }
    func scale(_ proxy: GeometryProxy) -> CGFloat {
        let minY = proxy.frame(in: .scrollView(axis: .vertical)).minY
        let progress = minY / itemHeight
        let maxScale = CGFloat(stackedDisplayCount) * scaleForEachItem
        let scale = max(min(progress * scaleForEachItem, maxScale), 0)
        
        return 1 - scale
    }
    func opacity(_ proxy: GeometryProxy) -> CGFloat {
        let minY = proxy.frame(in: .scrollView(axis: .vertical)).minY
        let progress = minY / itemHeight
        let opacityForItem = 1 / CGFloat(opacityDisplayCount + 1)
        
        let maxOpacity = CGFloat(opacityForItem) * CGFloat(opacityDisplayCount + 1)
        let opacity = max(min(progress * opacityForItem, maxOpacity), 0)
        
        return progress < CGFloat(opacityDisplayCount + 1) ? 1 - opacity : 0
    }
    var offsetForEachItem: CGFloat { return 8 }
    var scaleForEachItem: CGFloat { return 0.08 }
}

// IGNORE

struct ItemC: Identifiable{
    var id: UUID = .init()
    var logo: String
    var title: String
    var description: String = "Lorem Ipsum is simply dummy text of the printing and typesetting industry."
}

var itemsC: [ItemC] = [
    ItemC(logo: "", title: ""),
    ItemC(logo: "Amazon", title: "Amazon"),
    ItemC(logo: "Youtube", title: "Youtube"),
    ItemC(logo: "Dribbble", title: "Dribbble"),
    ItemC(logo: "Apple", title: "Apple"),
    ItemC(logo: "Patreon", title: "Patreon"),
    ItemC(logo: "Instagram", title: "Instagram"),
    ItemC(logo: "Netflix", title: "Netflix"),
    ItemC(logo: "Photoshop", title: "Photoshop"),
    ItemC(logo: "Figma", title: "Figma")
]

#Preview {
    HomeMain2()
}

问题原因

  1. zIndex层级颠倒:当前zIndex函数让列表中越靠下的item(index越大)拥有更高的层级数值,导致视觉上被压在下方的卡片,其点击区域反而覆盖在上方卡片之上——SwiftUI的点击测试会优先响应层级更高的视图,哪怕它视觉上不可见或偏移到别处。
  2. 视觉变换不影响点击区域:visualEffect仅修改视图的视觉呈现,不会改变视图的点击测试区域(hit testing frame)。按钮的可点击区域还是原始布局的位置,和视觉上变换后的位置不匹配,导致点击视觉上的卡片时,实际点击的是其他位置的无效区域。

解决方法

1. 修正zIndex层级逻辑

将zIndex设置为与item的index正相关,让视觉上在最上层的卡片拥有最高的层级,确保点击测试优先响应可见的卡片:

func zIndex(_ item: Data.Element) -> Double {
    if let index = items.firstIndex(where: { $0.id == item.id }) as? Int {
        return Double(index) // index越小的item层级越高,对应视觉上的上层卡片
    }
    return 0
}

2. 让点击区域匹配视觉位置

将visualEffect的变换逻辑移到按钮的label内部,并添加.contentShape(Rectangle())确保点击区域跟随可视化后的形状:

ForEach(items) { item in
    Button {
        print("点击了卡片: \(item.title)")
    } label: {
        content(item)
            .frame(height: itemHeight)
            .visualEffect { content, geometryProxy in
                content
                    .opacity(disablesOpacityEffect ? 1 : opacity(geometryProxy))
                    .scaleEffect(scale(geometryProxy), anchor: .bottom)
                    .offset(y: offset(geometryProxy))
            }
    }
    .contentShape(Rectangle()) // 强制点击区域匹配视图的可视化边界
    .zIndex(zIndex(item))
}

3. 可选:优化滚动偏移逻辑

检查offset函数,确保视图的偏移计算正确,避免视觉位置与实际布局位置偏差过大,进一步减少点击区域的错位问题。


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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.20 18:34:51