Skip to content

Conversation

@rjrjr
Copy link
Collaborator

@rjrjr rjrjr commented Nov 5, 2021

First of several PRs replacing #202 and addressing #195, overhauling workflow-ui. Aimed at long living branch ray/ui-update.

Step One (this PR): Screen and WithEnvironment : Screen

  • Decouples ViewEnvironment and ViewRegistry from Android
  • Introduces ViewEnvironment.updateFrom to simplify combining environments and their nested ViewRegistry entries.
  • Introduces the Screen marker interface, similar to the one in workflow-swift
  • Introduces the WithEnvironment rendering type as the preferred way to manage the ViewEnvironment, and makes WorkflowLayout use it
  • Promotes BackStackScreen to workflow-ui:core-common
  • Updates all non-Compose samples accordingly

This work is intended to be mostly backward compatible. My goal is that client code should require no more than updating some imports and ignoring a lot of @Deprecated warnings to upgrade.

Was Is
RenderingT : Any RenderingT : Screen
LayoutRunner ScreenViewRunner
ViewFactory<T> ViewRegistry.Entry<T>, ScreenViewFactory<T: Screen> : Entry<T>
AndroidViewRendering AndroidScreen
WorkflowViewStub.update(Any) WorkflowViewStub.show(Screen)
ViewRegistry.buildView(Any, ViewEnvironment, ...) Screen.buildView(ViewEnvironment, ...)
WorkflowLayout.start(Flow<Any>) WorkflowLayout.take(Flow<Screen>)

Yes, Screen is kind of a weird choice when what we really mean is View. But it's well established in workflow-swift, and doesn't seem to confuse anyone. More on this below.

Step Two: Overlay replaces ModalContainer

ModalContainer and the Compose support are basically unchanged in this PR, and still built against the now deprecated machinery. They'll be addressed in follow ups, also merged to ray/ui-update.

The Modal changes will be pretty drastic. The idea is to introduce another marker interface, Overlay<T>, and a corresponding OverlayDialogRunner<OverlayT : Modal> : ViewRegistry.Entry<ModalT>. OverlayDialogRunner will replace the abstract methods in ModalContainer, and also ModalContainer.DialogRef.

With that, we won't have to make a new FrameLayout subclass every time we want a new type of dialog. To show, say, an alert over a modal backstack panel over a body, we can render something like:

BodyAndOverlays(
  body = HomeScreen(),
  overlays: listOf(
    AlertModal("Oh Well", "Guess you screwed up", listOf(AlertButton("I sure did"))),
    CardModal(BackStackScreen(StepOne(), StepTwo())
  )
)

Step Three: Compose

Hoping this lets us do something cleaner than extending AndroidViewRendering<Nothing>. Current strawman:

  • ScreenComposable<T: Screen> : ViewRegistry.Entry<T>
  • Screen.buildView
    • looks for ScreenComposable if it fails to find ScreenViewFactory
    • If a ScreenComposable is found, returns a ScreenViewFactory like today's ComposeViewFactory -- creates a ComposeView and uses it to host ScreenComposable.content()
  • @Composable Screen.BoxRendering(rendering: Screen, modifier: Modifier)
    • Basically today's WorkflowRendering, which uses Box() but didn't document that fact
    • Combines WorkflowViewStub and getViewFactoryForRendering(), is that okay?
  • ComposeScreen : Screen is like today's ComposeRendering, analog to AndroidScreen

This will probably require replacing the ViewRegistry interface with something that it's actually possible to wrap, in order to:

Terminology and concepts: Why "Screen"?

On the main branch, the only thing that I can map a rendering type to is a ViewFactory, which can only build android.view.View. The driving goal of the work kicked off by this PR is to make it possible for us to map rendering types to other things. Most immediately, we'd like to be able to map some rendering types directly to android.app.Dialog, without having to define an entire new classes of android.view.View to host them.

Screen is the marker interface for something view-like.

  • In classical Android, it maps to android.view.View
  • In Compose, it maps to a @Composable function that gets wrapped in Box() {}

Modal is a terrible name, so we'll replace it with Overlay. It is the marker interface for something window-like.

  • In classical Android, it maps to android.app.Dialog at the moment
  • In the past, it has mapped to a nested FrameLayout that acted window-like. That might happen again.
  • In Compose, dunno yet. Maybe it always falls back on the classic Android implementation. Maybe something entirely different.

The implementations that Screen and Overlay bind to are not interesting. The fact that we could easily implement both via View, or via @Composable, is not interesting. The key distinction is their behavior, and that they are definitely not interchangable.

We don't want Rendering as the marker interface for something view-like. The RenderingT type parameter of the Workflow interface exists already. Having an interface with the same name would be terribly misleading. A workflow can render Screens, it can render Overlays, or it can render anything else at all, even things completely unrelated to UI concerns.

We can't co-opt View as that marker interface either. In both Android and iOS, View is no longer just UI terminology, it indicates a concrete class. An Android developer reads FooView to mean a subclass of android.view.View. For the same reason, Overlay is more attractive than Dialog or Window.

Screen is nice because it isn't used yet, and yet it's familiar. "I was on the home screen, I was on the checkout screen." The term was in widespread use long before Workflow showed up, and when we started using it as our marker term in Swift, it was understood intuitively. And back to the "why not View" point, it is not uncommon in our code base for both class FooScreen and class FooView to exist, where the former is the model for the latter.

Both Screen and Overlay are examples of "UI renderings." UI rendering is likely to be a useful term in documentation, but we have no need for a common parent interface for them to extend. Screen : Any, there is no need for Screen : UiRendering. Solves no problems.

Some Q & A:

What’s the name of the component/idea that the Workflow runtime uses to convert the Container into the various Screen+Overlays that show up on screen?

In main today, that’s ViewFactory. In this PR, it’s ViewRegistry.Entry. There is still only one type of Entry in this PR, but I'm looking to follow up quickly with at least two more:

interface ScreenViewFactory<T: Screen> : ViewRegistry.Entry<T>

interface OverlayDialogFactory<T : Overlay> : ViewRegistry.Entry<T>

interface ScreenComposableFactory<T: Screen> : ViewRegistry.Entry<T>

How does the Workflow runtime convert a Container (composed of Screens and Overlays) into Views+Dialogs? Like who does the mapping?

Consider this example:

class AlertModal(
  val title: String,
  val body: String,
  val buttons: List<AlertButton>
) : Overlay

class CardModal<S: Screen>(val content: S) : Overlay

class BodyAndOverlays<O: Overlay, B: Screen>(
  body: B,
  overlays: List<O>
): Screen

A workflow might render this:

BodyAndOverlays(
  body = HomeScreen(),
  overlays: listOf(
    AlertModal("Oh Well", "Guess you screwed up", listOf(AlertButton("I sure did"))),
    CardModal(BackStackScreen(StepOne(), StepTwo())
  )
)

To turn that into View and Dialog instances, in the new world:

  • BodyAndOverlays.buildView() is called, probably by WorklowViewStub.show().

  • buildView() finds the associated ScreenViewFactory<BodyAndOverlays<*, *>>, which probably inflates a BodyAndOverlaysContainer : FrameLayout, and calls a private show(BodyAndOverlays, ViewEnvironment) method on it.

  • That method calls WorklowViewStub.show(rendering.body) to show the body, and something like [TBD] Overlay.buildDialogRunner(coveringView: this) for each entry in rendering.overlays

@rjrjr rjrjr force-pushed the ray/introducing-Screen branch from 1417717 to 23488af Compare November 5, 2021 15:27
*/
@WorkflowUiExperimentalApi
public fun View.showFirstRendering() {
showRendering(getRendering()!!, environment!!)
Copy link
Collaborator Author

@rjrjr rjrjr Nov 5, 2021

Choose a reason for hiding this comment

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

I want to replace showRendering(Any) with showScreen(Screen), but this PR is already big enough.

*/
@WorkflowUiExperimentalApi
@PublishedApi
internal class LayoutScreenViewFactory<RenderingT : Screen>(
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Tempted to change all of these RenderingT param types to ScreenT. Downside is that we lose the hint that these are expected to be the RenderingT from a workflow. Probably won't mess.

import kotlin.reflect.KClass

/**
* The [ViewEnvironment] service that can be used to display the stream of renderings
Copy link
Collaborator Author

@rjrjr rjrjr Nov 5, 2021

Choose a reason for hiding this comment

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

Needs to be updated to be less Android specific, and fix broken links. Will get to it in follow up.

@rjrjr rjrjr force-pushed the ray/introducing-Screen branch from 1e9de52 to 193ab93 Compare November 5, 2021 18:39
@rjrjr rjrjr marked this pull request as ready for review November 5, 2021 18:39
@rjrjr rjrjr requested review from a team and zach-klippenstein as code owners November 5, 2021 18:39
@rjrjr rjrjr force-pushed the ray/introducing-Screen branch from 193ab93 to 49183bb Compare November 5, 2021 20:58
@rjrjr rjrjr force-pushed the ray/introducing-Screen branch from 49183bb to 24a7ba4 Compare November 5, 2021 23:43
@rjrjr
Copy link
Collaborator Author

rjrjr commented Nov 5, 2021

Spent the rest of the day polishing this up -- updating samples, muting deprecations, fixing docs. @steve-the-edwards, @Blisse, @mbrubin56-square, @helios175, I'd like to get LGs from at least a couple of you before merging. I don't know if it'll be green before I give up for the day, but it'll be close.

I think I'll update the Compose stuff on Monday. Shouldn't be a drastic change, just clean up a couple of warts now that ViewRegistry is more flexible. Thanks for leaving things so tidy, @zach-klippenstein.

@rjrjr rjrjr force-pushed the ray/introducing-Screen branch 4 times, most recently from 93ed6f1 to bd6184c Compare November 8, 2021 17:57
@rjrjr rjrjr force-pushed the ray/introducing-Screen branch from cca7597 to fed57f6 Compare November 8, 2021 21:12
@rjrjr rjrjr force-pushed the ray/introducing-Screen branch 2 times, most recently from 2901a45 to cc4cfcc Compare November 8, 2021 22:08
@rjrjr
Copy link
Collaborator Author

rjrjr commented Nov 8, 2021

Oh joy. samples:hello-workflow-fragment:connectedDebugAndroidTest failing consistently on API 21 only:

Caused by: java.lang.NullPointerException: Attempt to invoke virtual method 'androidx.activity.result.contract.ActivityResultContract$SynchronousResult androidx.activity.result.contract.ActivityResultContract.getSynchronousResult(android.content.Context, java.lang.Object)' on a null object reference
at androidx.activity.ComponentActivity$2.onLaunch(ComponentActivity.java:157)
at androidx.activity.OnBackPressedDispatcher$LifecycleOnBackPressedCancellable.onStateChanged(OnBackPressedDispatcher.java:233)
at androidx.lifecycle.LifecycleRegistry$ObserverWithState.dispatchEvent(LifecycleRegistry.java:354)
at androidx.lifecycle.LifecycleRegistry.forwardPass(LifecycleRegistry.java:265)
at androidx.lifecycle.LifecycleRegistry.sync(LifecycleRegistry.java:307)
at androidx.lifecycle.LifecycleRegistry.moveToState(LifecycleRegistry.java:148)
at androidx.lifecycle.LifecycleRegistry.handleLifecycleEvent(LifecycleRegistry.java:134)
at androidx.fragment.app.Fragment.performStart(Fragment.java:3026)
at androidx.fragment.app.FragmentStateManager.start(FragmentStateManager.java:589)
at androidx.fragment.app.FragmentStateManager.moveToExpectedState(FragmentStateManager.java:300)
at androidx.fragment.app.FragmentStore.moveToExpectedState(FragmentStore.java:112)
at androidx.fragment.app.FragmentManager.moveToState(FragmentManager.java:1647)
at androidx.fragment.app.FragmentManager.dispatchStateChange(FragmentManager.java:3128)
at androidx.fragment.app.FragmentManager.dispatchStart(FragmentManager.java:3079)
at androidx.fragment.app.FragmentController.dispatchStart(FragmentController.java:262)
at androidx.fragment.app.FragmentActivity.onStart(FragmentActivity.java:510)
at androidx.appcompat.app.AppCompatActivity.onStart(AppCompatActivity.java:246)
at android.app.Instrumentation.callActivityOnStart(Instrumentation.java:1220)
at androidx.test.runner.MonitoringInstrumentation.callActivityOnStart(MonitoringInstrumentation.java:723)
at android.app.Activity.performStart(Activity.java:5953)
at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2261)
... 10 more

Notice the conspicuous absence of any workflow code in that stack.

@rjrjr rjrjr force-pushed the ray/introducing-Screen branch 2 times, most recently from fef4c41 to edd4fcc Compare November 8, 2021 23:39
@rjrjr rjrjr force-pushed the ray/introducing-Screen branch from ae60cee to bd658fd Compare November 10, 2021 00:05
* Typically the rendering type (`RenderingT`) of the root of a UI workflow,
* but can be used at any point to modify the [ViewEnvironment] received from
* a parent view.
*/
Copy link
Contributor

Choose a reason for hiding this comment

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

  • Introduces the Screen marker interface, similar to the one in workflow-swift
  • Introduces the RootScreen rendering type as the preferred way to manage the ViewEnvironment, and makes WorkflowLayout use it

What is RootScreen

I guess I'm trying more to understand what does "Root" mean in the context of Workflows here. In my mind Root almost means singular, but this seems more generic.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It might be a bad name. Really it's just a screen that can wrap another and provide a new ViewEnvironment at the same time. The 99% use case is at the top of a view hierarchy, and so that's what I named it.

Copy link
Contributor

Choose a reason for hiding this comment

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

Makes sense as a utility. I think that we need a new dominant metaphor for naming this as the 'tree' metaphor is lacking here when talking about transforming the ViewEnvironment.

Copy link
Contributor

@steve-the-edwards steve-the-edwards left a comment

Choose a reason for hiding this comment

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

I have a huge bikeshed I'd like to paint as I wholeheartedly do not resonate with the Screen naming. This is especially a problem when we introduce Modal as that may begin to suggest that a Modal is anything less than a "full" "Screen" when that it is explicitly not what it wants to represent. Can talk in slack or elsewhere.

@rjrjr
Copy link
Collaborator Author

rjrjr commented Nov 10, 2021

I have a huge bikeshed I'd like to paint as I wholeheartedly do not resonate with the Screen naming. This is especially a problem when we introduce Modal as that may begin to suggest that a Modal is anything less than a "full" "Screen" when that it is explicitly not what it wants to represent. Can talk in slack or elsewhere.

https://workflow-community.slack.com/archives/CHTFPR277/p1636572894038600

@rjrjr
Copy link
Collaborator Author

rjrjr commented Nov 11, 2021

Gonna try to summarize the bikeshed here.

On the main branch, the only thing that I can map a rendering type to is a ViewFactory, which can only build android.view.View. The driving goal of the work kicked off by this PR is to make it possible for us to map rendering types to other things. Most immediately, we'd like to be able to map some rendering types directly to android.app.Dialog, without having to define an entire new classes of android.view.View to host them.

Responding @steve-the-edwards's immediate concern: Screen will indeed be the marker interface for something view-like.

  • In classical Android, it maps to android.view.View
  • In Compose, it maps to a @Composable function that gets wrapped in Box() {}

Modal is a terrible name, and will probably be replaced with Overlay. It is the marker interface for something window-like.

  • In classical Android, it maps to android.app.Dialog at the moment
  • In the past, it has mapped to a nested FrameLayout that acted window-like. That might happen again.
  • In Compose, dunno yet. Maybe it always falls back on the classic Android implementation. Maybe something entirely different.

The implementations that Screen and Overlay bind to are not interesting. The fact that we could easily implement both via View, or via @Composable, is not interesting. The key distinction is their behavior, and that they are definitely not interchangable.

We don't want Rendering as the marker interface for something view-like. The RenderingT type parameter of the Workflow interface exists already. Having an interface with the same name would be terribly misleading. A workflow can render Screens, it can render Overlays, or it can render anything else at all, even things completely unrelated to UI concerns.

We can't co-opt View as that marker interface either. In both Android and iOS, View is no longer just UI terminology, it indicates a concrete class. An Android developer reads FooView to mean a subclass of android.view.View. For the same reason, Overlay is more attractive than Dialog or Window.

Screen is nice because it isn't used yet, and yet it's familiar. "I was on the home screen, I was on the checkout screen." The term was in widespread use long before Workflow showed up, and when we started using it as our marker term in Swift, it was understood intuitively. And back to the "why not View" point, it is not uncommon in our code base for both class FooScreen and class FooView to exist, where the former is the model for the latter.

Both Screen and Overlay are examples of "UI renderings." UI rendering is likely to be a useful term in documentation, but we have no need for a common parent interface for them to extend. Screen : Any, there is no need for Screen : UiRendering. Solves no problems.

Some Q & A:

What’s the name of the component/idea that the Workflow runtime uses to convert the Container into the various Screen+Modals that show up on screen?

In main today, that’s ViewFactory. In this PR, it’s ViewRegistry.Entry. There is still only one type of Entry in this PR, but I'm looking to follow up quickly with at least two more:

interface ScreenViewFactory<T: Screen> : ViewRegistry.Entry<T>

interface ModalDialogFactory<M : Modal> : ViewRegistry.Entry<M>

interface ScreenComposableFactory<T: Screen> : ViewRegistry.Entry<T>

How does the Workflow runtime convert a Container (composed of Screens and Modals) into Views+Dialogs? Like who does the mapping?

Consider this example:

class AlertModal(
  val title: String,
  val body: String,
  val buttons: List<AlertButton>
) : Overlay

class CardModal<S: Screen>(val content: S) : Overlay

class BodyAndOverlays<O: Overlay, B: Screen>(
  body: B,
  overlays: List<O>
): Screen

A workflow might render this:

BodyAndOverlays(
  body = HomeScreen(),
  overlays: listOf(
    AlertModal("Oh Well", "Guess you screwed up", listOf(AlertButton("I sure did"))),
    CardModal(BackStackScreen(StepOne(), StepTwo())
  )
)

To turn that into View and Dialog instances, in the new world:

  • The BodyAndOverlays rendering gets passed to ViewEnvironment.buildView(Screen), probably by WorklowViewStub.show().

  • buildView() finds the associated ScreenViewFactory<BodyAndOverlays<*, *>>, which probably inflates a BodyAndOverlaysContainer : FrameLayout, and calls a private show(BodyAndOverlays, ViewEnvironment) method on it.

That method calls WorklowViewStub.show(rendering.body) to show the body, and something like [TBD] ViewEnvironment.buildDialogRunner(Overlay) for each entry in rendering.overlays

@rjrjr
Copy link
Collaborator Author

rjrjr commented Nov 11, 2021

Maybe I should add the comment above to the commit message.

@rjrjr
Copy link
Collaborator Author

rjrjr commented Nov 11, 2021

Hmmmm. Probably this:

fun <T: Screen> ViewEnvironment.buildView(screenRendering: T): View

should be this:

fun <T: Screen> T.buildView(environment: ViewEnvironment): View

Makes the marker interface serve more of a purpose, and is an even closer parallel to how the Swift code works.

@steve-the-edwards @Blisse @mbrubin56-square WDYT?

@steve-the-edwards
Copy link
Contributor

Hmmmm. Probably this:

fun <T: Screen> ViewEnvironment.buildView(screenRendering: T): View

should be this:

fun <T: Screen> T.buildView(environment: ViewEnvironment): View

Makes the marker interface serve more of a purpose, and is an even closer parallel to how the Swift code works.

@steve-the-edwards @Blisse @mbrubin56-square WDYT?

Hmmmm. Probably this:

fun <T: Screen> ViewEnvironment.buildView(screenRendering: T): View

should be this:

fun <T: Screen> T.buildView(environment: ViewEnvironment): View

Makes the marker interface serve more of a purpose, and is an even closer parallel to how the Swift code works.

@steve-the-edwards @Blisse @mbrubin56-square WDYT?

I like it a lot. The Screen extension should know how to build its view with the help of the viewEnvironment not the other way around.

As for RootScreen - does it make any sense to be EnvironmentRoot? We could have a separate extension there that uses its own viewEnvironment to build the view.

fun <T: EnvironmentRoot> T.buildView(): View

@rjrjr
Copy link
Collaborator Author

rjrjr commented Nov 11, 2021

As for RootScreen - does it make any sense to be EnvironmentRoot? We could have a separate extension there that uses its own viewEnvironment to build the view.

fun <T: EnvironmentRoot> T.buildView(): View

I like the extra extension. How about WithEnvironment as the class name?

@rjrjr
Copy link
Collaborator Author

rjrjr commented Nov 11, 2021

Actually…I can't find any place that we would ever actually call the WithEnvironment.buildView() overload, because WorkflowViewStub. Gonna refrain.

@rjrjr rjrjr force-pushed the ray/introducing-Screen branch from fade109 to bde238a Compare November 11, 2021 17:48
@rjrjr rjrjr changed the title Introduces Screen, RootScreen. Deprecates ViewFactory Introduces Screen, WithEnvironment : Screen. Deprecates ViewFactory Nov 11, 2021
@rjrjr
Copy link
Collaborator Author

rjrjr commented Nov 11, 2021

Okey doke, I have…

  • Moved ViewEnvironment.buildView() to Screen
  • Renamed RootScreen to WithEnvironment
  • Updated the PR title and first comment to reflect these changes, and overhauled the comment to include the bikeshed recap.

I'll plan to merge later today, barring any further feedback.

Renames `RootWorkflow` to `WithEnvironment`. Moves `ViewEnvironment.buildView` to `Screen`.
@rjrjr rjrjr force-pushed the ray/introducing-Screen branch from bde238a to 3f479a8 Compare November 11, 2021 18:12
@rjrjr rjrjr merged commit f82f32f into ray/ui-update Nov 11, 2021
@rjrjr rjrjr deleted the ray/introducing-Screen branch November 11, 2021 19:40
* @throws IllegalArgumentException if no factory can be find for type [RenderingT]
*/
@WorkflowUiExperimentalApi
public fun <RenderingT : Any>
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

These are nearly verbatim copies of existing extensions on ViewRegistry.

import org.junit.Test

@OptIn(WorkflowUiExperimentalApi::class)
internal class DecorativeScreenViewFactoryTest {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

copy / paste

import android.view.ViewGroup
import kotlin.reflect.KClass

/**
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

copy / paste

import kotlin.reflect.KClass

/**
* A [ScreenViewFactory] that creates [View]s that need to be generated from code.
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

copy / paste of BuilderViewFactory

* implement this interface at all. For details, see the three overloads of [ScreenViewRunner.bind].
*/
@WorkflowUiExperimentalApi
public fun interface ScreenViewRunner<RenderingT : Screen> {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

copy / paste of LayoutRunner

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.

6 participants