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

SwiftUI中ForEach列表选择功能失效问题求助

SwiftUI中ForEach列表选择功能失效问题

在SwiftUI中使用List(selection: $selection)配合ForEach渲染Core Data的FetchedResults<Manufacturer>时,点击列表行无任何反应,但.onDelete功能正常。期望实现点击厂商行时,行内显示勾选标记,同时将selection变量设置为对应的Manufacturer对象。(注:此烟草相关应用仅用于学习,清楚无法上架App Store)


核心代码片段

@FetchRequest(fetchRequest: ContentView.manufacturerByName)
var manufacturers:FetchedResults<Manufacturer>

@State private var selection: Manufacturer?

Section("Manufacturer") {
    List(selection: $selection) {
        ForEach(manufacturers, id:\.self) { manufacturer in
            Text(manufacturer.name ?? "--")
        }
        .onDelete { rows in
            for row in rows {
                let manufacturer = manufacturers[row]
                storageProvider.deleteManufacturer(manufacturer)
            }
        }
    }
}

完整工程代码

MyApp.swift

@main
struct MyApp: App {
    let storageProvider = StorageProvider.standard
    
    var body: some Scene {
        WindowGroup {
            ContentView(storageProvider: storageProvider)
                .environment(\.managedObjectContext, storageProvider.persistentContainer.viewContext)
        }
    }
}

ContentView.swift

import SwiftUI
import CodeScanner

struct ContentView: View {
    @Environment(\.dismiss) var dismiss
    
    let storageProvider: StorageProvider
    
    // Tobacco
    @State var tobaccoName: String = ""
    @State var tobaccoNr: String? = nil
    @State var ean: String? = nil
    
    // EAN
    @State private var isPresentingScanner = false
    @State private var scannedCode: String?
    
    // Manufacturers
    @FetchRequest(fetchRequest: ContentView.manufacturerByName)
    var manufacturers: FetchedResults<Manufacturer>
    
    @StateObject var searchText = SearchText()
    @State private var selection: Manufacturer?
    
    var body: some View {
        Form {
            Section("Tobacco Details") {
                TextField("Name", text: $tobaccoName)
                TextField("Number (e.g. #017)", text: $tobaccoNr ?? "")
                    .keyboardType(.decimalPad)
                HStack {
                    TextField("ean", text: $ean ?? "")
                        .keyboardType(.numberPad)
                    Button {
                        isPresentingScanner.toggle()
                    } label: {
                        Image(systemName: "camera")
                    }
                }
            }
            Text(selection?.name ?? "no selection")
            Button {
                storageProvider.saveManufacturer(named: "Test")
            } label: {
                Text("create manufacturer")
            }
            Section("Manufacturer") {
                List(selection: $selection) {
                    ForEach(manufacturers, id:\.self) { manufacturer in
                        Label(manufacturer.name ?? "--",
                              systemImage: selection == manufacturer ? "checkmark" : "circle")
                    }
                    .onDelete { rows in
                        for row in rows {
                            let manufacturer = manufacturers[row]
                        }
                    }
                }
            }
        }
        .navigationTitle("add Tobacco")
        .navigationBarTitleDisplayMode(.inline)
        
        // MARK: - EAN-Scan Sheet
        .sheet(isPresented: $isPresentingScanner) {
            CodeScannerView(codeTypes: [.ean13], showViewfinder: true) { response in
                switch response {
                case .success(let result): do {
                    ean = result.string
                    isPresentingScanner = false
                }
                case .failure(let error):
                    print(error.localizedDescription)
                }
            }
            .presentationDetents([.medium])
        }
        
        // MARK: - Bottom Button
        .safeAreaInset(edge: .bottom) {
            VStack {
                Button {
                    if selection == nil {
                        print("no manufacturer")
                    } else {
                        storageProvider.saveTabacco(
                            named: tobaccoName,
                            number: tobaccoNr,
                            ean: ean,
                            manufacturer: selection
                        )
                        dismiss()
                    }
                } label: {
                    Text("save Tobacco")
                        .font(.callout)
                        .frame(maxWidth: .infinity, minHeight: 44)
                }
                .buttonStyle(.borderedProminent)
                .contentTransition(.identity)
            }
            .frame(maxWidth: .infinity)
            .padding([.horizontal, .top])
            .background(.ultraThinMaterial)
        }
        
        // MARK: - Manufacturer Search-Filter
        .onReceive(searchText.$debounced) { query in
            guard !query.isEmpty else {
                manufacturers.nsPredicate = nil
                return
            }
            manufacturers.nsPredicate = NSPredicate(format: "%K CONTAINS[cd] %@", argumentArray: [#keyPath(Manufacturer.name), query])
        }
    }
}

func ??<T>(lhs: Binding<Optional<T>>, rhs: T) -> Binding<T> {
    Binding(
        get: { lhs.wrappedValue ?? rhs },
        set: { lhs.wrappedValue = $0 }
    )
}

StorageProvider.swift

public class StorageProvider {
    
    public static var standard = StorageProvider()
    public let persistentContainer: NSPersistentContainer
    
    public init() {
        persistentContainer = NSPersistentContainer(name: "DataModel")
        
        persistentContainer.loadPersistentStores(completionHandler: { description, error in
            if let error = error {
                fatalError("Core Data store failed to load with error: \(error)")
            }
        })
    }
}

// MARK: - Functions to call from the Views
public extension StorageProvider {
    func saveTabacco(named name: String, number: String?, ean: String?, manufacturer: Manufacturer?) {
        let tabacco = Tabacco(context: persistentContainer.viewContext)
        tabacco.name = name
        tabacco.number = number
        tabacco.ean = ean
        tabacco.manufacturer = manufacturer
        tabacco.creationDate = Date.now
        
        do {
            try persistentContainer.viewContext.save()
            print("Tabacco saved succesfully")
        } catch {
            persistentContainer.viewContext.rollback()
            print("Failed to save Tabacco: \(error)")
        }
    }

    func saveManufacturer(named name: String) {
        let manufacturer = Manufacturer(context: persistentContainer.viewContext)
        manufacturer.name = name
        
        do {
            try persistentContainer.viewContext.save()
            print("Manufacturer saved succesfully")
        } catch {
            persistentContainer.viewContext.rollback()
            print("Failed to save Manufacturer: \(error)")
        }
    }

    func getAllTabaccos() -> [Tabacco] {
    let fetchRequest: NSFetchRequest<Tabacco> = Tabacco.fetchRequest()

    do {
      return try persistentContainer.viewContext.fetch(fetchRequest)
    } catch {
      print("Failed to fetch tabacco: \(error)")
      return []
    }
  }

  func deleteTabacco(_ tabacco: Tabacco) {
      persistentContainer.viewContext.delete(tabacco)
      
      do {
          try persistentContainer.viewContext.save()
      } catch {
          persistentContainer.viewContext.rollback()
          print("Failed to save context: \(error)")
      }
  }
    
    func deleteManufacturer(_ manufacturer: Manufacturer) {
        persistentContainer.viewContext.delete(manufacturer)
        
        do {
            try persistentContainer.viewContext.save()
        } catch {
            persistentContainer.viewContext.rollback()
            print("Failed to save context: \(error)")
        }
    }
    
    func updateTabacco(_ tabacco: Tabacco) {
        do {
            try persistentContainer.viewContext.save()
        } catch {
            persistentContainer.viewContext.rollback()
            print("Failed to save context: \(error)")
        }
    }
}

// MARK: - FetchRequest

extension ContentView {
    static var tabaccosByName: NSFetchRequest<Tabacco> {
        let request: NSFetchRequest<Tabacco> = Tabacco.fetchRequest()
        request.sortDescriptors = [NSSortDescriptor(keyPath: \Tabacco.name, ascending: true)]
        return request
    }
    
    static var manufacturerByName: NSFetchRequest<Manufacturer> {
        let request: NSFetchRequest<Manufacturer> = Manufacturer.fetchRequest()
        request.sortDescriptors = [NSSortDescriptor(keyPath: \Manufacturer.name, ascending: true)]
        return request
    }
}

临时解决方案

Section("Manufacturer") {
    List() {
        TextField("search for manufacturer", text: $searchText.text)
        
        if manufacturers.isEmpty {
            if searchText.debounced != "" {
                NavigationLink(destination: CreateManuView(storageProvider: storageProvider, manuName: searchText.text)) {
                    Label("add **\(searchText.text)**", systemImage: "plus")
                        .tint(.accentColor)
                }
            } else {
                Text("search for a manufacturer to add")
                    .foregroundColor(.gray)
            }
        }
        
        ForEach(manufacturers) { manufacturer in
            Button(action: {
                selection = manufacturer
            }, label: {
                HStack {
                    Text(manufacturer.name ?? "--")
                        .tint(.primary)
                    Spacer()
                    if manufacturer == selection {
                        Image(systemName: "checkmark")
                    }
                }
            })
        }
        .onDelete { rows in
            for row in rows {
                let manufacturer = manufacturers[row]
                storageProvider.deleteManufacturer(manufacturer)
            }
        }
    }
}

问题原因分析

  1. List选择模式限制:SwiftUI的List(selection:)默认适配多选场景(需配合EditButton触发编辑模式),在iOS的Form内嵌List中,原生选择交互会被Form的上下文逻辑覆盖,导致点击无响应。
  2. Core Data对象相等性问题:使用id:\.self时,Core Data的NSManagedObject子类默认通过内存地址判断相等性,但FetchedResults更新时对象可能被重新实例化,导致selection == manufacturer判断失效。
  3. Form与List交互冲突:Form的单元格交互逻辑会拦截点击事件,阻止List的选择逻辑触发。

优化解决方法

方法1:启用原生List选择模式

保留List(selection:)的原生样式,需开启编辑模式并修正Core Data对象标识:

Section("Manufacturer") {
    List(selection: $selection) {
        ForEach(manufacturers, id: \.objectID) { manufacturer in
            Text(manufacturer.name ?? "--")
        }
        .onDelete { rows in
            for row in rows {
                let manufacturer = manufacturers[row]
                storageProvider.deleteManufacturer(manufacturer)
            }
        }
    }
    .listStyle(.plain)
    .toolbar {
        EditButton()
    }
}
  • 使用id: \.objectID替代id:\.self,objectID是Core Data对象的唯一持久化标识,不受实例化变化影响。
  • 添加EditButton开启编辑模式,原生List选择需在编辑状态下触发。
  • 设置.listStyle(.plain)避免Form的默认样式干扰交互。

方法2:自定义单选交互(更灵活)

无需依赖原生选择模式,直接实现自定义点击逻辑:

Section("Manufacturer") {
    List {
        ForEach(manufacturers, id: \.objectID) { manufacturer in
            HStack {
                Text(manufacturer.name ?? "--")
                Spacer()
                if selection?.objectID == manufacturer.objectID {
                    Image(systemName: "checkmark")
                        .foregroundColor(.accentColor)
                }
            }
            .contentShape(Rectangle())
            .onTapGesture {
                selection = manufacturer
            }
        }
        .onDelete { rows in
            for row in rows {
                let manufacturer = manufacturers[row]
                storageProvider.deleteManufacturer(manufacturer)
            }
        }
    }
}
  • 用objectID进行相等性判断,避免Core Data对象实例变化导致的判断错误。
  • 添加.contentShape(Rectangle())确保整行区域可响应点击。
  • 使用.onTapGesture替代Button,避免破坏Form单元格的默认外观。

内容的提问来源于stack exchange,提问作者luegm.dev

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.30 04:38:01