https://kotlinlang.org logo
#compose
Title
# compose
m

Michael Langford

10/28/2021, 7:00 PM
Hi, I'm trying to untangle a tricky routing/data passing question in jetpack compose on android: We have 3 types of events we help users record. Think "I cut my toe, it's still got a scab". These sometimes reference past events, including ones of other types. Sometimes the users haven't created the past events. Events aren't officially created until the user hits a save button (as deleting is kind of hard, and we're matching iOS behavior). So let's say they make a BTypeEvent. BTypeEvents have a reference to an optional past BTypeEvent, (lets call it previousBEvent) , a past ITypeEvent, and a past ATypeEvent. In my routing table, I currently have entries like:
Copy code
composable ("/EntryEditor/BTypeEditor/${logEntryId}") {
    val existing = it.arguments?.getString(logEntryIdentifierName)
    EventEditorBType(navController = navController, eventStoreModel = eventStoreModel,        existing?.let{BTypeIdentifier(value = it)})
}
This successfully lets us edit new log items as well as select existing log items via nested navigation. Here is where the problem hits. When editing new log items, the log items only exist in the ViewModel of the EntryEditor as they aren't saved to the flow store, or the backend, so "deeper" events can't reference the "shallower" events by just parsing the route when the shallower events aren't yet saved to the eventStoreModel. We do need to not save the events until the user confirms they've entered everything to both match iOS and meet scientific needs of the tool. E.g. User hits "New BType" Floating Action Button on main screen. User partially enters in BType event. Hits "previous BTypeEvent" button on this Btype event. This launches a screen kinda like the main screen, except it only shows BType events. It does also have a + button. If the user selects an event, how do we easily pass this selected event back to the first editor screen? it's easy enough to go off the route to get the logEntryId in the case where the toplevel BTypeEvent is saved to the event store, however, when a new BTypeEvent event is being recorded, this avenue isn't available, as the event isn't yet in the event store. This problem also has to be "technically infinitely scalable" but de facto, like 8 levels deep scalable. A person could start to record a current BType record, start storing a new BType record about a past report on this topic, and in that, start storing a past BType record on this item. In iOS, this is easy, we don't use path based routing, so we can just push a list of BTypeEvents with a + button on it, and if the user hits plus, we let them record the event, save it, etc, then when we return, we call a closure which was passed to the invoked editor when we navigated. We can't pass such closures with jetpack. One idea I've had is to delve into the backstack to guess if you're saving events for "shallower" events by the path, but with this approach, I'm not sure how to get the reference to the previous screen from this and google is failing me there. I've heard the approach to "share a view model"...and that feels like a bit of a stretch with how "nested" all of this is and how complex saving that view model wold be, as only parts of it would save at a time. Any ideas folks?
Example of the data type we're talking about editing
Copy code
data class BTypeEntry(
  var identifier: BTypeIdentifier,
  var lookAndFeel: String = "",
  var durationInMinutes: Int = 0,
  var previousBEvent: BTypeIdentifier? = null,
  var associatedAEvent: ATypeIdentifier? = null,
  var lastIEvent: ITypeIdentifier? = null,

//like 10 more fields here, but stuff like strings, ints.
):EventLogEntry() {}
i

Ian Lake

10/28/2021, 7:39 PM
So what is the single source of truth for this inflight data? What is the scope of that single source of truth?
m

Michael Langford

10/28/2021, 8:23 PM
eventStoreModel has saved events. Any events "passed up" the tree would be saved events. As I mentioned, the partially entered "shallow" BTypeEntry would not yet be saved, due to "Business Rules" for lack of a concise explanation.
Like if a person started making a BTypeEntry(lookAndFeel = "almostHealed"), then hit the button to enter the previous entry, hit the + FAB button to make a new one, started filled out the new entry BTypeEntry(lookAndFeel = "Scabbing Gash"), then hit the previous entry and then + then started entering BTypeEntry(lookAndFeel = "Fresh wound"), and completely saved that the freshWound one, that would save to the server as soon as they hit the save button. If they then took their time to keep messing around with "Scabbing gash", or never finished filling it out, the scabbingGash and almostHealed entries wouldn't be saved until/if the user explicitly did so. They could cancel making them, and "Fresh Wound" would be visible from the main list of entries.
i

Ian Lake

10/28/2021, 8:31 PM
You didn't answer my question. What single component owns the inflight data that the user is building? When does that building of the inflight data start and when does it get cleaned up (either by saving or the user backing out of the whole process)?
You need to figure out the answer to those questions first
m

Michael Langford

10/28/2021, 8:31 PM
Each view has it's own view model
i

Ian Lake

10/28/2021, 8:32 PM
Clearly that isn't a wide enough scope
m

Michael Langford

10/28/2021, 8:32 PM
Why not?
Not trying to be dumb here, it literally is a wide enough scope on another platform.
i

Ian Lake

10/28/2021, 8:33 PM
Didn't you say that there are multiple screens in a infinitely nested structure? That certainly sounds like more than just a single screen
m

Michael Langford

10/28/2021, 8:34 PM
okay, so there are those 3 types of events right? You could make a single one of any of those from the main screen of the app.
Each one has its own simple view model.
Each one of these logged event does have a UUID, however those are not stored globally anywhere until the event is actually marked to be saved by the user (because of those business rules).
The BType event in general can refer to past BType events (this is a medical reporting app, it's talking about would healing).
i

Ian Lake

10/28/2021, 8:36 PM
So what ties all of these simple view models into your infinitely deep structure?
m

Michael Langford

10/28/2021, 8:36 PM
Copy code
var previousBEvent: BTypeIdentifier? = null,
reference numbers
that's just another UUID, about previous events.
I'm asking here about "passing data on a stack" instead of "passing data on a heap".
I get that Jetpack Compose style navigation comes from a "stateless but for a heap like database" world, but I'm trying to find the stack based data passing parameter mechanisms.
I literally don't see how to say "what's the view model of the view that navigated to me". Does that make sense? How do I get that? or how do I pass a closure to a view I navigate to? Or even specify a Flow state to update?
Like the navController.navigate("whatever") call seems to be a "if you can't communicate it through strings, you're screwed" situation, and I'm sure there is a way to do some stack based stuff here.
i

Ian Lake

10/28/2021, 8:44 PM
Let's take a step back. In part of your app, the user is just viewing already saved events, correct? There's no inflight (i.e., not saved) events floating around during this time?
(I'm just looking for a "yes" at this point, I have many more clarifying quesitons)
m

Michael Langford

10/28/2021, 8:46 PM
Yes
(btw, I really do appreciate your help here, want to be clear)
i

Ian Lake

10/28/2021, 8:47 PM
And at some point, the user hits your + FAB and enters the creation flow?
m

Michael Langford

10/28/2021, 8:47 PM
Yes, or they can edit any of those events (and in the editing case, this problem vanishes for the top level object).
i

Ian Lake

10/28/2021, 8:48 PM
And once they enter that new event creation flow, that's when you create your (at first empty) BType object?
m

Michael Langford

10/28/2021, 8:49 PM
Right. But it does have default values and a new UUID to identify itself
i

Ian Lake

10/28/2021, 8:49 PM
Gotcha. And if they were to hit the back button at that point without saving anything, you'd throw away that BType object?
m

Michael Langford

10/28/2021, 8:49 PM
totally
i

Ian Lake

10/28/2021, 8:50 PM
So the scope of that BType object is only when they are in that creation flow (it is created when it starts and is destroyed (or transformed into a saved event) when it ends)?
m

Michael Langford

10/28/2021, 8:51 PM
When the user hits save, we do validation, then if good, we save it to the eventStoreModel, either replacing the previous incarnation if already there, or adding it if not
i

Ian Lake

10/28/2021, 8:52 PM
But when you hit the save button, you'd be leaving the creation flow?
m

Michael Langford

10/28/2021, 8:53 PM
Yup.
i

Ian Lake

10/28/2021, 8:55 PM
And within that creation flow, you have many screens that all touch a part (or subpart or sub-sub-part) of that BType object?
m

Michael Langford

10/28/2021, 8:56 PM
For the most part, it's literally just a nested invocation of those same 3 editing screens.
Like I do occasionally need to say "What body part hurts" which is a simple version of this problem, but as it doesn't have a recursive hellscape that the main problem does, I was afraid to ask the simple version
Like to pass data back to a BTypeEvent on what body part hurt, on iOS we bring up a list of body parts and pass a closure to be called when they say where it hurts
It's the non-recursive analog of the problem.
Does that have a simple solution that you can think of that doesn't clearly NOT work for recursive ones? 😄
i

Ian Lake

10/28/2021, 9:01 PM
Let's figure out this problem first, then we can see how it generalizes
m

Michael Langford

10/28/2021, 9:01 PM
Sounds awesome
So imagine a jetpack compose screen, there is a text button there that says "Select Body Part..."
i

Ian Lake

10/28/2021, 9:02 PM
So you have a creation flow that allows the user to move between 3 different edit screens
m

Michael Langford

10/28/2021, 9:03 PM
They boop it, we pop into a new screen that's a list of body parts, they select one and hit a checkmark in the upper right, or not (by swiping left or hitting X). If they did select one, i need to tell the "parent screen" they did
Sorry, to answer your question on "Creation Flow": There are 3 screens. The BType one refers to IType Events, BTypeEvents, and AType events. AType events and IType events don't recursively refer to any other events
A user can choose to add any of the 3 event types from the main screen. Or when needed when filling in the BType event and they don't see the appropriate type of event.
i

Ian Lake

10/28/2021, 9:06 PM
Okay, but when they hit the checkmark on a particular screen, that indicates that there's a change that needs to be applied to some object in that deeply nested set of inflight data that is scoped to the whole creation flow
m

Michael Langford

10/28/2021, 9:06 PM
So mainScreen -> AType -(pop)-> mainScreen
Ooooh, I see what you're asking
Okay, so lets go into the situation where there are no new peices of info at all.
Lets say there are already tons of AType, BType and IType events.
The user goes, I want to fill in a BType event
The open it
They tap "previousBType"
It shows a list of them, they tap the corresponding event, woo hoo, I want that to get back to the Btype event.
Now if they were just editing an already saved event, I could just rely on that handy UDID to pull it out of the eventStoreModel.
So lets do that for this simple case, we just look at the path in navigation, pull out the ID , and on save, write back the particular field from that selection list.
That would all work.
But alas, we do sometimes need to also get data back to these partially made new items instead of stuff in that store.
i

Ian Lake

10/28/2021, 9:10 PM
Just to clarify, you said that inflight events also have a UUID, it is just a temporary UUID that is scoped to your creation flow and not part of your eventStoreModel, correct?
m

Michael Langford

10/28/2021, 9:12 PM
It's the exact same UUID that will end up being saved with the object for all eternity if the user hits save
We very intentionally do this for the purpose of allowing the app to function if the server is unreachable
i

Ian Lake

10/28/2021, 9:12 PM
Right, but that UUID doesn't yet exist in eventStoreModel, but just as something that is scoped that creation flow?
m

Michael Langford

10/28/2021, 9:12 PM
Yup
Like, if I were more confident about how to do it with Flow, I could take the "world" and the "world if I were to save this" and make a nested hypothetical state. But that sounds a little convoluted for people to understand why I'm doing it.
(and I'd still need to figure out how to pass the "nested world" to what I navigate to when I call navController("path to deeper section", nestedEventStore))
Like is there some field in the back stack that would be sufficient to pass that UUID around? Perhaps a world of "possible objects" for the half-saved objects really isn't that bad
i

Ian Lake

10/28/2021, 9:16 PM
So we've been talking about a 'creation flow' that your 3 edit screens live in, right?
m

Michael Langford

10/28/2021, 9:17 PM
I mean, you've called it that. I want to be clear, the app really does just authenticate the user then store these three types of events, showing them in a list.
The IType ones have some cool computer vision stuff, but that has 0 to do with his problem.
Like a user can pick to do any of the 3
or just look at their past ones. That's really it
i

Ian Lake

10/28/2021, 9:18 PM
And that creation flow is the scope that your inflight events live in, right? Assuming you're doing a new creation and not an edit
m

Michael Langford

10/28/2021, 9:18 PM
yeah
I'd like to say, if there is some insultingly simple way to pass data around on the back stack that every android dev would know, I may not. I leave android for years at a time then come back 😄
i

Ian Lake

10/28/2021, 9:21 PM
Navigation has exactly that concept - it is called a nested graph: https://developer.android.com/jetpack/compose/navigation#nested-nav
Copy code
navigation(startDestination="...", route = "creation_flow") {
  composable(...) {}
  composable(...) {}
  composable(...) {}
}
That parent "creation_flow" gives you a scope for holding onto data that all your screen need access. A scope that is created when you first navigate to one of those destinations and that is automatically destroyed when the last of those screens is popped off the back stack
m

Michael Langford

10/28/2021, 9:23 PM
So I do see how to pass data "deeper" into the stack there, I definitely pass filter params, etc down "deeper".
I don't see how to pass it back up
i

Ian Lake

10/28/2021, 9:26 PM
Let's focus on what needs to live at each level. You said that your inflight data needs to live at the creation_flow level right?
That's what serves as the equivalent to your eventStoreModel for specifically the creation case?
m

Michael Langford

10/28/2021, 9:28 PM
https://stackoverflow.com/a/62768072 <- See that? Can I do that without turning every view into a fragment?
Copy code
"Deeper view when saved"

findNavController().previousBackStackEntry?.savedStateHandle?.set("key", data)
findNavController().popBackStack()
+ shallower view getting data
Copy code
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    findNavController().currentBackStackEntry?.savedStateHandle?.getLiveData<String>("key")?.observe(viewLifecycleOwner) { data ->
        // Do something with the data.
    }
}
Like I don't have the equivalent of the event store model: When you launch the editor for any of the three types of event, if you pass an identifier, it gets it from the event store. If you pass null, it makes a new one.
Ian: I have to head out to pick up my kid, but I'm going to say, believe it or not, the fact that I'm not missing something super obvious here to more experienced jetpack users is a great find. It lets me know I really should be doing a complex solution to this problem. I have several good leads now and will do one over the next day or so. That you so much for your time and attention on all of this.
i

Ian Lake

10/28/2021, 9:35 PM
You're getting ahead of yourself and still approaching the problem from the wrong direction - you haven't had your 'aha' moment
I'll write up a few more notes here and you can see if it makes sense when you come back
The problem is exactly that you don't have the equivalent of the event store model for your inflight data - that's the single source of truth you're missing that would make your edit case and creation case align
Instead of passing inflight data back and forth between destinations, you'd want to do exactly what you are doing for your edit case - having that checkbox on each screen writing a new entry or updating the existing entry into your single source of truth
But instead of directly having each screen write that into the eventStoreModel, you'd want to talk to something scoped at the creation_flow level - that scope for all of your inflight data
That 'something scoped at the creation_flow level' is a ViewModel - an object that is decoupled from any individual screen's composition, but tied to the scope of the entire flow
Looking at the ViewModel docs (and specifically at the Hilt and Navigation section), we can see how to get a ViewModel scoped to a parent scope: https://developer.android.com/jetpack/compose/libraries#hilt-navigation
Copy code
navigation(startDestination = "/EntryEditor/BTypeEditor?entryId=${logEntryId}&parentId={parentId}", route="creation_flow") {
  composable ("/EntryEditor/BTypeEditor?entryId=${logEntryId}&parentId={parentId}") {
    val existing = it.arguments?.getString(logEntryIdentifierName)
    val parentId = it.arguments?.getString("parentId")
    // This is how each destination gains access to the parent navigation graph entry
    val parentEntry = remember {
      navController.getBackStackEntry("creation_flow")
    }
    // And this ViewModel would be the same one from each destination
    // You'd use viewModel instead of hiltViewModel if you weren't using Hilt
    val creationFlowViewModel = hiltViewModel<CreationFlowViewModel>(
      parentEntry
    )
    // Gets the existing entry using existing or creates a new one that has a parentId
    // If both are null, we're on the root of a brand new BType
    val bType = creationFlowViewModel.getOrCreateBType(exiting, parentId)
    EventEditorBType(navController = navController, bType,
      onSaved = { updatedBType ->
        creationFlowViewModel.save(updatedBType)
      },
      onCreateNestedAType = {
        // Navigate to the ATypeEditor, passing in this BType's ID as the parentId
        navController.navigate("/EntryEditor/ATypeEditor?parentId={bType.id}")
    )
}
Now you've abstracted away your event store model into your
CreationFlowViewModel
, which could be implemented like:
Copy code
@HiltViewModel
class CreationFlowViewModel @Inject constructor(
    private val eventStoreModel: EventStoreModel
) : ViewModel() {
  // You probably want a better data type than this :)
  val tempBTypeStore = mapOf<String, BType>()
  val tempBTypeParentMap = mapOf<String, String>()

  fun getOrCreateBType(existing: String?, parentid: String?): BType {
    if (existing != null) {
      val existingBType = eventStoreModel.getBType(existing)
      if (existingBType != null) {
        return existingBType;
      }
    }
    val newBType = BType(UUID.randomUUID())
    if (parentId != null) {
      tempBTypeParentMap[newBType.id] = parentId
    }
    tempBTypeStore[newBType.id] = newBType
    return newBType
  }

  fun save(bType: BType) {
    val existing = eventStoreModel.getBType(existing)
    if (existing != null) {
      // This is an edit case, we can directly save it
      eventStoreModel.update(bType)
    } else {
      if (tempBTypeParent[bType.id] == null) {
        // This is the root item being saved, so we
        // can save the BType - ideally, your better data
        // structure can do this efficiently
        eventStoreModel.add(bType)
      } else {
        // This isn't the root node, so we just update
        // our temp copy
        tempBTypeStore[bType.id] = bType
      }
    }
  }
}
m

Michael Langford

10/29/2021, 1:19 PM
Thanks for the more complete solution! That's very helpful. Additionally: I had missed this gem about NavController.getBackStackEntry(): ≥If the back stack contains more than one instance of the specified destination, 
getBackStackEntry()
 returns the topmost instance from the stack.
That helps too.