Navigation Issue - Bindings + Sheet

Hi. I created a navigation in my application. However, when I try to modify the state within the view hierarchy, it does not work.

I created the simplest example I could, which shows the problem.

public enum Route: Hashable {
    case sheet
    case link
}

public struct AppState: Equatable {
        
    @BindableState var route: Route?
    
    public var myNumber: String = ""
    public var myName: String = ""
    
    public var sheetState: SheetState {
        get {
            .init(
                myNumber: self.myNumber
            )
        }
        set {
            self.myNumber = newValue.myNumber
        }
    }
}

public enum AppAction: Equatable, BindableAction {
    case sheetButtonTapped
    case sheet(SheetAction)
    case binding(BindingAction<AppState>)
}

public struct AppEnvironment { ยทยทยท }

public let appReducer = Reducer<AppState, AppAction, AppEnvironment>.combine(
    sheetReducer.pullback(
        state: \AppState.sheetState,
        action: /AppAction.sheet,
        environment: { _ in SheetEnvironment(mainQueue: .main) }
    ),
    Reducer { state, action, environment in
        
        switch action {
        case .sheetButtonTapped:
            state.route = .sheet
            return .none
            
        case .sheet(_):
            return .none
            
        case .binding(_):
            return .none
        }
    }
    .binding()
)
.debug()

struct AppView: View {
    
    let store: Store<AppState, AppAction>
    
    var body: some View {
        WithViewStore(self.store) { viewStore in
            VStack(alignment: .leading, spacing: 24) {
                Text("Number: \(viewStore.myNumber)")
                Text("Name: \(viewStore.myName)")
                
                Button("Sheet") {
                    viewStore.send(.sheetButtonTapped, animation: .default)
                }
                .sheet(unwrapping: viewStore.binding(\.$route), case: /Route.sheet) { _ in
                    NavigationView {
                        SheetView(store: self.store.scope(
                            state: \.sheetState,
                            action: AppAction.sheet
                        ))
                    }
                }
            }
        }
    }
}

public struct SheetState: Equatable {
    
    @BindableState var myNumber: String = ""
    public var name: String = ""
    
    public var linkState: LinkState {
        get {
            .init(
                name: self.name
            )
        }
        set {
            self.name = newValue.name
        }
    }
}

public enum SheetAction: Equatable, BindableAction {
    case link(LinkAction)
    case binding(BindingAction<SheetState>)
}

public struct SheetEnvironment { ยทยทยท }

public let sheetReducer = Reducer<SheetState, SheetAction, SheetEnvironment>.combine(
    linkReducer.pullback(
        state: \SheetState.linkState,
        action: /SheetAction.link,
        environment: { _ in LinkEnvironment(mainQueue: .main) }
    ),
    Reducer { state, action, environment in
        switch action {
        case .binding(_):
            return .none
        case .link(_):
            return .none
        }
    }.binding()
)

struct SheetView: View {
    
    let store: Store<SheetState, SheetAction>
    
    var body: some View {
        WithViewStore(self.store) { viewStore in
            VStack(alignment: .leading, spacing: 24) {
                TextField("Type here the number...", text: viewStore.binding(\.$myNumber))
                    .keyboardType(.numberPad)
                
                Text("The name is: \(viewStore.name)")
                
                NavigationLink {
                    LinkView(store: self.store.scope(
                        state: \.linkState,
                        action: SheetAction.link
                    ))
                } label: {
                    Text("Push")
                }.padding(.top, 44)
            }
            .padding(.horizontal, 20)
        }
    }
}

public struct LinkState: Equatable {
    @BindableState var name: String = ""
}

public enum LinkAction: Equatable, BindableAction {
    case binding(BindingAction<LinkState>)
}

public struct LinkEnvironment { ยทยทยท }

public let linkReducer = Reducer<LinkState, LinkAction, LinkEnvironment> { state, action, environment in
    switch action {
    case .binding(_):
        return .none
    }
}.binding()
.debug()

struct LinkView: View {
    
    let store: Store<LinkState, LinkAction>
    
    var body: some View {
        WithViewStore(self.store) { viewStore in
            TextField("Type here the name...", text: viewStore.binding(\.$name)) // It doesn't work
            .padding(.horizontal, 20)
        }
    }
}

The binding in the LinkView doesn't work. The view is created again but I don't know why. What am I doing wrong?

Download the example

Hi @torcelly, the problem is most likely due to you observing way too much state in your views. When using WithViewStore(self.store) you are saying that the view should be re-computed anytime anything is changed in state (including child features), and that can trip up SwiftUI when dealing with navigation for some reason.

I recommend reading our article on performance and try whittling down the observed state to its essentials to see if it helps you out.

1 Like

Thanks @mbrandonw I'm going to read your article on performance. Honestly, I didn't quite understand why to use the ViewState approach. I think I'm starting to get it now. I will work on it and share the findings here so that it will be useful to others in the future.

Thanks again and congratulations on the great work you do with stephen

Thanks again @mbrandonw. I read the article and also reviewed the "Tic Tac Toe Bindings" example.

Applying the ViewState and ViewAction helpers, I avoid reloading the entire view now and it works perfectly. I share here the changes made in the example to help other users:

public struct AppState: Equatable {
        
    @BindableState var route: Route?
    
    public var myNumber: String = ""
    public var myName: String = ""
    
    public var sheetState: SheetState {
        get {
            .init(
                myNumber: self.myNumber,
                myName: self.myName
            )
        }
        set {
            self.myNumber = newValue.myNumber
            self.myName   = newValue.myName
        }
    }
    
    init(route: Route? = nil, myNumber: String, myName: String) {
        self.myNumber = myNumber
        self.myName   = myName
    }
}

public enum AppAction: Equatable, BindableAction {
    case sheetButtonTapped
    case numberChanged(String)
    case nameChanged(String)
    case routeChanged(Route?)
    case sheet(SheetAction)
    case binding(BindingAction<AppState>)
}

public struct AppEnvironment {
    public var mainQueue: AnySchedulerOf<DispatchQueue>
    
    public init(mainQueue: AnySchedulerOf<DispatchQueue>) {
        self.mainQueue = mainQueue
    }
}

public let appReducer = Reducer<AppState, AppAction, AppEnvironment>.combine(
    sheetReducer.pullback(
        state: \AppState.sheetState,
        action: /AppAction.sheet,
        environment: { _ in SheetEnvironment(mainQueue: .main) }
    ),
    Reducer { state, action, environment in
        
        switch action {
        case .sheetButtonTapped:
            state.route = .sheet
            return .none
            
        case .sheet(_):
            return .none
            
        case .binding(_):
            return .none
            
        case let .numberChanged(number):
            state.myNumber = number
            return .none
            
        case let .nameChanged(name):
            state.myName = name
            return .none
            
        case let .routeChanged(route):
            state.route = route
            return .none
        }
    }
    .binding()
)
.debug()


struct AppView: View {
    
    let store: Store<AppState, AppAction>
    
    struct ViewState: Equatable {
        
        var route: Route?
        var number: String
        var name: String
        
        init(state: AppState) {
            self.route  = state.route
            self.number = state.myNumber
            self.name   = state.myName
        }
    }
    
    enum ViewAction {
        case numberChanged(String)
        case nameChanged(String)
        case routeChanged(Route?)
        case sheetButtonTapped
    }
    
    var body: some View {
        WithViewStore(
            self.store.scope(state: ViewState.init, action: AppAction.init)
        ) { viewStore in
            VStack(alignment: .leading, spacing: 24) {
                Text("Number: \(viewStore.number)")
                Text("Name: \(viewStore.name)")
                
                Button("Sheet") {
                    viewStore.send(.sheetButtonTapped, animation: .default)
                }
                .sheet(unwrapping: viewStore.binding(get: \.route, send: ViewAction.routeChanged), case: /Route.sheet) { _ in
                    NavigationView {
                        SheetView(store: self.store.scope(
                            state: \.sheetState,
                            action: AppAction.sheet
                        ))
                    }
                }
            }
        }
    }
}

extension AppAction {
    init(action: AppView.ViewAction) {
        switch action {
        case let .numberChanged(number):
            self = .numberChanged(number)
        case let .nameChanged(name):
            self = .nameChanged(name)
        case .sheetButtonTapped:
            self = .sheetButtonTapped
        case let .routeChanged(route):
            self = .routeChanged(route)
        }
    }
}struct AppView: View {
    
    let store: Store<AppState, AppAction>
    
    struct ViewState: Equatable {
        
        var route: Route?
        var number: String
        var name: String
        
        init(state: AppState) {
            self.route  = state.route
            self.number = state.myNumber
            self.name   = state.myName
        }
    }
    
    enum ViewAction {
        case numberChanged(String)
        case nameChanged(String)
        case routeChanged(Route?)
        case sheetButtonTapped
    }
    
    var body: some View {
        WithViewStore(
            self.store.scope(state: ViewState.init, action: AppAction.init)
        ) { viewStore in
            VStack(alignment: .leading, spacing: 24) {
                Text("Number: \(viewStore.number)")
                Text("Name: \(viewStore.name)")
                
                Button("Sheet") {
                    viewStore.send(.sheetButtonTapped, animation: .default)
                }
                .sheet(unwrapping: viewStore.binding(get: \.route, send: ViewAction.routeChanged), case: /Route.sheet) { _ in
                    NavigationView {
                        SheetView(store: self.store.scope(
                            state: \.sheetState,
                            action: AppAction.sheet
                        ))
                    }
                }
            }
        }
    }
}

extension AppAction {
    init(action: AppView.ViewAction) {
        switch action {
        case let .numberChanged(number):
            self = .numberChanged(number)
        case let .nameChanged(name):
            self = .nameChanged(name)
        case .sheetButtonTapped:
            self = .sheetButtonTapped
        case let .routeChanged(route):
            self = .routeChanged(route)
        }
    }
}

Download here