Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

API overhaul because splitting the keys and the composables don't work. #64

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

rjrjr
Copy link
Owner

@rjrjr rjrjr commented Aug 27, 2022

All of our tests and demos were built using String as the key, with content that does nothing but render the key.
This approach doesn't reflect reality very well, and masked #63, where keys for more interesting objects can get out of sync with the content lambda that can render them.
When popping, you would wind up crashing when the up to date lambda is unable to interpret the key for the screen that is being animated away.

The fix is to change the API from something that takes a list of keys and a function that can render them, to a list of model objects that themselves are able to provide @Composable Content().
IMHO the updated API actually feels pretty good, more like the conventional hoisted-state @Composable Foo(model: FooModel) idiom.
(Of course I've been working on this all day, so I'm biased.)

We provide a new interface:

interface BackstackFrame<out K : Any> {
  val key: K
  @Composable fun Content()
}

And change the signature of the Backstack() function:

fun <K : Any> Backstack(
  frames: List<BackstackFrame<K>>,
  modifier: Modifier = Modifier,
  frameController: FrameController<K>
)

Note that the param type, K, is still the type of the key, not the type of a particular flavor of BackstackFrame.
This makes it easy for us to provide convenience functions to map lists of arbitrary model objects to BackstackFrame instances, so it's not much more verbose than it used to be to make it go.

Before:

Backstack(backstack) { screen ->
  when(screen) {
    Screen.ContactList -> ShowContactList(navigator)
    is Screen.ContactDetails -> ShowContact(screen.id, navigator)
    is Screen.EditContact -> ShowEditContact(screen.id, navigator)
  }
}

After:

Backstack(
  backstack.toBackstackModel { screen ->
    when(screen) {
      Screen.ContactList -> ShowContactList(navigator)
      is Screen.ContactDetails -> ShowContact(screen.id, navigator)
      is Screen.EditContact -> ShowEditContact(screen.id, navigator)
    }
  }
)

Note that there are two flavors of toBackstackModel. The second one supports models with more interesting keys.

data class Portrait(
  val id: Int,
  val url: String
)

Backstack(
  backstack.toBackstackModel(
    getKey = { it.id }
  ) {
    PrettyPicture(it.url)
  }
)

Fixes #63

@rjrjr rjrjr force-pushed the ray/take-two branch 4 times, most recently from 8b55940 to a3d85d7 Compare August 27, 2022 01:20
All of our tests and demos were built using `String` as the key, with `content` that does nothing but render the key.
This approach doesn't reflect reality very well, and masked #63, where keys for more interesting objects can get out of sync with the `content` lambda that can render them.
When popping, you would wind up crashing when the up to date lambda is unable to interpret the key for the screen that is being animated away.

The fix is to change the API from something that takes a list of keys and a function that can render them, to a list of model objects that themselves are able to provide `@Composable Content()`.
IMHO the updated API actually feels pretty good, more like the conventional hoisted-state `@Composable Foo(model: FooModel)` idiom.
(Of course I've been working on this all day, so I'm biased.)

We provide a new interface:

```kotlin
interface BackstackFrame<out K : Any> {
  val key: K
  @composable fun Content()
}
```

And change the signature of the `Backstack()` function:

```kotlin
fun <K : Any> Backstack(
  frames: List<BackstackFrame<K>>,
  modifier: Modifier = Modifier,
  frameController: FrameController<K>
)
```

Note that the param type, `K`, is still the type of the key, not the type of a particular flavor of `BackstackFrame`.
This makes it easy for us to provide convenience functions to map lists of arbitrary model objects to `BackstackFrame` instances, so it's not much more verbose than it used to be to make it go.

Before:

```kotlin
 Backstack(backstack) { screen ->
   when(screen) {
     Screen.ContactList -> ShowContactList(navigator)
     is Screen.ContactDetails -> ShowContact(screen.id, navigator)
     is Screen.EditContact -> ShowEditContact(screen.id, navigator)
   }
 }
 ```

After:
```kotlin
Backstack(
  backstack.toBackstackModel { screen ->
    when(screen) {
      Screen.ContactList -> ShowContactList(navigator)
      is Screen.ContactDetails -> ShowContact(screen.id, navigator)
      is Screen.EditContact -> ShowEditContact(screen.id, navigator)
    }
  }
)
```

Note that there are two flavors of `toBackstackModel`. The second one supports models with more interesting keys.

```kotlin
data class Portrait(
  val id: Int,
  val url: String
)

Backstack(
  backstack.toBackstackModel(
    getKey = { it.id }
  ) {
    PrettyPicture(it.url)
  }
)
```

Fixes #63
@rjrjr rjrjr marked this pull request as ready for review August 27, 2022 02:50
Copy link
Collaborator

@zach-klippenstein zach-klippenstein left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know you're in a time crunch and I like the overall direction so approving. But this API feels very unidiomatic for Compose.

I really want to be able to write:

Backstack(listOfMyModels, key = { it.key }) { model ->
  Content()
}

I think the only thing preventing you from making that API right now is that both the Backstack function and the FrameController class need to know of the frame type, and right now that would require passing the key function in twice. I think we could solve that by passing in a frame controller factory instead of the controller itself, and the factory would create a controller given the key function. Then Backstack could instantiate the actual controller itself.

Anyway, that's quite a significant change, and I'm not 100% confident it is the right move either, so this is fine for now.

Comment on lines +19 to +21
model: M,
key: K,
crossinline content: @Composable (M) -> Unit
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you need to take both the model and a function that takes the model? If you just take the function, the caller can just capture the model, right?

}
}

inline fun <reified M: Any> List<M>.toBackstackModel(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: either call this mapToBackstackModels or at least pluralize?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Counter nit: List is a Backstack model.

* with [Composable] code that can display them, suitable for use
* with [Backstack].
*/
interface BackstackFrame<out K : Any> {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I think you might notice better recomposition performance if you mark this interface as @Stable. But also very possibly not, since it's going to always be in a List which is not stable. Would be something to measure/test.


/**
* Models a frame in a Backstack, with a unique [key] to identify it,
* and a [Content] function to display it.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: it would be helpful to explain to maybe what the key is used for so that people would have some idea of what sort of thing to pass in.


/**
* A frame controlled by a [FrameController], to be shown by [Backstack].
*/
@Immutable
data class BackstackFrame<out T : Any>(
val key: T,
data class FrameAndModifier<out K : Any>(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Modified(Backstack)Frame?

val popping = toKey in fromKeys
val fromFrame = fromFrames.last()
val toFrame = toFrames.last()
val popping = fromFrames.firstOrNull { it.key == toFrame.key } != null
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
val popping = fromFrames.firstOrNull { it.key == toFrame.key } != null
val popping = fromFrames.any { it.key == toFrame.key }

val progress = Animatable(0f)

val fromVisibility = derivedStateOf { 1f - progress.value }
val toVisibility = progress.asState()

// Wrap modifier functions in each their own recompose scope so that if they read the visibility
// (or any other state) directly, the modified node will actually be updated.
@SuppressLint("UnnecessaryComposedModifier")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So glad I put that comment in – I hope i wrote a test too 😅

*
* The [backstack] must follow some rules:
* The [frames] list must follow some rules:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Frames list" is a lot less meaningful than "backstack", since the frame list semantically is a backstack, so I would prefer keeping backstack here.

Suggested change
* The [frames] list must follow some rules:
* The [backstack][frames] must follow some rules:

* - Must always contain at least one item.
* - Items in the stack must implement `equals` and not change over the lifetime of the screen.
* If an item changes, it will be considered a new screen and any state held by the screen will
* - Keys must implement `equals` and cannot change over the lifetime of the screen.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* - Keys must implement `equals` and cannot change over the lifetime of the screen.
* - [Keys][BackstackFrame.key] must implement `equals` and cannot change over the lifetime of the screen.

@lucamtudor
Copy link

following some talks & chats, I've landed on this PR. @rjrjr can you drop an update, just so I understand what's your current thinking on all this? I'm looking for a "pure Compose backstack container" solution (because of square/workflow-kotlin#750, square/workflow-kotlin#669)

@rjrjr
Copy link
Owner Author

rjrjr commented Jan 18, 2023

Wow, we left you dangling. Very sorry.

I want to go back to that PR and clean up the API in the way that @zach-klippenstein suggested, but haven't managed to make time for it yet. What I should really do is ask him to pair with me.

But the PR worked well as is, if you'd want to just fork. :/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Map-based backstack crashes when popping.
3 participants