-
DescriptionWhen a screen is presented with multiple possible destinations, the view unexpectedly dismisses when transitioning between these views. Checklist
Expected behaviorThe view will smoothly transition between destinations without dismissing when a single Actual behaviorThe destination view dismisses before transitioning to the new destination. Steps to reproduce
import ComposableArchitecture
import SwiftUI
public struct AppFeature: Reducer {
public struct Destination: Reducer {
public enum State: Equatable {
case first(FirstDestination.State)
case second(SecondDestination.State)
}
public enum Action: Equatable {
case first(FirstDestination.Action)
case second(SecondDestination.Action)
}
public var body: some ReducerOf<Self> {
Scope(state: /Destination.State.first, action: /Destination.Action.first) {
FirstDestination()
}
Scope(state: /Destination.State.second, action: /Destination.Action.second) {
SecondDestination()
}
}
}
public struct State: Equatable {
public var text: String = "Press Me"
@PresentationState public var destination: Destination.State?
}
public enum Action {
case presentButtonTapped
case destination(PresentationAction<Destination.Action>)
}
struct Core: Reducer {
func reduce(into state: inout State, action: Action) -> Effect<Action> {
switch action {
case .presentButtonTapped:
state.destination = .first(.init())
return .none
case .destination(.presented(let destinationAction)):
switch destinationAction {
case .first(.nextTapped):
state.destination = .second(.init())
return .none
case .second(.dismissTapped):
state.destination = nil
return .none
}
case .destination(.dismiss):
return .none
}
}
}
public var body: some ReducerOf<Self> {
Core()
.ifLet(\.$destination, action: /Action.destination) {
Destination()
}
}
}
public struct FirstDestination: Reducer {
public struct State: Equatable {}
public enum Action: Equatable {
case nextTapped
}
public var body: some ReducerOf<Self> {
EmptyReducer()
}
}
public struct SecondDestination: Reducer {
public struct State: Equatable {}
public enum Action: Equatable {
case dismissTapped
}
public var body: some ReducerOf<Self> {
EmptyReducer()
}
}
public struct ContentView: View {
let store: StoreOf<AppFeature> = .init(initialState: .init(), reducer: AppFeature())
public var body: some View {
WithViewStore(store, observe: \.text) { textViewStore in
Button(textViewStore.state) {
textViewStore.send(.presentButtonTapped)
}
}
.fullScreenCover(
store: store.scope(
state: \.$destination,
action: AppFeature.Action.destination
)
) { destinationStore in
SwitchStore(destinationStore) {
CaseLet(
state: /AppFeature.Destination.State.first,
action: AppFeature.Destination.Action.first
) { firstStore in
WithViewStore(firstStore, observe: { $0 }) { viewStore in
Button("Next View") {
viewStore.send(.nextTapped)
}
.tint(.cyan)
}
}
CaseLet(
state: /AppFeature.Destination.State.second,
action: AppFeature.Destination.Action.second
) { secondStore in
WithViewStore(secondStore, observe: { $0 }) { viewStore in
Button("Dismiss") {
viewStore.send(.dismissTapped)
}
.tint(.red)
}
}
}
}
}
}
public struct VanillaContentView: View {
@State var state: AppFeature.State = .init()
public var body: some View {
Button("Press Me") {
state.destination = .first(.init())
}
.fullScreenCover(unwrapping: $state.destination, onDismiss: { state.destination = nil }) { $destination in
if let _ = Binding(unwrapping: $destination, case: /AppFeature.Destination.State.first) {
Button("Next View") {
state.destination = .second(.init())
}
.tint(.cyan)
}
if let _ = Binding(unwrapping: $destination, case: /AppFeature.Destination.State.second) {
Button("Dismiss") {
state.destination = nil
}
.tint(.red)
}
}
}
} The Composable Architecture version informationprerelease/1.0 Destination operating systemiOS 16.4 Xcode version informationXcode 14.3 Swift Compiler version informationNo response |
Beta Was this translation helpful? Give feedback.
Replies: 8 comments
-
Hey @junebash! I'm afraid this is the expected behavior in vanilla, where the change of ID triggers a re-presentation. I have a pending PR in |
Beta Was this translation helpful? Give feedback.
-
So in that case, is there any way using TCA to get the desired behavior of not dismissing and re-presenting when the destination changes? |
Beta Was this translation helpful? Give feedback.
-
Hi @junebash, have you seen this version of If you use that version instead of manually doing a .fullScreenCover(
store: store.scope(
state: \.$destination,
action: AppFeature.Action.destination
),
state: /AppFeature.Destination.State.first,
action: AppFeature.Destination.Action.first
) { firstStore in
WithViewStore(firstStore, observe: { $0 }) { viewStore in
Button("Next View") {
viewStore.send(.nextTapped)
}
.tint(.cyan)
}
}
.fullScreenCover(
store: store.scope(
state: \.$destination,
action: AppFeature.Action.destination
),
state: /AppFeature.Destination.State.second,
action: AppFeature.Destination.Action.second
) { secondStore in
WithViewStore(secondStore, observe: { $0 }) { viewStore in
Button("Dismiss") {
viewStore.send(.dismissTapped)
}
.tint(.red)
}
} |
Beta Was this translation helpful? Give feedback.
-
Interesting! Looking at the APIs, I find that behavior surprising; I would expect two different In any case, even though it feels counterintuitive to me, that does indeed give me a solution. Thanks! |
Beta Was this translation helpful? Give feedback.
-
We too were a little surprised by it and we want to look into it more. I'm going to convert this to a discussion, and we this documented in our notes to look at before final release. Also please let us know if you uncover anything else. |
Beta Was this translation helpful? Give feedback.
-
This is rather odd, because on both cases, I'm getting a re-presentation in Vanilla when the case changes: enum Destination: String, Equatable, Identifiable {
case a
case b
var id: String {
rawValue
}
}
struct ContentView: View {
@State var state: Destination?
var body: some View {
Button {
self.state = .a
} label: {
Text("Present")
}
// Re-presentation on change
.sheet(item: $state) { state in
Button {
self.state = state == .a ? .b : .a
} label: {
Text(String(describing: state))
}
}
// // Re-presentation on change
// .sheet(item: Binding(
// get: { self.state == .a ? Destination.a : nil },
// set: { self.state = $0 })
// ) { state in
// Button {
// self.state = .b
// } label: {
// Text(String(describing: state))
// }
// }
// .sheet(item: Binding(
// get: { self.state == .b ? Destination.b : nil },
// set: { self.state = $0 })
// ) { state in
// Button {
// self.state = .a
// } label: {
// Text(String(describing: state))
// }
// }
}
} |
Beta Was this translation helpful? Give feedback.
-
For what it's worth, this version seems to work well for me so far, although I haven't tested extensively. extension View {
public func persistentFullScreenCover<PresentedState, PresentedAction>(
store: Store<PresentationState<PresentedState>, PresentationAction<PresentedAction>>,
content: @escaping (Store<PresentedState, PresentedAction>) -> some View
) -> some View {
WithViewStore(store, observe: { $0.wrappedValue != nil }) { viewStore in
self.fullScreenCover(isPresented: .constant(viewStore.state)) {
IfLetStore(
store.scope(state: \.wrappedValue, action: { .presented($0) })
) { presentedStore in
content(presentedStore)
}
}
}
}
} |
Beta Was this translation helpful? Give feedback.
-
@junebash I took a look into this today and the main issue is that presentation was driven by the I've relaxed this on |
Beta Was this translation helpful? Give feedback.
Hi @junebash, have you seen this version of
fullscreenCover(store:state:action:)
? It allows you to first specify a store focused on the destination domain, and then further state and action transformations that single out a case in the destination enum.If you use that version instead of manually doing a
SwitchStore
insidefullscreenCover
you will get the behavior you expect: