Add or remove an item from a collection (set, array, or dictionairy) with a SwiftUI Toggle

Viewed 470

I'm building a feature that's similar to the "favorites" feature in Apple's Fruta SwiftUI sample app. Users of that app can view a list of smoothies and mark any smoothie as "favorite". The list of favorites is stored and viewable on a separate screen.

The sample view below does two important things:

  1. It checks if the smoothie is already in the list of favorites with isFavorite, so that this can be reflected by the button image.
  2. It adds/removes that smoothie to/from the favorites list with toggleFavorite.

Model:

struct Smoothie: Identifiable, Codable {
    var id: String
    var title: String
    var description: String
    var hasFreeRecipe = false
}

class FrutaModel: ObservableObject {
    @Published private(set) var favoriteSmoothieIDs = Set<Smoothie.ID>()

    func toggleFavorite(smoothie: Smoothie) {
        if favoriteSmoothieIDs.contains(smoothie.id) {
            favoriteSmoothieIDs.remove(smoothie.id)
        } else {
            favoriteSmoothieIDs.insert(smoothie.id)
        }
    }
    
    func isFavorite(smoothie: Smoothie) -> Bool {
        favoriteSmoothieIDs.contains(smoothie.id)
    }
}

Button view that's used to (un)favorite a smoothie:

struct SmoothieFavoriteButton: View {
    @EnvironmentObject private var model: FrutaModel
    
    var smoothie: Smoothie?
    
    var isFavorite: Bool {
        guard let smoothie = smoothie else { return false }
        return model.favoriteSmoothieIDs.contains(smoothie.id)
    }
    
    var body: some View {
        Button(action: toggleFavorite) {
            Label("Favorite", systemImage: isFavorite ? "heart.fill" : "heart")
        }
    }
    
    func toggleFavorite() {
        guard let smoothie = smoothie else { return }
        model.toggleFavorite(smoothie: smoothie)
    }
}

My problem: How can I achieve a similar result when using a Toggle instead of a Button? A Toggle requires a binding as a property, but since the isFavorite value for a particular smoothie comes from a Set (favoriteSmoothieIDs) that contains the full favorites list, I can't simply provide a binding for the Toggle.

1 Answers

This is the required code:

@State var isToggleFavorite = false

Toggle("", isOn: $isToggleFavorite)
            .onChange(of: isToggleFavorite) { value in
                guard let smoothie = smoothie else { return }
                model.toggleFavorite(smoothie: smoothie)
            }

Here I've added the code that answers your request and left the original intact.

struct SmoothieFavoriteButton: View {
    @EnvironmentObject private var model: FrutaModel
    
    var smoothie: Smoothie?

    @State var isToggleFavorite = false // <== Newly Added

    var isFavorite: Bool {
        guard let smoothie = smoothie else { return false }
        return model.favoriteSmoothieIDs.contains(smoothie.id)
    }
    var body: some View {
        Toggle("", isOn: $isToggleFavorite)
            .onChange(of: isToggleFavorite) { value in
                guard let smoothie = smoothie else { return }
                model.toggleFavorite(smoothie: smoothie)
            } // <== Newly Added

        Button(action: toggleFavorite) {
            Label("Favorite", systemImage: isFavorite ? "heart.fill" : "heart")
        }
        .foregroundColor(isFavorite ? .accentColor : nil)
        .accessibility(label: Text("\(isFavorite ? "Remove from" : "Add to") Favorites"))
        } // <== Newly Added
    } 
    
    func toggleFavorite() {
        guard let smoothie = smoothie else { return }
        model.toggleFavorite(smoothie: smoothie)
    }
}

[C]

You could replace the button with a Text object:

 Text(isToggleFavorite ? Image(systemName: "heart.fill"): Image(systemName: "heart"))

But you would then need to persist the isFavorite value.

If having the heart on left side of the toggle is acceptable, you could remove the button entirely and place the heart image within the label of the toggle:

Toggle(isOn: $isToggleFavorite) {
                isToggleFavorite ?
                    Image(systemName: "heart.fill") : Image(systemName: "heart")
            }
            .onChange(of: isToggleFavorite) { value in
                guard let smoothie = smoothie else { return }
                model.toggleFavorite(smoothie: smoothie)
            }

You have a number of options available to you.

Related