How to use @Binding with TCA?

I have the following NewCategoryView view:

struct NewCategoryView: View {
/// Store
let store: Store<NewCategoryState, NewCategoryAction>
/// ViewStore
@ObservedObject var viewStore: ViewStore<NewCategoryState, NewCategoryAction>

init() {
    self.store = Store(
        initialState: NewCategoryState(),
        reducer: newCategoryReducer,
        environment: { }())
    self.viewStore = ViewStore(self.store)
}

var body: some View {
    NavigationView {
        VStack(alignment: .leading, spacing: 16) {
            // ...
            IconList
        }
        // ...
    }
}

var IconList: some View {
    let rows: Int
    if icons.count % 6 != 0 {
        rows = (icons.count / 6) + 1
    } else {
        rows = (icons.count / 6)
    }
    return VStack(alignment: .leading, spacing: 8) {
        Group {
            Text("Icon")
                //.primaryLabel()
                .padding(.horizontal)
            Text("Wähle ein Icon aus")
                //.primaryInfoLabel()
                .padding(.horizontal)
        }.resignKeyboardOnDragGesture()
        ScrollView(.horizontal, showsIndicators: false) {
            ForEach(0..<6) { y in
                HStack(alignment: .center, spacing: 8) {
                    ForEach(0..<rows) { x in
                        IconButton(
                            index: x * 6 + y,
                            selectedIcon:
                                // THIS DOES NOT WORK?
                                self.viewStore.binding(
                                    get: { $0.selectedIcon },
                                    send: NewCategoryAction.selectedIconChanged
                            ).debug("IconButton")
                        )
                    }
                }.padding(.horizontal)
            }.padding(.vertical, 2)
        }
    }
}

and I want to bind a value from the store to this next IconButton view:

struct IconButton: View {
    /// The index in the icon list (String)
    let index: Int
    /// The name of the selected icon
    @Binding var selectedIcon: String

    var body: some View {
        if index < icons.count {
            let icon: String = icons[index]
            return Button(action: {
                self.selectedIcon = icon
            }) {
                Image(systemName: icon)
                    .foregroundColor(.label)
                    .frame(width: 69, height: 50)
                    //.background(Color.tertiarySystemFill)
                    .overlay(
                        RoundedRectangle(cornerRadius: 8)
                            .stroke(Color.blue, lineWidth: icon == selectedIcon ? 4 : 0)
                    )
                    .cornerRadius(8)
            }.eraseToAnyView()
        } else {
            return
                Spacer()
                    .frame(minWidth: 0, maxWidth: .infinity, minHeight: 50, maxHeight: 50)
                    .eraseToAnyView()
        }
    }
}

public let icons = ["airplane",
"alarm",
"antenna.radiowaves.left.and.right",
"app.gift.fill",
"archivebox.fill" //...
]

The NewCategoryCore:

public enum NewCategoryViewAlert: Int, Identifiable {
    case name = 0
    case icon = 1

    var text: String {
        switch self {
            case .name:
                return "Lege einen Namen fest!"
            case .icon:
                return "Wähle ein Icon aus!"
        }
    }

    public var id: Int {
        return self.rawValue
    }
}

public struct NewCategoryState: Equatable {
    /// The name of the selected icon
    var selectedIcon: String = ""
    /// The name of the new category
    var categoryName: String = ""
    /// The alert of the view
    var alert: NewCategoryViewAlert?
}

enum NewCategoryAction: Equatable {
    case saveSpace
    case categoryNameChanged(String)
    case selectedIconChanged(String)
    case alertChanged(NewCategoryViewAlert?)
}

let newCategoryReducer = Reducer<NewCategoryState, NewCategoryAction, Void> { state, action, _ in
    switch action {
        case .saveSpace:
            // MARK: TODO save
            return .none
        case let .categoryNameChanged(name):
            state.categoryName = name
            return .none
        case let .selectedIconChanged(icon):
            state.selectedIcon = icon
            return .none
        case let .alertChanged(alert):
            state.alert = alert
            return .none
    }
}.debug()

In the console this seams to work:

IconButton: setting bandage.fill to calendar

received action:
NewCategoryAction.selectedIconChanged(
"calendar"
)

NewCategoryState(
− selectedIcon: "bandage.fill",
+ selectedIcon: "calendar",
categoryName: "",
alert: nil
)

... but the IconButton view does not update.
What am I missing?

I'm sorry for my bad english.

/// ViewStore
var viewStore: ViewStore<NewCategoryState, NewCategoryAction>

You shouldn't do that! That's why they created WithViewStore Please take a look at those basic examples and it has everything you are looking for. Like for binding look here

Thanks for your help.
But what if I can’t use WithViewStore?
I tried @ObservedObject var viewStore: ViewStore<State, Action> but that didn’t worked either.
Is there something else I could try?

If the OP is using a GeometryReader, he will need to fall back to that solution. See documentation here.

With that being said, I'm wondering, have you tried manually setting that icon, just to make sure those icons are there and everything is working properly in that department? The binding and state updates seem ok at first glance..

Yes I tried different things:

  • Without TCA and just a @State variable it works fine.
  • (With TCA) I hooked up a TextField to the
    /// The name of the selected icon var selectedIcon: String = "". I entered a valid icon name (the state printed in console changed) but then the IconButton view didn’t update anymore. Also when I pressed an IconButton the state change (in the console) but the TextField did not.

If you can't use WithViewStore, then you definitely need to use @ObservedObject var on the viewStore you hold in your view.

Your code looks fine to me, but unfortunately I can't run it locally because it's not complete. Is it possible to post something that will compile so that I can see what is going wrong?

NewCategoryCore.swift

import Foundation
import ComposableArchitecture

public enum NewCategoryViewAlert: Int, Identifiable {
    case name = 0
    case icon = 1

    var text: String {
        switch self {
            case .name:
                return "Lege einen Namen fest!"
            case .icon:
                return "Wähle ein Icon aus!"
        }
    }

    public var id: Int {
        return self.rawValue
    }
}

public struct NewCategoryState: Equatable {
    /// The name of the selected icon
    var selectedIcon: String = ""
    /// The name of the new category
    var categoryName: String = ""
    /// The alert of the view
    var alert: NewCategoryViewAlert?
}

enum NewCategoryAction: Equatable {
    case saveSpace
    case categoryNameChanged(String)
    case selectedIconChanged(String)
    case alertChanged(NewCategoryViewAlert?)
}

let newCategoryReducer = Reducer<NewCategoryState, NewCategoryAction, Void> { state, action, _ in
    switch action {
        case .saveSpace:
            // MARK: TODO save
            return .none
        case let .categoryNameChanged(name):
            state.categoryName = name
            return .none
        case let .selectedIconChanged(icon):
            state.selectedIcon = icon
            return .none
        case let .alertChanged(alert):
            state.alert = alert
            return .none
    }
}.debug()

NewCategoryView.swift

import SwiftUI
import ComposableArchitecture

struct NewCategoryView: View {
    @Environment(\.presentationMode) var isPresented

    /// Store
    let store: Store<NewCategoryState, NewCategoryAction>
    /// ViewStore
    @ObservedObject var viewStore: ViewStore<NewCategoryState, NewCategoryAction>

    init() {
        self.store = Store(
            initialState: NewCategoryState(),
            reducer: newCategoryReducer,
            environment: { }())
        self.viewStore = ViewStore(self.store)
    }

    var body: some View {
        NavigationView {
            VStack(alignment: .leading, spacing: 16) {
                Group {
                    CategoryName
                    Divider()
                }
                .padding(.horizontal)
                IconList
            }
            .navigationBarTitle("Neue Kategorie", displayMode: .inline)
            .navigationBarItems(trailing: self.trailingItem())
            .bottomButton(buttonTitel: "Hinzufügen") {
                self.save()
            }
            .alert(item: self.viewStore.binding(
                get: { $0.alert },
                send: NewCategoryAction.alertChanged)
            ) { (alert) -> Alert in
                Alert(title: Text(alert.text))
            }
        }
    }

    var CategoryName: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text("Name")
            Text("Gib einen Namen für die Kategorie an")
            TextField("z.B. Lebensmittel & Gastronomie",
                      text: self.viewStore.binding(
                        get: { $0.categoryName },
                        send: NewCategoryAction.categoryNameChanged
                )
            )
            .keyboardType(.alphabet)
            .padding(.horizontal)
            .frame(height: 50, alignment: .center)
            .background(Color.tertiarySystemFill)
            .cornerRadius(8)
        }
    }

    var IconList: some View {
        let rows: Int
        if icons.count % 6 != 0 {
            rows = (icons.count / 6) + 1
        } else {
            rows = (icons.count / 6)
        }
        return VStack(alignment: .leading, spacing: 8) {
            Group {
                Text("Icon")
                    .padding(.horizontal)
                Text("Wähle ein Icon aus")
                    .padding(.horizontal)
            }
            ScrollView(.horizontal, showsIndicators: false) {
                ForEach(0..<6) { y in
                    HStack(alignment: .center, spacing: 8) {
                        ForEach(0..<rows) { x in
                            IconButton(
                                index: x * 6 + y,
                                selectedIcon:
                                    self.viewStore.binding(
                                        get: { $0.selectedIcon },
                                        send: NewCategoryAction.selectedIconChanged
                                )
                            )
                        }
                    }.padding(.horizontal)
                }.padding(.vertical, 2)
            }
        }
    }

    struct IconButton: View {
        /// The index in the icon list (String)
        let index: Int
        /// The name of the selected icon
        @Binding var selectedIcon: String

        var body: some View {
            if index < icons.count {
                let icon: String = icons[index]
                return Button(action: {
                    self.selectedIcon = icon
                }) {
                    Image(systemName: icon)
                        .foregroundColor(.label)
                        .frame(width: 69, height: 50)
                        .background(Color.tertiarySystemFill)
                        .overlay(
                            RoundedRectangle(cornerRadius: 8)
                                .stroke(Color.blue, lineWidth: icon == selectedIcon ? 4 : 0)
                        )
                        .cornerRadius(8)
                }.eraseToAnyView()
            } else {
                return
                    Spacer()
                        .frame(minWidth: 0, maxWidth: .infinity, minHeight: 50, maxHeight: 50)
                        .eraseToAnyView()
            }
        }
    }

    private func save() {
        self.isPresented.wrappedValue.dismiss()
    }

    private func trailingItem() -> some View {
        return Button(action: {
            self.isPresented.wrappedValue.dismiss()
        }) {
            Text("Abbrechen")
        }
    }
}

struct NewCategoryView_Previews: PreviewProvider {
    static var previews: some View {
        NewCategoryView()
    }
}

public static let tertiarySystemFill: Color = Color(UIColor.tertiarySystemFill)

extension View {
    func eraseToAnyView() -> AnyView {
        AnyView( self )
    }
}

public let icons = ["airplane",
    "alarm",
    "antenna.radiowaves.left.and.right",
    "app.gift.fill",
    "archivebox.fill",
    "bag.fill",
    "bandage.fill",
    "barcode",
    "bed.double.fill",
    "bell.fill",
    "book.fill",
    "briefcase.fill",
    "calendar",
    "camera.on.rectangle.fill",
    "car.fill",
    "cart.fill",
    "creditcard.fill",
    "cube.box.fill",
    "desktopcomputer",
    "doc.fill",
    "doc.text.fill",
    "envelope.fill",
    "envelope.open.fill",
    "eyeglasses",
    "film",
    "gamecontroller.fill",
    "gear",
    "gift.fill",
    "globe",
    "guitars",
    "hammer.fill",
    "hare.fill",
    "headphones",
    "heart.fill",
    "hifispeaker.fill",
    "house.fill",
    "map.fill",
    "mic.fill",
    "music.mic",
    "music.note.list",
    "paintbrush.fill",
    "paperclip",
    "paperplane.fill",
    "pencil",
    "pencil.and.outline",
    "person.2.square.stack.fill",
    "person.crop.rectangle.fill",
    "phone.fill",
    "photo.fill",
    "printer.fill",
    "shield.fill",
    "signature",
    "snow",
    "speedometer",
    "sportscourt.fill",
    "stopwatch.fill",
    "studentdesk",
    "tag.fill",
    "thermometer",
    "tram.fill",
    "trash.fill",
    "tv.fill",
    "umbrella.fill",
    "wrench.fill",
    "zzz"]

Hope that helps.

The binding in your code seems to execute just fine. When I tap a button on the screen I get this in the logs:

received action:
  NewCategoryAction.selectedIconChanged(
    "film"
  )
  NewCategoryState(
−   selectedIcon: "calendar",
+   selectedIcon: "film",
    categoryName: "Test",
    alert: nil
  )

So it's definitely sending an action and updating the state.

Do you expect something else to happen?

Yes the actions are fine. I could see that too:

But the blue border of the IconButton
.overlay( RoundedRectangle(cornerRadius: 8) .stroke(Color.blue, lineWidth: icon == selectedIcon ? 4 : 0) )
is not displayed when the respective button is pressed.

If you give the selectedIcon variable in the state a string from the icons e.g. :

/// The name of the selected icon
var selectedIcon: String = "airplane"

You can see the border on the IconButton. And if you press an other IconButton, the blue border stays on the airplane IconButton. But the state changes correctly.

It seems like the get methode is not working correctly.

self.viewStore.binding(
    get: { $0.selectedIcon },
    send: NewCategoryAction.selectedIconChanged
)

Because the wrapped value of the @Binding var selectedIcon: String variable stays the same.

Strangely enough, using WithViewStore seems to give you the behavior you're looking for:

WithViewStore(self.store) { viewStore in
  IconButton(
    index: x * 6 + y,
    selectedIcon: viewStore.binding(
      get: { $0.selectedIcon },
      send: NewCategoryAction.selectedIconChanged
    )
  )
}

We've encountered a lot of strange Observ{able,ed}Object bugs while working with SwiftUI, so I wonder if this is another one. Someone else may have a better explanation, though!

1 Like

TL;DR: This is definitely a SwiftUI bug and not TCA-specific. (Filed feedback FB7709705.)


We just spent a lil more time looking at this and found a couple interesting things:

  1. It appears to be a bug with @ObservedObject. If you swap out ViewStore for a simple view model you will see the exact same behavior:

    class ViewModel: ObservableObject {
      @Published var selectedIcon = ""
    }
    ...
    struct NewCategoryView: View {
      @ObservedObject var viewModel = ViewModel()
      ...
        IconButton(
          index: x * 6 + y,
          selectedIcon: self.$viewModel.selectedIcon
        )
    

    We're not quite sure why @State works around this, but we've seen similar bugs in the past where @State works but @ObservedObject does not.

  2. We were able to whittle away at the view to find a minimal repro of this bug (or something like it).

    If you run the following preview, the button should toggle its foreground and background color when tapped, which are view modifiers driven by @ObservedObject and @State, but tapping does nothing. Meanwhile, if you comment out the ForEach, things will work as expected. We're not sure if this is the same bug, but the behavior is definitely the same. What's strange is that this affects @State, as well.

    import Foundation
    import SwiftUI
    
    class ViewModel: ObservableObject {
      @Published var isSelected = false
    }
    
    struct MyView: View {
      @ObservedObject var viewModel = ViewModel()
      @State var isSelected = false
    
      var body: some View {
        // Comment out the `ForEach` and things will work.
        ForEach(0..<1) { _ in
          Button(action: {
            self.viewModel.isSelected.toggle()
            self.isSelected.toggle()
          }) {
            Text("Hello")
              .padding()
              .foregroundColor(self.viewModel.isSelected ? Color.white : nil)
              .background(self.isSelected ? Color.red : nil)
          }
        }
      }
    }
    
    struct MyView_Previews: PreviewProvider {
      static var previews: some View {
        MyView()
      }
    }
    
  3. Also interestingly, WithViewStore provides a nice workaround in TCA :laughing:

1 Like

Thanks for the support.!
I found a similar bug with binding in navigation. But this appears only when using TCA.

import SwiftUI
import ComposableArchitecture


struct ParentState: Equatable {
    var child: ChildState = .init()
    
    struct ChildState: Equatable {
        var isChildViewPresented: Bool = false
    }
}

enum ParentAction: Equatable {
    case setNavigationChildView(isActive: Bool)
    case currencies(ChildAction)
    
    enum ChildAction: Equatable {
        case dismissChildView
    }
}

let childReducer = Reducer<ParentState.ChildState, ParentAction.ChildAction, Void> { state, action, _ in
    switch action {
        case let setNavigationCurrencyView:
            state.isChildViewPresented = false
            return .none
    }
}

let parentReducer: Reducer<ParentState, ParentAction, Void> = .combine(
    Reducer { state, action, environment in
        switch action {
            case let .setNavigationChildView(isActive: active):
                state.child.isChildViewPresented = active
                return .none
            case .currencies:
                return .none
        }
    }.debug(),
    childReducer.pullback(
        state: \ParentState.child,
        action: /ParentAction.currencies,
        environment: { _ in () }
    )
)

struct ParentView: View {
    let store: Store<ParentState, ParentAction>
    // With @ObservedObject it will work
    //@ObservedObject var viewStore: ViewStore<ParentState, ParentAction>
    var viewStore: ViewStore<ParentState, ParentAction>
    
    init() {
        self.store = Store(
            initialState: ParentState(),
            reducer: parentReducer,
            environment: {}())
        self.viewStore = ViewStore(store)
    }
    
    var body: some View {
        NavigationView {
            // WithViewStore it will also work
            //WithViewStore(self.store) { viewStore in
                NavigationLink(
                    destination: Text("Hello"),
                    isActive: self.viewStore.binding(
                        get: { $0.child.isChildViewPresented },
                        send: ParentAction.setNavigationChildView(isActive:)
                    )
                ) {
                    Text("Next View")
                }
            //}
        }
    }
}

struct ParentView_Previews: PreviewProvider {
    static var previews: some View {
        ParentView()
    }
}

This time it works fine with @ObservedObject and @State.

class ViewModel: ObservableObject {
    @Published var isSelected = false
}

struct MyView: View {
    @ObservedObject var viewModel = ViewModel()
    @State var isSelected = false
    
    var body: some View {
        NavigationView {
            VStack {
                NavigationLink(destination: Text("Hello"), isActive: $viewModel.isSelected) {
                    Text("Next View @ObservedObject")
                }
                NavigationLink(destination: Text("Hello"), isActive: $isSelected) {
                    Text("Next View @State")
                }
            }
        }
    }
}

struct MyView_Previews: PreviewProvider {
    static var previews: some View {
        MyView()
    }
}

@moebius Not sure I understand. Your comments seem to imply that it works when you observe the view store or use WithViewStore, right?

Any view that holds onto a view store directly must be marked @ObservedObject for the view to observe when state changes. WithViewStore does this under the hood for you as a convenience.

Yes of course, my mistake.

1 Like

No worries :smile: The vanilla SwiftUI code would likewise break if you removed @ObservedObject from it.

Terms of Service

Privacy Policy

Cookie Policy