pointfreeco/swift-composable-architecture

isPresented is showing true for an embedded feature

Closed this issue · 1 comments

Description

I might be stumbled upon a bug with the isPresented utility but I'm unsure whether this actually might be the intended behaviour.

One of my features in my app can be used as a presented view (i.e. a sheet) as well as being embedded into a preexisting view.

I utilize dismiss in combination with 'isPresented' to dismiss this feature through a cancel button.

This view is used a tree navigation hierarchy and thus, even if embedded, can be part of a presented view marked with @Presents higher up in the view hierarchy.

For clarity sake, let's use the following naming and hierarchy:

RootFeature -> @presents FullScreenFeature (fullscreenmodal) -> EmbeddedFeature (as a conditional child view)

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

I expect the isPresented of the EmbeddedFeature to be true, if the feature is utilised behind a @Presents annotation, while it should be false without the annotation. This should also be the case if the FullScreenFeature, that embeds the EmbeddedFeature, is presented via @Presents by the RootFeature.

Actual behavior

Unfortunately, the isPresented within the EmbeddedFeature is true in the aforementioned scenario:

RootFeature -> @presents FullScreenFeature (fullscreenmodal) -> EmbeddedFeature (as a conditional child view)

leading to a dismissal of the Fullscreenfeature upon tapping the cancel button in the EmbeddedFeature.

Steps to reproduce

import SwiftUI
import ComposableArchitecture

@main
struct MyApp: App {
  
  let store = Store(initialState: RootFeature.State(), reducer: { RootFeature() })
  
    var body: some Scene {
        WindowGroup {
            RootView(store: store)
        }
    }
}

@Reducer
struct RootFeature {
  
  @ObservableState
  struct State {
    @Presents var destination: Destination.State?
  }
  
  @Reducer(state: .equatable)
  enum Destination {
    case fullscreen(FullScreenFeature)
  }
  
  enum Action {
    case fullScreenButtonTapped
    case destination(PresentationAction<Destination.Action>)
  }
  
  var body: some Reducer<State, Action> {
    Reduce { state, action in
      switch action {
      case .fullScreenButtonTapped:
        state.destination = .fullscreen(.init())
        return .none
      default: return .none
      }
    }
    .ifLet(\.$destination, action: \.destination)
  }
  
}

struct RootView: View {
  
  @Bindable var store: StoreOf<RootFeature>
  
  var body: some View {
    Button("Full Screen", action: { store.send(.fullScreenButtonTapped) })
    .fullScreenCover(
      item: $store.scope(state: \.destination?.fullscreen, action: \.destination.fullscreen),
      content: { FullScreenView(store: $0) }
    )
  }
}

@Reducer
struct FullScreenFeature {
  
  @ObservableState
  struct State: Equatable {
    var embeddedFeature: EmbeddedFeature.State? = nil
  }
  
  enum Action {
    case embeddedViewButtonTapped
    case embeddedFeature(EmbeddedFeature.Action)
  }
  
  var body: some Reducer<State, Action> {
    Reduce { state, action in
      switch action {
      case .embeddedViewButtonTapped:
        state.embeddedFeature = .init()
        return .none
      case .embeddedFeature(.hideButtonTapped):
        state.embeddedFeature = nil
        return .none
      }
    }
    .ifLet(\.embeddedFeature, action: \.embeddedFeature) { EmbeddedFeature() }
  }
}

struct FullScreenView: View {
  let store: StoreOf<FullScreenFeature>
  
  var body: some View {
    Button("Show Embedded View", action: { store.send(.embeddedViewButtonTapped) })
    
    if let embeddedFeatureStore = store.scope(state: \.embeddedFeature, action: \.embeddedFeature) {
      EmbeddedView(store: embeddedFeatureStore)
    }
  }
}


@Reducer 
struct EmbeddedFeature {
  
  @Dependency(\.dismiss)
  private var dismiss
  
  @Dependency(\.isPresented)
  private var isPresented
  
  @ObservableState
  struct State: Equatable {
  }
  
  enum Action {
    case hideButtonTapped
  }
  
  var body: some Reducer<State, Action> {
    Reduce { state, action in
      switch action {
      case .hideButtonTapped:
        if isPresented {
          return .run { _ in await dismiss() }
        }
        return .none
      }
    }
  }
  
}

struct EmbeddedView: View {
  let store: StoreOf<EmbeddedFeature>
  
  var body: some View {
    VStack(spacing: 10) {
      Text("Embedded View 🎉")
      Button("Hide Embedded View", action: { store.send(.hideButtonTapped) })
    }
    .padding()
    .background(.teal)
  }
}

The Composable Architecture version information

No response

Destination operating system

No response

Xcode version information

No response

Swift Compiler version information

No response

Hi @Narsail, your FullScreenFeature reducer is not using the presentation tools of the library. It needs to use @Presents and PresentationAction in order for \.dismiss to work.

Since this isn't an issue with the library I am going to convert it to a discussion. Please feel free to ask any questions over there!