pointfreeco/swift-composable-architecture

Binding for scrollPosition not working when using ForEach

Closed this issue · 1 comments

Description

Binding for scrollPosition is not working when using ForEach, but it works using ForEachStore.

Checklist

  • I have determined whether this bug is also reproducible in a vanilla SwiftUI project.
  • If possible, I've reproduced the issue using the main branch of this package.
  • This issue hasn't been addressed in an existing GitHub issue or discussion.

Expected behavior

scrollPosition should update binding attribute.

Actual behavior

Binding attribute is not updated.

Steps to reproduce

@Reducer
struct ItemFeature {
    
    @ObservableState
    struct State: Identifiable, Equatable {
        var id: UUID = .init()
    }
    
    enum Action: Equatable {}
    
    var body: some ReducerOf<Self> {
        EmptyReducer()
    }
}

@Reducer
struct ListFeature {
    
    @ObservableState
    struct State: Equatable {
        var items: IdentifiedArrayOf<ItemFeature.State> = .init(uniqueElements: [.init(), .init(), .init(), .init(), .init()])
        var scrolledID: ItemFeature.State.ID?
    }
    
    enum Action: BindableAction, Equatable {
        case binding(BindingAction<State>)
        case items(IdentifiedActionOf<ItemFeature>)
    }
    
    var body: some ReducerOf<Self> {
        BindingReducer()
        Reduce { state, action in
            switch action {
            case .binding(\.scrolledID):
                // Not called.
                return .none
                
            default:
                return .none
            }
        }
        .forEach(\.items, action: \.items, element: ItemFeature.init)
    }
}

struct ListView: View {
    
    @Bindable var store: StoreOf<ListFeature>
    
    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach(store.scope(state: \.items, action: \.items)) { store in
                    Text(store.id.uuidString)
                        .frame(height: 500)
                }
            }
            .scrollTargetLayout()
        }
        .scrollPosition(id: $store.scrolledID)
    }
}

Replace ForEach with ForEachStore and binding for scroll position will work.

The Composable Architecture version information

1.8.2

Destination operating system

iOS 17.2

Xcode version information

15.2

Swift Compiler version information

No response

Hi @Rokas91, this is mentioned in the migration guide for 1.7, but using ForEach with a $store binding uses the child stores for the identity of each row, not the identity of the state in the store. This means your scrollPosition is a completely different type than the underlying identity of the ForEach.

To fix you must use an explicit id key path:

ForEach(
  store.scope(state: \.items, action: \.items),
  id: \.state.id
) { store in
}

Since this is not an issue with the library I am going to convert it to a discussion.