pointfreeco / swift-composable-architecture

A library for building applications in a consistent and understandable way, with composition, testing, and ergonomics in mind.
https://www.pointfree.co/collections/composable-architecture
MIT License
12.35k stars 1.44k forks source link

isPresented is showing true for an embedded feature #2933

Closed Narsail closed 7 months ago

Narsail commented 7 months ago

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

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

mbrandonw commented 7 months ago

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!