Search code examples
swiftbuttonswiftuitoggleonchange

How to avoid Toggle view recursive onChange call SwiftUI


HStack{
  ForEach($rs.newsCategoryCollection){ $category in 
    Toggle(category.id, isOn: $category.isSelected)
      .toggleStyle(.button)
      .onChange(of: category.isSelected) {value in
          print("Changed")
          if value{
              rs.unselectOtherCategory(id: category.id)
              print("Selected: \(category.id)")
              viewModel.loadNews()
          }else{
              category.isSelected = true
              print("Deselected: \(category.id)")
          }
      }
  }
}

Here I am trying to have toggle button in horizontal view. I Want this to be used as selectors to select only one category at a time.

So after user selects any one of the category the other category should be deselected using this method: rs.unselectOtherCategory(id: category.id)

So I am using onChange to detect user selecting a category by tracking the change of value. But using the previous method to deselect all other categories changes the value of a previously selected category and called the onChange again.

The problem gets more complecated when I use the else block to make sure If the user tap on a selected button and deselect which makes all the button deselected, the else block can counter it and select the only button selected.

How can I avoid this repeated call of onChange.

I want to the user to be able to tap on a button and select it, other buttons will be deselected. But If an user taps on a button that is already selected the button will be remain selected.


Solution

  • This looks more like a Picker than a group of Toggles. Don't use Toggles when you want one thing to be selected at a time.

    You can change the Toggles to Buttons:

    HStack{
        ForEach($rs.newsCategoryCollection){ $category in
            Button(category.id) {
                if !category.isSelected {
                    category.isSelected = true
                    rs.unselectOtherCategory(id: category.id)
                    viewModel.loadNews()
                }
            }
            .padding(8)
            .background(.accent.opacity(category.isSelected ? 0.1 : 0), in: RoundedRectangle(cornerRadius: 10))
        }
    }
    

    That said, I would suggest using Picker, with .segmented picker style, which looks similar to a row of buttons.

    struct ContentView: View {
        
        @State var rs = ...
    
        @State var selectedCategory: NewsCategory.ID = "" // assuming NewsCategory.ID is a String
    
        var body: some View {
            
            Picker(selection: $selectedCategory) {
                ForEach(rs.newsCategoryCollection){ category in
                    Text(category.id)
                }
            } label: {
                
            }
            .pickerStyle(.segmented)
            .onChange(of: selectedCategory) {
                viewModel.loadNews()
            }
        }
    }
    

    Now you don't even need an isSelected property in NewsCategory. If you really need it for some reason, you can add a selectCategory method to rs, e.g.

    // selectCategory could be implemented as:
    mutating func selectCategory(id: String) {
        for i in newsCategoryCollection.indices {
            if newsCategoryCollection[i].id != id {
                newsCategoryCollection[i].isSelected = false
            } else {
                newsCategoryCollection[i].isSelected = true
            }
        }
    }
    

    Then call this in onChange.