Search code examples
iosswiftswiftuicore-dataface-id

How to update selected row from List after Face ID matched in SwiftUI?


I have a List that I want to update item's text when contextMenu button is tapped.

When the button is tapped a @Published value is updated. I listen to value changes with onReceive and if that value is true the list item where I long pressed to bring the contextMenu and tap the button should update its text.

The issue is that all the items from the list are updated. So onReceive is hit for every element from the list. In one way I understand because elements are populated in ForEach although my expectation was to update only one item.

The behaviour I'm trying to replicate is from Notes app when you long press a Note and tap Lock Note. On that action the lock is applied only for the selected Note.

I tried to capture the selected index but again the onReceive is triggered for every item from the list.

How to define a custom modifier like onDelete that deletes at the right IndexSet or a function that can take the IndexSet and apply the changes I need to that index?

Here is the code I'm trying to solve.

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())
    }
}

This is just a replication from my app. I want to understand how this onReceive is working or if it is a good idea to apply it on ForEach. I tried to move it on List level but I don't have access anymore to index that I get from the loop.

Also would like to mention that in real app the data is being persisted in CoreData but for simplicity I created an array in this exmple.

Any help would be much appreciated.


Solution

  • I managed to do it. I moved onReceive on List level and got the selected item from the list, the one that is tapped for the context menu to show. Set the selected item after the call to authenticate.

    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
            }
        }
    
        func passcodeAuthenticate() {
            let context = LAContext()
            var error: NSError?
        
            // check wether biometric authentication is possible
            if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) {
                // it's possible, so go ahead and use it
                let reason = "Authenticate to access your data"
            
                context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, authenticationError in
                    // authentication has now completed
                    if success {
                        // authenticated successfully
                        DispatchQueue.main.async {
                            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")
        ]
        @State var selectedOcean: Ocean?
        @State var selectedIndex: Int?
        @State var biometricsCalls: Int = 0
    
        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 biometricsHandler.isUnlocked {
                                    biometricsHandler.passcodeAuthenticate()
                                }
                                selectedOcean = ocean
                            }) {
                                if ocean.hasPhoto {
                                    Label("Remove lock", systemImage: "lock.slash")
                                } else {
                                    Label("Lock", systemImage: "lock")
                                }
                            }
                        }
                    }
                    .onDelete(perform: removeRows)
                }
                .onReceive(biometricsHandler.$isUnlocked) { isUnlocked in
                    if isUnlocked {
                        if let index = oceans.firstIndex(where: {$0 == selectedOcean}) {
                            oceans[index].hasPhoto.toggle()
                        }
                    }
                }
            }
        }
    
        func removeRows(at offsets: IndexSet) {
            withAnimation {
                oceans.remove(atOffsets: offsets)
            }
        }
    }
    
    struct ContentView_Previews: PreviewProvider {
        static var previews: some View {
            ContentView()
                .environmentObject(BiometricsHandler())
        }
    }