Displaying a list of views that may or may not exist

Maybe I'm going about this in entirely the wrong way, if so, please let me know.

Anyway, in the "Home" screen of our app it essentially acts as a "Springboard" like service for other features in the app. Each view has it's own state and own loading etc...

However, some of the views may decide after their state is loaded that actually the view on the home screen shouldn't exist.

For instance, lets say you have banking app and you have a section for "Savings accounts" but you actually don't have any savings accounts open. So after any "loading" animation or whatever we will hide that view.

However... it might be the case that later on in the app you decide to open a savings account. If that happens then when you come back to the home screen it will be listed there in the "Savings Account" section.

At the moment I have each section in the view having its own child TCA "module". Such that the "Home" screen knows very little. It just loads the different modules. The modules then provide a view (or not) that the home screen will show.

For example in the hypothetical banking app...

ScrollView(.vertical, showsIndicators: false) {
	VStack(spacing: 0) {
		BalanceView(
			store: store.scope(state: \.balanceHeading, action: HomeFeature.Action.balanceHeading)
		)
		SavingsAccountsView(
			store: store.scope(state: \.savingsAccounts, action: HomeFeature.Action.savingsAccounts)
		)
		CreditCardsView(
			store: store.scope(state: \.creditCards, action: HomeFeature.Action.creditCards)
		)
		// ... and so on
	}
	.frame(maxWidth: .infinity)
}

Now, inside each of these views at the moment I'm using a .task so that it can subscribe to a local cache dependency and update itself with the relevant data. But I don't know how to make the "thie view should not appear" bit work nicely without making it a bit hacky.

At the moment I'm just telling it to display Color.clear.frame(height: 0) if it does not have any data. But this causes issues with spacing etc...

If I change it to use EmptyView() then the .task no longer runs on subsequent appearances of the view.

Ideally, each time this screen appears it should reevaluate all its views and only show the ones that actually have a view to return. And update any where the data has changed.

Hmm... maybe I could have a computed var / extension of the State that stores the full array of sections and filters it based on whether each section has the required data and then each time it appears it would do the same thing.

The problem then is that we still are not showing the view and so the view will not run the task which will not subscribe to the cache and not get the data.

I really want to avoid having all of this logic and data stored inside the HomeFeature as it will just bloat it up to a ridiculous size. :sweat_smile: (Which is where we are in the old version of the app right now).

I hope these incoherent ramblings are making some sort of sense. It would be excellent to have any sort of input even just rough pointers of how to think about a solution here.

Thanks

Hmm... maybe we just need to approach this from the other direction entirely and have things defined in the home view.

Or have an array of enums where each enum contains its State or something?

enum HomeSection {
  case balance(BalanceHeading)
  case savingsAccount(SavingsAccounts)
  case creditCards(CreditCards)
}

And then filter an array of those based on interrogating them about their data?! Maybe?

Is that even possible to scope a store like that if you have an array of enums where each enum contains a different Store type?

Hmm... maybe this does work. If I define the padding around each view that exists and 0 if it doesn't then each 0 height view will technically "not be there".

Maybe I'm not as stuck as I thought... :thinking:

If I understand correctly, then you have most "classic" HomeFeature store, and all child features are scoped from that one store. In that case, you can call child features actions from the parent. You don't have to limit yourself to call child features actions only from theirs view.

Something like this in HomeSection reducer would control fetching from the parent but still all logic is encapsulated in child features:

{...}
  case .fetchSubsectionsData:
    return .run { send in 
      await send(.balance(.fetch))
      await send(.savings(.fetch))
      await send(.creditsCards(.fetch))
    }
{...}

Probably you should store in state information which views should be displayed to only fetch the ones which are needed. Mentioned by you array of states is possible. However if the list is rather small that something simpler is possible. Let's say that every child feature has Bool state isActive

// Home view
{...}
 if viewStore.balance.isActive {
    BalanceView(
      store: store.scope(state: \.balanceHeading, action: HomeFeature.Action.balanceHeading)
    )
  }
}

if viewStore.savings.isActive {
  BalanceView(
      store: store.scope(state: \.savingsAccounts, action: HomeFeature.Action.savingsAccounts)
    )
  }
}
{...}

To avoid performance problem, it will be better create separate ViewState with only these really needed data to avoid monitoring changes of all child features states.

I was curious about scoping stores and composing reducers for a model like this. And surprisingly it's quite straightforward. Quick example below hidden behind toggle. However, if number of child features is known and low, that I would choose some simpler solution.

Quick and dirty code example
import ComposableArchitecture
import SwiftUI

struct ArrayOfStates: ReducerProtocol {
  struct State: Equatable {
    var features: IdentifiedArrayOf<Feature.State>
  }
  
  enum Action {
    case feature(id: Feature.State.ID, action: Feature.Action)
  }
  
  var body: some ReducerProtocolOf<Self> {
    Reduce { state, action in
      return .none
    }
    .forEach(\.features, action: /Action.feature) {
      Feature()
    }
  }
}

struct Feature: ReducerProtocol {
  enum State: Equatable, Identifiable {
    case number(Int)
    case text(String)
    
    // Naturally this is just a ugly shortcut to to make it work quickly
    var id: Int {
      switch self {
      case .number:
        return 0
      case .text:
        return 1
      }
    }
  }
  
  enum Action {
    case number(NumberFeature.Action)
    case text(TextFeature.Action)
  }
  
  var body: some ReducerProtocolOf<Self> {
    Reduce { state, action in
      return .none
    }
    .ifCaseLet(/State.number, action: /Action.number) {
      NumberFeature()
    }
    .ifCaseLet(/State.text, action: /Action.text) {
      TextFeature()
    }
  }
}

struct ArrayOfStatesView: View {
  let store: StoreOf<ArrayOfStates>

  init(store: StoreOf<ArrayOfStates>) {
    self.store = store
  }
  
  var body: some View {
    ForEachStore(
      self.store.scope(state: \.features, action: ArrayOfStates.Action.feature)
    ) { featureStore in
      SwitchStore(featureStore) {
        CaseLet(state: /Feature.State.number, action: Feature.Action.number) { store in
          NumberFeatureView(store: store)
        }
        CaseLet(state: /Feature.State.text, action: Feature.Action.text) { store in
          TextFeatureView(store: store)
        }
      }
    }
  }
}

struct TextFeature: ReducerProtocol {
  typealias State = String
  
  enum Action {
    case append(String)
  }
  
  func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
    switch action {
    case let .append(value):
      state.append(value)
      return .none
    }
  }
}

struct TextFeatureView: View {
  let store: StoreOf<TextFeature>
  
  var body: some View {
    WithViewStore(store) { viewStore in
      VStack {
        Text(viewStore.state)
        Button("Append one more `A`") {
          viewStore.send(.append("A"))
        }
      }
    }
  }
}

struct NumberFeature: ReducerProtocol {
  typealias State = Int
  
  enum Action {
    case add(Int)
  }
  
  func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
    switch action {
    case let .add(value):
      state += value
      return .none
    }
  }
}

struct NumberFeatureView: View {
  let store: StoreOf<NumberFeature>
  var body: some View {
    WithViewStore(store) { viewStore in
      VStack {
        Text("\(viewStore.state)")
        Button("Add") {
          viewStore.send(.add(5))
        }
      }
    }
  }
}

struct ArrayOfStates_Previews: PreviewProvider {
  static var previews: some View {
    List {
      ArrayOfStatesView(
        store: Store(
          initialState: .init(
            features: [
              .text("A"),
              .number(5),
            ]
          ),
          reducer: ArrayOfStates()
        )
      )
    }
  }
}


1 Like

Nice, I like this idea! I'll give this a try.

Thanks :smiley:

Well... that worked a treat! And now the layout of the home screen is managed in data rather than in the view which is event better!

Thanks :smiley:

1 Like