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

SwiftUI实现含Button的List删除及手势冲突等问题排查

问题根因汇总

所有异常都是SwiftUI布局规则、API使用限制和数据逻辑错误共同导致的,不存在玄学问题:

  • 列表不显示:List本身是自带滚动能力的容器,将其嵌套在ScrollView内部时,两个滚动容器会出现互相依赖的高度计算异常,最终List高度被计算为0,自然看不到任何条目。单独测试GetCurrentCats时没有外层ScrollView约束,所以能正常显示。
  • VStack下onDelete不生效:onDelete修饰符仅在ForEach被List/Form包含时,才会触发系统原生左滑删除逻辑,放在VStack、LazyVStack等普通容器里没有任何效果,这是系统API的固有约束。
  • 删除功能失效:ForEach遍历的是过滤后的filteredInUse数组,返回的IndexSet是过滤数组的局部索引,直接拿这个索引去删原始全量数组catItem,索引完全不匹配,要么删错数据要么触发越界崩溃。
  • 数据一致性错误:原有toggleCatShow计算属性会生成全新的UUID,切换分类显示状态时相当于删掉原条目、插入一条ID完全不同的新条目,会导致ForEach视图刷新异常、状态匹配错乱。
  • 手势冲突:List行内直接放Button会抢占行的手势响应优先级,和左滑删除手势产生冲突,容易出现误触。
  • 缺少分类类型标记:现有数据模型没有字段区分系统预设分类和用户自定义分类,无法实现“预设分类不可删除”的需求。

分步修复方案

1. 修复数据层逻辑

先修正数据模型和分类管理类的错误,保证数据一致性:

struct CatModel:  Codable, Identifiable, Hashable {
    var id = UUID()
    var catName: String
    var catTotal: Double
    var catBudget: Double
    var catPix: String
    var catShow: Bool
    var isPreset: Bool = false // 新增字段:标记是否为系统预设分类,用户自定义分类默认值为false

    // 修复:切换状态时保留原有ID,不生成新实例
    mutating func toggleShowState() {
        catShow = !catShow
    }
}

class Categories: ObservableObject {
    @Published var filteredInUse: [CatModel] = []
    @Published var filteredNotInUse: [CatModel] = []
    @Published var catItem: [CatModel] {
        didSet {
            self.save()
            self.updateStatus() // catItem变更时自动刷新过滤数组,无需手动重复调用
        }
    }

    init() {
        // 原有读取本地持久化数据逻辑保留
        if let catItem = UserDefaults.standard.data(forKey: StorageKeys.workCat.rawValue) {
            if let decoded = try? JSONDecoder().decode([CatModel].self, from: catItem) {
                self.catItem = decoded
                self.updateStatus()
                return
            }
        }

        catItem = []
        // 初始化预设分类时,统一标记isPreset为true
        let presetCats = [
            ("Lodging", "bed.double.fill"),
            ("Food", "cart"),
            ("Airplane", "airplane"),
            ("Train", "tram"),
            ("Bus", "bus.fill"),
            ("Ferry", "ferry"),
            ("Local Transit", "textbox"),
            ("Sightseeing", "photo"),
            ("Entertainment", "music.mic"),
            ("Souvenirs", "gift"),
            ("Laundry", "scribble"),
            ("Rental Car", "car"),
            ("Fuel", "fuelpump"),
            ("Parking", "parkingsign.circle")
        ]
        for (name, icon) in presetCats {
            catItem.append(CatModel(catName: name, catTotal: 0.0, catBudget: 0, catPix: icon, catShow: false, isPreset: true))
        }
        updateStatus()
    }

    func updateStatus() {
        filteredInUse = catItem.filter { $0.catShow }
        filteredNotInUse = catItem.filter { !$0.catShow }
    }

    func setCatShow(forId id: UUID) {
        guard let index = catItem.firstIndex(where: { $0.id == id }) else { return }
        catItem[index].toggleShowState() // 直接修改原条目,不生成新ID
    }

    // 新增:修复后的删除方法,自动匹配原数组索引、拦截预设分类删除
    func deleteCategory(from filteredArray: [CatModel], at offsets: IndexSet) {
        for offset in offsets {
            let targetCat = filteredArray[offset]
            // 系统预设分类直接跳过,不允许删除
            guard !targetCat.isPreset else { continue }
            guard let originalIndex = catItem.firstIndex(where: { $0.id == targetCat.id }) else { continue }
            catItem.remove(at: originalIndex)
        }
    }

    // 修复方法名拼写错误,自定义分类默认isPreset为false
    func addNewCategory(catName: String) {
        let item = CatModel(catName: catName, catTotal: 0.0, catBudget: 0, catPix: "person.2", catShow: true)
        catItem.append(item)
    }

    // 原有save持久化方法保留
    func save() {
        // 你的原有本地存储逻辑
    }
}

2. 修复视图层级和手势冲突

删掉包裹GetCurrentCats和GetAvailableCats的外层ScrollView,List本身自带滚动能力,不需要额外套滚动容器,直接给List设置固定高度即可,从根源解决高度计算为0和手势冲突问题。

修复后的GetCurrentCats代码:

struct GetCurrentCats: View {
    var g: GeometryProxy
    @Binding var presentAlert: Bool
    @Binding var presentDetail: String
    @EnvironmentObject var categories: Categories

    var body: some View {
        List {
            ForEach(categories.filteredInUse) { item in
                HStack {
                    Image(systemName: item.catPix)
                        .resizable()
                        .foregroundColor(.orange)
                        .frame(width: 25, height: 25)
                        .frame(width: UIDevice.current.userInterfaceIdiom == .phone ? g.size.width * 0.40 : g.size.width * 0.20)

                    Text(item.catName)
                        .padding(.horizontal, 8)
                        .background(item.catTotal != 0.0 ? Color.red : Color.blue)
                        .foregroundColor(.white)
                        .cornerRadius(8)
                }
                .contentShape(Rectangle()) // 扩大点击响应区域
                .onTapGesture {
                    // 原有点击切换分类的逻辑保留
                    if item.catTotal == 0.0 {
                        withAnimation {
                            categories.setCatShow(forId: item.id)
                        }
                    } else {
                        presentAlert = true
                        presentDetail = item.catName
                    }
                }
                .deleteDisabled(item.isPreset) // 预设分类禁用左滑删除
            }
            .onDelete { offsets in
                // 调用修复后的删除方法,传入当前过滤数组匹配正确索引
                categories.deleteCategory(from: categories.filteredInUse, at: offsets)
            }
        }
        .listStyle(.plain) // 去掉默认List样式的多余边距
        .frame(maxWidth: .infinity, alignment: .leading)
    }
}

对应ContentView里的布局修改,移除包裹List的ScrollView:

// 原有顶部导航、状态变量代码保留
VStack (alignment: .center) {
    Text("Current Categories")
        .font(.headline)
        .frame(maxWidth: .infinity, alignment: .center)
        .padding(.top, 6)
        .padding(.bottom, -3)

    // 删掉外层ScrollView,直接嵌入GetCurrentCats
    GetCurrentCats(g: g, presentAlert: $presentAlert, presentDetail: $presentDetail)
        .frame(width: g.size.width * 0.80, height: g.size.height * 0.35)
        .border(Color(.systemBlue), width: 4)

    // 原有提示文本逻辑保留

    Text("Available Categories")
        .font(.headline)
        .padding(.top, g.size.height * 0.05)
        .padding(.bottom, -3)

    // 同样删掉包裹GetAvailableCats的ScrollView
    GetAvailableCats(g: g)
        .frame(width: g.size.width * 0.80, height: g.size.height * 0.35)
        .border(Color(.systemBlue), width: 4)

    // 原有底部提示、弹窗逻辑保留
}

3. 额外优化点

  • 用onTapGesture替换List行内的Button,配合.contentShape(Rectangle())可以避免Button手势和左滑删除手势的冲突,左滑操作时不会误触分类切换逻辑。
  • 行级修饰符.deleteDisabled(item.isPreset)会让系统自动屏蔽预设分类的左滑删除能力,用户左滑预设分类时不会出现删除按钮。
  • 如果需要更可控的删除交互(比如长按弹出菜单选择删除),可以放弃系统自带的onDelete,自定义交互逻辑,完全规避手势冲突问题。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.09.01 02:48:33