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

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都会监听它的变化。要实现仅更新选中项,需要在生物认证成功时记录选中项的唯一标识,而非用一个布尔值触发全局更新。

具体修改步骤

  1. 修改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
                        }
                    }
                }
            }
        }
    }
    
  2. 更新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")
            }
        }
    }
    
  3. 修改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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.16 13:55:36