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