SwiftUI中Face ID验证通过后如何更新列表选中行?
我有一个List,希望点击contextMenu按钮后更新单个列表项的文本。点击按钮时会更新一个@Published值,我通过onReceive监听该值变化,当值为true时,仅更新长按调出contextMenu并点击按钮的那个列表项的文本。
但目前所有列表项都会被更新,因为ForEach生成的每个元素都绑定了onReceive,每次值变化时每个元素的onReceive都会触发。我理解这是ForEach的特性,但预期是仅更新选中项。
我想复刻Notes应用的行为:长按笔记并点击Lock Note时,仅对选中的笔记应用锁定。我尝试捕获选中索引,但所有列表项的onReceive仍然会被触发。
请问如何定义一个类似onDelete的自定义修改器,或者一个可以接收IndexSet并对指定索引应用所需更改的函数?
import SwiftUI import LocalAuthentication enum BiometricStates { case available case lockedOut case notAvailable case unknown } class BiometricsHandler: ObservableObject { @Published var biometricsAvailable = false @Published var isUnlocked = false private var context = LAContext() private var biometryState = BiometricStates.unknown { didSet { switch biometryState { case .available: self.biometricsAvailable = true case .lockedOut: // self.loginState = .biometryLockout self.biometricsAvailable = false case .notAvailable, .unknown: self.biometricsAvailable = false } } } init() { // self.loginState = .loggedOut checkBiometrics() } private func checkBiometrics() { var evaluationError: NSError? if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &evaluationError) { switch context.biometryType { case .faceID, .touchID: biometryState = .available default: biometryState = .unknown } } else { guard let error = evaluationError else { biometryState = .unknown return } let errorCode = LAError(_nsError: error).code switch(errorCode) { case .biometryNotEnrolled, .biometryNotAvailable: biometryState = .notAvailable case .biometryLockout: biometryState = .lockedOut default: biometryState = .unknown } } } func authenticate() { let context = LAContext() var error: NSError? // check wether biometric authentication is possible if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) { // it's possible, so go ahead and use it let reason = "We need to unlock your data" context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, authenticationError in // authentication has now completed if success { // authenticated successfully Task { @MainActor in self.isUnlocked = true } } else { // there was a problem } } } else { // no biometrics } } } struct Ocean: Identifiable, Equatable { let name: String let id = UUID() var hasPhoto: Bool = false } struct OceanDetails: View { var ocean: Ocean var body: some View { Text("\(ocean.name)") } } struct ContentView: View { @EnvironmentObject var biometricsHandler: BiometricsHandler @State private var oceans = [ Ocean(name: "Pacific"), Ocean(name: "Atlantic"), Ocean(name: "Indian"), Ocean(name: "Southern"), Ocean(name: "Arctic") ] var body: some View { NavigationView { List { ForEach(Array(oceans.enumerated()), id: \.element.id) { (index,ocean) in NavigationLink(destination: OceanDetails(ocean: ocean)) { ocean.hasPhoto ? Text(ocean.name) + Text(Image(systemName: "lock")) : Text("\(ocean.name)") } .contextMenu() { Button(action: { biometricsHandler.authenticate() }) { if ocean.hasPhoto { Label("Remove lock", systemImage: "lock.slash") } else { Label("Lock", systemImage: "lock") } } } .onReceive(biometricsHandler.$isUnlocked) { isUnlocked in if isUnlocked { oceans[index].hasPhoto.toggle() biometricsHandler.isUnlocked = false } } } .onDelete(perform: removeRows) } } } func removeRows(at offsets: IndexSet) { withAnimation { oceans.remove(atOffsets: offsets) } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() .environmentObject(BiometricsHandler()) } }
核心思路
问题根源在于isUnlocked是全局布尔状态,所有列表项的onReceive都会监听它的变化。要实现仅更新选中项,需要在生物认证成功时记录选中项的唯一标识,而非用一个布尔值触发全局更新。
具体修改步骤
修改BiometricsHandler,添加目标项ID状态
替换原有的isUnlocked,新增可选UUID类型的targetItemId,用于存储需要修改的列表项ID:class BiometricsHandler: ObservableObject { @Published var biometricsAvailable = false @Published var targetItemId: UUID? // 存储选中项的唯一ID // ... 原有代码保持不变 ... // 修改认证方法,接收目标项ID func authenticate(for itemId: UUID) { let context = LAContext() var error: NSError? if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) { let reason = "We need to unlock your data" context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, authenticationError in if success { Task { @MainActor in self.targetItemId = itemId // 认证成功后设置目标ID } } } } } }更新ContentView的contextMenu按钮
点击按钮时,将当前列表项的id传递给认证方法:.contextMenu() { Button(action: { biometricsHandler.authenticate(for: ocean.id) // 传递当前项ID }) { if ocean.hasPhoto { Label("Remove lock", systemImage: "lock.slash") } else { Label("Lock", systemImage: "lock") } } }修改onReceive逻辑,仅匹配目标ID时更新
监听targetItemId的变化,只有当前项ID与目标ID匹配时才执行更新,完成后重置状态:.onReceive(biometricsHandler.$targetItemId) { targetId in guard let targetId = targetId, targetId == ocean.id else { return } oceans[index].hasPhoto.toggle() biometricsHandler.targetItemId = nil // 重置状态,避免重复触发 }
CoreData场景适配
如果实际使用CoreData,只需将@State数组替换为@FetchRequest,更新时直接修改对应的NSManagedObject实例即可,核心逻辑保持一致:
// CoreData场景下的更新示例 .onReceive(biometricsHandler.$targetItemId) { targetId in guard let targetId = targetId, targetId == ocean.id else { return } ocean.hasPhoto.toggle() try? viewContext.save() biometricsHandler.targetItemId = nil }
自定义修改器(可选)
如果想封装成类似onDelete的复用组件,可以创建如下View扩展:
extension View { func onLockToggle(handler: BiometricsHandler, itemId: UUID, isLocked: Bool, action: @escaping () -> Void) -> some View { self .contextMenu { Button(action: { handler.authenticate(for: itemId) }) { Label(isLocked ? "Remove lock" : "Lock", systemName: isLocked ? "lock.slash" : "lock") } } .onReceive(handler.$targetItemId) { targetId in if targetId == itemId { action() handler.targetItemId = nil } } } }
使用时简化为:
NavigationLink(destination: OceanDetails(ocean: ocean)) { ocean.hasPhoto ? Text(ocean.name) + Text(Image(systemName: "lock")) : Text("\(ocean.name)") } .onLockToggle(handler: biometricsHandler, itemId: ocean.id, isLocked: ocean.hasPhoto) { oceans[index].hasPhoto.toggle() }
内容的提问来源于stack exchange,提问作者s_diaconu

