List of items with a computed viewState for each of them?

I'm really getting in a muddle trying to work out how to make this work. I'm creating an app to show a list of people and their birthdays.

I've got my AppState with an var people: IdentifiedArrayOf<Person> = [].

Where Person just contains a name and dob.

I've got the app showing a list of the people. And I can even tap to open a view that just takes a Person.

But... I'd like to extend this view to have other computed vars and even be editable etc...

My first attempt was to put extra parameters into Person like func age() -> String etc... And then to add a PersonAction and treat the Person as a child state of AppState. But it didn't feel right. Making the Person codable now stores extra stuff to disc like isEditing etc... which doesn't feel right.

My current idea is to have something like PersonViewState which takes a Person and then contains all the other properties.

struct PersonViewState {
  var person: Person
  var ageString: String
  var isEditing: Bool
  etc...
}

But... with this I don't really want to change the AppState to store IdentifiedArrayOf<PersonViewState> as I'll only be showing one of these at a time (when the user taps the row with the person on).

So I'm just wondering how I can use the IdentifiedArrayOf<Person> and then have a NavigationLink which will load a view that takes PersonViewState.

So...

IdentifiedArrayOf<Person>
-> ForEach(viewStore.people) { person in
-> NavigationLink(destination: { PersonView(store: ...?) }

I'm not sure how to scope this store to the AppState and create the action so that I can edit the Person from this view.

I'd like to create the PersonViewState in the reducer too so that I can pass in dependencies like Date() and Calendar.current and use them to calculate the age at the point that the user taps the row. I'm thihnking from this that I actually don't just need a NavigationLink but a Button that would send an action into the reducer to create the PersonViewState at the point of the user tapping the button and use an IfLetStore to scope the viewstore?

Sorry if this is all a bit confused but that probably reflects what is in my brain right now.

Oh, actually, I’ve been thinking about this and I think I could possibly do it in a different way.

Going to give it a try.

Let me know what you come up with :)

Here's something I've done in the past which worked okay. Wasn't a massive fan of it though.

struct State: Equatable {
  var people: IdentifiedArrayOf<Person>
  var selection: PersonState?
}

enum Action {
  case setNavigation(selection: Person.ID?)
}

let reducer = Reducer<State, Action, Void> { state, action, _ in
  switch action {
  case let .setNavigation(.some(id)):
    guard id != state.selection?.id,
          let person = state.people[id: id] else { return .none }
    state.selection = PersonState(person)
    return .none
  case .setNavigation(nil):
    state.selection = nil
    return .none
  }
}

You could also use your Environment to help create the initial state.

And then I used IfLetStore inside the NavigationLink scoping state with

state: { $0.selection.flatMap {  $0.id == person.id ? $0 : nil },

or

state: { state in
  guard let personState = state.selection, personState.id == person.id else { return nil }
  return personState
}
2 Likes

Hmm... where did you send the action from for this to set the selection? Did you use a button inside your navigationlink?

I've got the state set up and the navigation link set up but I don't seem to be able to actually send the setSelection action. I've tried using a button but that doesn't seem to work. I've tried using a tapGesture and that didn't do anything either.

I think this is where I got to before. I'm thinking I might just use a button and not a navigation link and then do a manual navigation to the new view or something.

Thanks

It would look something like this

ForEach(viewStore.people) { person in
          NavigationLink(
            tag: person.id,
            selection: viewStore.binding(
              get: \.selection?.id,
              send: Action.setNavigation
            )
          ) {
            IfLetStore(
              self.store.scope(
                state: { $0.selection.flatMap { person.id == $0.id ? $0 : nil } },
                action: Action.person
              ),
              then: PersonView.init
            ) 
          } label: {
            PersonRow(person: person)
          }
…

And be sure to wrap it in a NavigationView.

1 Like

Ah! I haven't used the tag, selection, content initialiser before. I'll give that a try.

Incidentally, I did think of a potential other way of doing what I wanted.

The problem I've got is that there is stuff that is needed later on that I don't necessarily want to calculate right now. For instance the person's age. Or other things that are potentially expensive. Or that require some environment properties.

So... for instance, I might have naively created a function on Person like...

func age(now: Date, calendar: Calendar) -> String {
  // use self.dob and now and calendar to calculate the age and return a string.
}

But... now I'd have to be able to provide now and calendar to the function when I call it from the view. Or... I could store now and calendar in the Person (which seems incorrect to me).

But... if I change the above function to something like ...

func age(now: () -> Date, calendar: () -> Calendar) -> () -> String -> {
  { [weak self] in
    guard let self = self else return { "" }
    // use self.dob and now and calendar to calculate the age and return a string.
  }
}

Or sometihng... now I can store that partial function in a var in the person and it only ever is calculated when the view calls it but also it is able to use the environment properties of now and calendar.

Also... you could use the same to create a specialised personViewState so that it is curried... storing te env vars in the production of the viewstate but not actually creating the view state and calling the expensive functions.

That means it costs much less now to store those extra properties in the Person and then also sow them in the list. But the nesting is now done in such way that it works in a much more convenient way for TCA.

struct PersonSpecificViewState {
  var person: Person
  var isEditing = false
  var age: () -> String

  func ageFunc(now: () -> Date) -> () -> String {
    { [weak self] in
      // calculate age here
    }
  }

  init(now: () -> Date, person: Person) {
    self.person = person
    self.age = ageFunc(now)
  }
}

... or something.

Not sure I like this much better but it's a possible othher way around thhe problem? I need to explore it further as this is all just in my head for now.

Thanks! Managed to get this working now! Thanks very much for your help. :+1:t2:

Oh man, I'm really getting confused with this.

I got it working were I can now select a person from the list and then view that person in a separate view (using a NavigationLink).

But now when I try to "Edit" that person nothing happens. I am using a sheet to try and replicate the iOS Contacts app and have it like this...

      .sheet(isPresented: viewStore.$isEditSheetPresented) {
        PersonEditView(
          store: store.scope(
            state: \.personEditState,
            action: PersonViewAction.editAction
          )
        )
      }

I can see when I first tap on a person in the list the state shows isEditSheetPresented is false.

And I can see the action setting isEditSheetPresented to true.

But then it says (No state changes).

image
(Sorry, I couldn't paste this in a sensible format).

I tried updating to the new TCA from the latest videos but that had no effect.

However...

If I intercept that edit and also change the person's name then it does trigger a state update...

    case .binding(\.$isEditSheetPresented):
      state.person.name = "Bob"
      return .none

... and it shows the edit screen. And I can edit the person.

But when I get back to the list it isn't updated there.

I think it's due to trying to force TCA to not use a nested structure.

I currently have in AppState...

var people: IdentifiedArrayOf<Person> = []
var selectedPersonState: PersonViewState?
var selectedPersonID: Person.ID?

I think I just need to change it to...

var people: IdentifiedArrayOf<PersonViewState> = []

But then I bring along with it all the extra data about the PersonView when I just want to display a list of people. I don't want all their info there too.

Hmm...

I feel like I'm not thinking the right way about this. It should be much simpler than I'm making it.

Hmm...

I guess I could do it the other way around a bit?

If I had my Person like...

struct Person {
  var name: String
  var dob: Date
}

Then instead of adding extra stuff inside there I could add extensions onto Person like...

extension Person {
  var viewState: Person.ViewState {
    get { Person.ViewState(person: self) }
    set { self = newValue.person }
  }
}

Or something like that...?? Would that even work?

How would I make the reducer work?

  personViewReducer.forEach(
    state: \AppState.people.viewState, // is there a way to make this work?
    action: /AppAction.personViewAction(id:action:),
    environment: { _ in PersonViewEnvironment() }
  ),

I think I'm either too tired or my brain just isn't wired in to this correctly right now.

:smiley:

Thanks for any help you can provide or tips to point me in the right direction.

If anyone wants to take a look at what I've tried to come up with so far this is the repo I',m working on.

It's a bit of a mess mainly because i just don't know what I'm doing with TCA and really struggling with this. I feel like it should be easy but I'm just not sure what I'm doing wrong.

Thanks

Ok…

Thinking about this a completely different way.

I could approach sit by making PersonState own a Person. And then AppState would contain an IdentifiedArrayOf.

Then I could have vars in PersonState for the different other states it needs. Each one could then have its respective reducer.

And… TBH… the PersonState would probably not even need its own reducer. It would just be a combination of the others.

That way I can foreach it properly from AppState. And then also branch out to the other states required without breaking the nesting that seems to be required by TCA.

I’ll give it a try tomorrow.

Yep, that’s exactly what I did when running into a similar state-modelling problem! It seems to work great for me at least, hopefully it’ll work for you too.

1 Like

Excellent, thanks for the confirmation.

I'll give this another go tonight. :smiley:

I tell you what, it's tiring trying to get all these different approaches working but it sure is fun trying to wrap my brain around these new ideas and come up with solutions give a new set of tools. :smiley:

:crossed_fingers:

OK, this is going much better now...

And I think I've worked out why something I mentioned before wasn't working.

In my PersonState I have something like...

extension PersonState {
  struct DetailState: Equatable {
    var person: Person
    @BindableState var isEditSheetPresented = false
  }

  var detailState: DetailState {
    get { DetailState(person: person) }
    set { person = newValue.person }
  }
}

But when I send a binding action to update the isEditSheetPresented I get an output of (No state changes) and I think it's because it's a computed var.

I'm not actually updated the hierarchical state at all. It has no way to track the before and after of the value. (Or at least, not to track it an perform a refresh of the view).

Trying a different approach now...

OK, that worked...

I changed it to create the detailState inside the init of PersonState.

Not ideal though as that means we need it to be instantiated all the way down from the beginning.

It's working as expected now though and presenting/hiding the edit sheet properly.

OK... found another way around this which is not terrible.

Because I now have a PersonState which has a var person: Person I no longer need to worry about "infecting" the Person struct with loads of extra properties.

So...

I've updated to ...

struct PersonState: Equatable, Identifiable {
  var person: Person
  
  var id: UUID { person.id }
  var isEditSheetPresented = false
}

And then added a computed var like...

extension PersonState {
  struct DetailState: Equatable {
    var person: Person
    @BindableState var isEditSheetPresented: Bool
  }
  
  var detailState: DetailState {
    get {
      DetailState(
        person: person,
        isEditSheetPresented: isEditSheetPresented
      )
    }
    set {
      person = newValue.person
      isEditSheetPresented = newValue.isEditSheetPresented
    }
  }
}

And everything works as it should again.

I'm not too worried about PersonState growing as really it should be holding all those little state values. I just wanted to avoid Person growing out of control with lots of UI properties and stuff.

OK, final post here, I promise... for now.

I thought I'd share the new repo with the new implementation.

Also... I discovered what I think is quite a nice pattern with this implementation.

By having a PersonState that contains things like DetailState and EditState and ListViewModel I can create numerous views that all take a store like Store<PersonState, PersonAction>. And then use the WithViewStore function to scope the state inside the view. Instead of passing in the scoped state as the store in the first place.

It means I don't have to worry about how to create the EditState when I'm in the PersonDetailView. The PersonDetailView can just pass its own store straight into it. It is the responsibility of the PersonEditView to scope its own viewStore.

struct PersonEditView: View {
  let store: Store<PersonState, PersonAction>
  
  var body: some View {
    WithViewStore(store.scope(state: \.editState, action: PersonAction.editAction)) { viewStore in
      Form {
        TextField("Name", text: viewStore.$person.name)
        DatePicker("DOB", selection: viewStore.$person.dob, displayedComponents: [.date])
      }
    }
  }
}

Typing it out now I think I've seen this pattern in some of the PointFree code where instead of using WithViewStore they have created a viewStore property and created it inside the init.

Anyway, I'm much happier now.

Thanks :smiley:

Terms of Service

Privacy Policy

Cookie Policy