Search code examples
arraysswiftuiforeachtoggle

SwiftUI: Toggle for individual items of ForEach


Using ForEach, I want to create individual Toggles for each row. Right now, the @State binding toggles all of the items at the same time, and I can't figure out how to separate them.

In the code below, I put a hard-coded array, but it really comes from an ever-changing .json file. Therefore, I need the ForEach and the binding to be dynamic.

This post on hiding List items and this post on problems with List rows were helpful, but I couldn't make the binding work for my project. I'm on day 2 trying to figure this out, and none of what I've found online addresses this specific question.

Below is a small example of my code that reproduces my challenge. The dynamic data from the array comes from a .json file.

import SwiftUI

struct GreekWords: Codable, Hashable {
    var greekWordArray = ["Alpha", "Beta", "Gamma", "Delta", "Epsilon", "Zeta"]
    // The array data comes from a dynamic .json file
}

struct ContentView: View {
    var greekWords: GreekWords
    
    @State private var wordToggle = false
    
    var body: some View {
        VStack(spacing: 0) {
            ForEach(greekWords.greekWordArray, id: \.self) { word in
                Toggle(word, isOn: $wordToggle)
            }
        }
        .padding(.horizontal)
    }
}

I expect this is a simple solution, so I thank you in advance for any help. Also, I would appreciate any direction you might point me to better learn SwiftUI. I've tried all the Apple tutorials and books and the 100 days of SwiftUI on HackingWithSwift.

Cheers!


Solution

  • In your example code, all toggles are referencing to the same variable. So of course all toggles will always show the same state.

    In the example implementation in the link you provided, it is not just an array of strings, it is an array of objects, that also contain a bool variable to control that specific item by a toggle.

    UPDATE (2):

    Maybe the following approach is more what you expected. Sorry, that I didn't thought about it last night. But please keep in mind, the var for the toggle state is only available in that view, you can show the status in that view, but you can't really work with it. If you want to (re-)use that information, I'd rather take the alternative from last night (see below).

    //
    //  GreekWordTest.swift
    //  GreekWordTest
    //
    //  Created by Sebastian on 15.08.22.
    //
    
    import SwiftUI
    
    struct GreekWords: Codable, Hashable {
        var greekWordArray = ["Alpha", "Beta", "Gamma", "Delta", "Epsilon", "Zeta"]
        // The array data comes from a dynamic .json file
    }
    
    struct ContentView: View {
        var greekWords: GreekWords
        
        var body: some View {
            VStack(spacing: 0) {
                ForEach(greekWords.greekWordArray, id: \.self) { word in
                    GreekWordToggleView(greekWord: word)
                        .padding()
                }
            }
            .padding(.horizontal)
        }
    }
    
    struct GreekWordToggleView: View {
        
        var greekWord: String
        @State private var wordToggle = false
        
        var body: some View {
            VStack(spacing: 0) {
                Toggle(greekWord, isOn: $wordToggle)
            }
            .padding(.horizontal)
        }
    }
    

    And here the screenshot:

    Individual toggles for each row

    ALTERNATIVE:

    The approach from last night

    //
    //  GreekWordTest.swift
    //  GreekWordTest
    //
    //  Created by Sebastian on 14.08.22.
    //
    
    import SwiftUI
    
    struct ContentView: View {
        
        @StateObject var greekWordsViewModel = GreekWordsViewModel()
        
        var body: some View {
            VStack() {
                GreekWordView(greekWordsViewModel: greekWordsViewModel)
            }
            // For this test I am fetching the data once in the beginning when ContentView apears the first time, later I also added a button to fetch it again, it'll overwrite the existing data. You can also add a logic just to update it, that is up to you and your needs.
            .onAppear(){
                greekWordsViewModel.fetchData()
            }
        }
    }
    
    
    struct GreekWordView: View {
        @ObservedObject var greekWordsViewModel: GreekWordsViewModel
        
        var body: some View {
            VStack(){
                
                
                ForEach(greekWordsViewModel.greekWordArray.indices, id: \.self){ id in
                    Toggle(greekWordsViewModel.greekWordArray[id].name, isOn: $greekWordsViewModel.greekWordArray[id].isOn)
                        .padding()
                }
                
                // Here is the extra button to (re-)fetch the data from the json.
                Button(action: {
                    greekWordsViewModel.fetchData()
                }) {
                    Text("Fetch Data")
                }
                .padding()
            }
        }
    }
    
    struct GreekWord: Identifiable, Hashable  {
        var id: String = UUID().uuidString
        var name: String
        var isOn: Bool
    }
    
    class GreekWordsViewModel: ObservableObject {
        
        @Published var greekWordArray: [GreekWord] = []
        
        func fetchData(){
            // As mentioned above, in  his example I empty the array on each new loading event. You can also implement a logic to just update the data.
            greekWordArray = []
            
            let greekWords: [String] = load("greekWordsData.json")
            for greekWord in greekWords {
                greekWordArray.append(GreekWord(name: greekWord, isOn: false))
            }
        }
    }
    

    For decoding the json, I used the following:

    //
    //  ModelData.swift
    //  SwiftTest
    //
    //  Created by Sebastian Fox on 14.08.22.
    //
    
    import Foundation
    
    // This function is used to decode a file with a json. I guess you already created something that is decoding a json according to your need, of course you can still use it. 
    func load<T: Decodable>(_ filename: String) -> T {
        let data: Data
    
        guard let file = Bundle.main.url(forResource: filename, withExtension: nil)
        else {
            fatalError("Couldn't find \(filename) in main bundle.")
        }
    
        do {
            data = try Data(contentsOf: file)
        } catch {
            fatalError("Couldn't load \(filename) from main bundle:\n\(error)")
        }
    
        do {
            let decoder = JSONDecoder()
            return try decoder.decode(T.self, from: data)
        } catch {
            fatalError("Couldn't parse \(filename) as \(T.self):\n\(error)")
        }
    }
    

    And finally for testing, I used a very simple greekWordsData.json file that just contains:

    ["Alpha", "Beta", "Delta", "Gamma", "Epsilon", "Zeta"]
    

    Here a screenshot:

    Alternative: Individual toggles for each row

    Best, Sebastian