square / workflow-kotlin

A Swift and Kotlin library for making composable state machines, and UIs driven by those state machines.
https://square.github.io/workflow
Apache License 2.0
1.03k stars 101 forks source link

Decouple modality from modal visual treatment #195

Closed rjrjr closed 2 years ago

rjrjr commented 4 years ago

@bencochran @zach-klippenstein @AquaGeek @dhavalshreyas

I had a chat last week with @helios175, and he schooled me on decoupling modal visual style from layering.

e.g., in Kotlin right now we have a number of samples where the top level rendering type is something like…

AlertContainerScreen<CardContainerScreen<BackstackScreen<Any>>

That is, you can show alerts over cards. Not so bad. But in our prodution app, where we have both a number of modal layers and a number of modal styles, the combinatorics get kind of nuts, something like:

typealias Root =
    AlertContainerScreen<
        TutorialBarsContainerScreen<
            CardContainerScreen<
                SheetContainerScreen<
                    CardContainerScreen<Any, Any>,
                    Any
                    >,
                Any
                >
            >
        >

because we want

Instead of this, the "big idea" (probably obvious to anyone who isn't me) is to have a single container type that is able to make things modal, wrapping others that own the visual style. This works because our modal screens already hold lists to allow visual stacking.

So the above would reduce to something like:

typealias Root =
    ModalContainerScreen<
        TutorialBarsContainerScreen<
            ModalContainerScreen<Any, Any>
            >,
        Any
        >

With AlertModal<T>, CardModal<T>, SheetModal<T>, BottomSheetModal<T>, etc., as possible contituents. If I want to show an alert over a card over a sheet, my root workflow assembles the equivalent of:

ModalContainerScreen(
  modals = listOf(AlertModal(...)),
  body = TutorialBarsContainerScreen(
    ModalContainerScreen(
      modals = listOf(
        SheetModal(...), CardModal(...)
      )
      body = ...
    )
  )
)

The downside here is that some layering rules become slightly more runtime. E.g. we don't want every child workflow that may want to show modals to have to know anything about tutorials, or these layering rules. An inner workflow should be able to render something like:

ModalContainerScreen(
  modals = listOf(CardModal(...), AlertModal("Are you sure?", ...)),
  body = ...
)

So some tutorial-aware outer workflow will need to filter the modals lists of its children, and move AlertModal instances to an outer layer. But that doesn't seem like a big deal.

Anyway, I'm giving myself permission to play with this today because I need to solve a problem where I'm getting double-darkness when showing cards over cards.

WDYT?

bencochran commented 4 years ago

I’ve been leaning in this direction in things I’ve been working on lately.

One nice side-effect of it is other changes can happen at “runtime” too, for example: on tablet-sized devices we want some modal content to slide-over from the edge of the screen (what Dashboard web internally calls a “blade” today) but on phone-sized screens we want that to be a full-screen presentation. In my current modeling, the “inner” workflow specifies the target style for a modal like you’ve described, but the container itself can use the environment to decide to use a different presentation style (also the presentation style is added to the environment of the content for view-side internal styling that that content might need).

rjrjr commented 4 years ago

Maybe at the same time we can adopt these interfaces for our renderings, to get a bit closer to what Swift did to banish ViewRegistry:

interface ViewRendering {
  fun buildView(
    viewEnvironment: ViewEnvironment,
    context: Context,
    container: ViewGroup? = null
  ) = viewEnvironment[ViewRegistry].buildView(this, context, container)
}
interface ModalRendering {
  fun buildDialog(
    viewEnvironment: ViewEnvironment
  ) : StealDialogRunnerInterfaceFromHelios = andADefaultImplementationToo()
}
rjrjr commented 4 years ago

Or even:

interface ViewRendering {
  fun asViewFactory(
    viewEnvironment: ViewEnvironment
  ) = viewEnvironment[ViewRegistry].getFactoryFor(this)
}
interface ModalRendering {
  fun asDialogRunner(
    viewEnvironment: ViewEnvironment
  ) : StealDialogRunnerInterfaceFromHelios = andADefaultImplementationToo()
}
rjrjr commented 4 years ago

With these conventions, a lot of our SomeContainer<T: Any> types can be a bit more strict, and self documenting.

class BackStackScreen<StackedT : ViewRendering>(
  bottom: StackedT,
  rest: List<StackedT>
)
class ModalContainerScreen<BodyT: ViewRendering>(
  body: BodyT,
  modals: List<ModalRendering>
)
rjrjr commented 4 years ago

Anyway: the reason for the ViewRendering / ModalRendering tangent is because I need some kind of hook for building dialogs, similar to ViewFactory. It's not just a tangent.

rjrjr commented 4 years ago

Fair warning: this ticket is only going to get more stream of consciousness.

Realizing that the DialogRef object in ModalContainer could be replaced with tags on Window.decorView. Meaning that we can create a set of extensions on Dialog just like those on View -- Dialog.canShowRendering, Dialog.showRendering, etc.

So, should be able to have ViewRegistry manage DialogFactory instances just like it manages ViewFactory.

Should also be able to refine View.showRendering et al to require the ViewRendering interface, and likewise for these Dialog extensions and a ModalRendering interface.

rjrjr commented 4 years ago

Could just make ViewRendering and ModalRendering marker interfaces, and separately give them asViewFactory() and asDialogFactory() extensions. Even closer to the Swift pattern, and cannot be abused by making renderings implement those methods directly.

That's pretty much what we're doing internally already. It's caused some confusion there, but I think mainly due to a bad interface name and a bad initial implementation of the extension method.

rjrjr commented 4 years ago
@WorkflowUiExperimentalApi
interface ViewRendering<RenderT : ViewRendering<RenderT>>

@WorkflowUiExperimentalApi
fun <RenderT: ViewRendering<RenderT>> RenderT.asViewFactory(
    env: ViewEnvironment
  ): ViewFactory<RenderT> = env[ViewRegistry].getFactoryFor(this::class)
rjrjr commented 4 years ago

And its close friend:

@WorkflowUiExperimentalApi
interface ModalRendering<RenderT : ModalRendering<RenderT>>

@WorkflowUiExperimentalApi
fun <RenderT: ModalRendering<RenderT>> RenderT.asDialogFactory(
    env: ViewEnvironment
  ): DialogFactory<RenderT> = env[ViewRegistry].getDialogFactoryFor(this::class)

Perhaps we also get rid of the buildView and buildDialog extensions on ViewRegistry. Or rather, make them RenderT extensions instead of these asFooFactory extensions. Back to the first sketch above.

rjrjr commented 4 years ago

And make WorkflowViewStub work in terms of ViewRendering. Don't think there's a WorkflowViewStub analog to ModalRendering, but that's fine. Or rather, that analog is a non-abstract replacement for ModalContainer.

rjrjr commented 4 years ago

Took a few side trips, but I think I'm closing on it.

All of the above is working. Next step is to write the replacement for abstract class ModalContainer : FrameLayout, tomorrow's job. I think that's going to look like this:

class ModalContainerViewRendering<out ViewT: ViewRendering, out ModalT: ModalRendering>(
  val beneathModals: ViewT,
  val modals: List<ModalT>
) : ViewRendering
/** Should be able to stand in for ModalContainer and all of its subclasses. */
class ModalContainerView() : FrameLayout() {
  // BespokeViewBuilder<R: ViewRendering> is the new BuilderViewFactory<R: Any>
  companion object : ViewBuilder<ModalContainerViewRendering<*, *>>
  by BespokeViewBuilder( ) {

  }
}
rjrjr commented 2 years ago

A year later and work on this has resumed, on branch https://github.com/square/workflow-kotlin/tree/ray/ui-update. Here's the plan outlined in the first PR to that branch, #577:

Step One (#577): Screen and WithEnvironment : Screen

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:

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.

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

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:

rjrjr commented 2 years ago

Working on Overlay / Dialog now, with a lot of help from @helios175. Here's the current thinking.

There are two tricky things in this space: z order, and x/y bounds.

Z Order

So long as we stick with Android Dialogs (they're just easier than using Window directly), we're pretty screwed on the z order front. They stack up in the order they are shown.

That mostly works quite well, but falls apart if, say, an alert is showing that's meant to cover everything, but suddenly the body view decides it needs to show a tool tip that you'd expect to be under that alert. It won't be.

So far the plan is to limp along with our current approach, so you might want to skip to the next section. But for the record, here's what we might get to at some point:

The only solution we've found is to have a global backpane responsible for managing all dialogs, which can simulate an insert by hiding everything above the new index; showing the inserted dialog; and showing everything that was hidden. Besides being just, kind of a pain in the ass, this means that the views in the re-positioned dialogs would get more onDetach and onAttach events than they might expect. We can live with that.

Another alternative is to abandon windows entirely and instead simulate them with views, e.g. as overlapping children in a FrameLayout. We've done that in the past and it was a nightmare to manage all the touch, key, focus and accessibility events. That might be more managable these days. It might not. Dunno. It also might cost us some per-window optimizations, that's also unclear to me.

So basically, our choices here are to use real windows with one hand tied behind our backs (no control over z order); or to build our own window system out of views. At the moment sticking with the former is the plan, with the backplane being a maybe-we'll-get-to-that aspiration. Not great.

I was really hoping that the Compose crew were going to clean up this mess, but I haven't seen any signs of that. Seems like they'll have to get there eventually. A declarative system where I can't declare my layers and counting them to stay in the declared order is pretty hamstrung. (cc @zach-klippenstein)

Note that if we do eventually decide on a different approach, e.g. build that view-based window system, none of our workflows will need to change. We'll "just" centrally reconfigure how their renderings get mapped to UI, and then debug forever.

x/y bounds

A parent needs to be able to control what its children cover, or at least advise them on what's desirable. The classic case in Square's apps is that we have a device status bar at the top and a tutorial bar at the bottom that we don't want modals to interfere with. Except when we do want that.

Today we accomplish this with a ModalContainer subclass that forces the dialogs it manages to match the size of its body view. Our root RenderingT is something like the following (simplified, to say the least):

AlertContainerScreen<
  ModalViewContainerScreen<
    StatusAndTutorialBarsContainerScreen<
      ModalViewContainerScreen<Any>
    >
  >
>

Most feature code is run in workflows that are children of the one that renders StatusAndTutorialBarsContainerScreen. It passes their ModalViewContainerScreen renderings to a WorkflowViewStub that is sandwiched below the status bar and above the tutorial bar. Any modals they render are shown in dialogs that are automatically sized to the bounds of that inner view, and resized as the bars are shown and hidden. It works nicely.

Some higher priority features are run in workflows that are peers of the one that renders StatusAndTutorialBarsContainerScreen. If one of them needs a modal shown, its bounds will cover the two bars. And alerts can cover everything.

(Also, because we render bodies first and then show dialogs after, most of the time our show() calls happen in the order that gives the right layering. Most of the time. That's how we've managed to kick the can on a proper z-ordering solution for such a long time.)

The problem with this approach is that the only tool in our arsenal for mapping rendering types to the view system is ViewFactory. We don't have a declarative way of creating different kinds of dialogs for different kinds of modals. See how AlertContainerScreen is a different type than ModalViewContainerScreen? If we want to show more kinds of modals, and we do, we have to create more types of containers and do even more wrapping. We're talking about a serious combinatorial explosion, and the main motivation for this effort.

How the Overlay interface improves things

The main idea is to introduce a new ViewRegistry.Entry type, and an Overlay extension that uses it. (Really, probably two extensions, one for Dialog and one for some @Composable that gets wrapped in androidx.compose.ui.window.Dialog(), but that's getting ahead of ourselves.)

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

fun <T: Overlay> T.buildDialog(bounds: RectProvider): DialogRunner<T>

DialogRunner is just a wrapper around Dialog. It basically combines the duties of ScreenViewFactory and ScreenViewRunner, but for dialogs. (I had hoped to avoid it by storing state in tags on Dialog.decorView, but calling that method too early has nasty side effects.)

RectProvider is more interesting. It's the answer to the x/y bounds use cases.

Right now the nice view-bounds-tracking code I mentioned, not yet open sourced, is in an extension on Dialog:

fun Dialog.coverView(view)

It's nice enough, but it doesn't help with situations like tooltips or other popups where we want the window to track a view in a different way. And because it's View-specific, it's not going to be useful with Compose. RectProvider solves both of these problems.

interface RectProvider {
  // invoked once when it's set, and after every change on value
  var onChange: (Rect) -> Unit
}

// View case

class ViewRectProvider(val view: View) : RectProvider {
  private var cancelHandler: (() -> Unit)? = null
  override var onChange
    set(value) {
      cancelHandler?.invoke()
      field = value
      // listen to coordinates already calls "it" when invoked.
      // see wip: https://github.com/square/workflow-kotlin/commit/468ba828cb8c8b0a7d173deb92d3f68e57c129e7
      cancelHandler = value?.let { view.listenToCoordinates(it) }
    }
}

// usage:
runner.anchorRectProvider = ViewRect(anchorView) // runner will set onChange = null on dismissal

// Compose case

class ComposeRectProvider : RectProvider {
  var currentRect: Rect
    set(value) {
      currentRect
      onChange?.invoke(value)
    }

  override var onChange
    set(value) {
      field = value
      onChange?.invoke(currentRect)
    }
}

// usage: assume composeRectProvider and runner are remembered together
val composeRectProvider = ...
runner.anchorRectprovider = composeRectProvider
ComponentBeingTheAnchor(modifier = Modifier.onGlobalPositioned {
  // this runs once the node is positioned on screen
  // by setting the rect on the provider instance it'll call its onChange
  composeRectProvider.currentRect("it.toRectOnScreen()")
})

Each DialogRunner is given a RectProvider, probably as a constructor argument, which tells it where the container wants the dialog at any given moment.

The DialogRunner has the option to ignore the RectProvider, of course -- e.g. one showing AlertDialog would probably have to -- but we should be able to provide default implementations that make it convenient to honor.

To illustrate this, let's show

BodyAndModals(
  body = 
    BodyAndPopUp(
      body = HomeScreen(),
      popUp = TutorialPrompt(
        targetId = "magic tutorial tag value",
        content = NowClickChargeStep()
      )
    ),
  overlays: listOf(
    AlertModal("Oh Well", "Guess you screwed up", listOf(AlertButton("I sure did"))),
    CardModal(BackStackScreen(StepOne(), StepTwo())
  )
)

The container view built for BodyAndOverlays provides its own bounds through the RectProvider passed when calling buildDialog() on the AlertModal and the CardModal.

The nested container view built for BodyAndPopup finds a child element of the view built for HomeScreen, flagged somehow with targetId — maybe a View tag with id R.id.pop_up_target and the given value. It provides the bounds of that view when calling buildDialog() on TutorialPrompt : Overlay, and the returned DialogRunner positions itself above.

Of course, in this possible future we're still relying on different classes of container view for customization, but only when we want different policies for placement. I kind of like how that feels in the illustration above, since modal and popup use cases are pretty different. But if it proved problematic we could come up with a more complex grand unified overlay container, e.g. maybe one that makes different placement decisions based on the types of the overlays it's showing.

interface Prompt : Overlay {
  val targetId: String
}

BodyAndOverlays(
  body = HomeScreen(),
  overlays: listOf(
    AlertModal("Oh Well", "Guess you screwed up", listOf(AlertButton("I sure did"))),
    CardModal(BackStackScreen(StepOne(), StepTwo()),
    TutorialPrompt(
      targetId = "magic tutorial tag value",
      content = NowClickChargeStep()
    )
  )
)

I hope that the library doesn't need to be opinionated about these two possible approaches. If we can move all the complexity of today's ModalContainer and ModalViewContainer to the base implementation of DialogRunner, making app-specific containers should be easy.

Let's find out.

rjrjr commented 2 years ago

@helios175 Maybe there's a middle path, where we leave a hook like this in the FrameLayout that renders BodyAndOverlays

class BodyAndOverlaysLayout : FrameLayout {
  var rectProviders: (Overlay) -> RectProvider = { ViewRectProvider(this) }
}

If I have one of these at the root of my view hierarchy, I can provide a custom rectProvider that can, say, distinguish popups from inner modals from outer modals. Presto, my app has a single list of all dialogs, which can do the show / hide hack to maintain z ordering.

WDYT?

helios175 commented 2 years ago

Cardinality?

@rjrjr The RectProviders are meant to be only one per Runner. Each Runner sets the onChange to be notified. If you want to keep rect providers on the "producer side" then you'd have to be able to multiply the onChange calls. Create proxy implementations of RectProvider one per runner.

I think I get it

Now: what I understand is that rectProviders is a factory, and it can decide to return a different rect provider depending on which overlay it's meant to be used for, right? That sounds about right. I'm not sure if the issue is that you want to send different "cover areas" (rects) to different layers, right? And if you have a single list of dialogs, then... how do you do that? Sounds sensible.

One manager per modal <----> one manager per area <------> one manager.

I think in general there'll be one "manager" (the "ModalContainer") per proper area you want to cover but maybe that's because I'm used to that. I understand in some cases you'll want to provide a full-screen rect provider (for an alert) but if it's something that should cover only the "detail" pane. Sounds good, but...

What about still having different managers?. Not one manager per screen type as of now, but one manager per "cover area". One will be the whole app, another will be the work space (everything except status/tutorial bars), another can be the detail pane in a master/detail.

This is also inspired in my discussion of "view / window arrangements". Let's say we have a complex app: App

We want the detail sheets to cover the main space altogether. So the main space "renderer" gets the sheet property from the detail output, and adds it to its own "screen":

WDYT?

rjrjr commented 2 years ago

@rjrjr The RectProviders are meant to be only one per Runner. Each Runner sets the onChange to be notified. If you want to keep rect providers on the "producer side" then you'd have to be able to multiply the onChange calls. Create proxy implementations of RectProvider one per runner.

Yes, that's understood. I'm imagining that the container instantiates a new RectProducer to pass to each DialogRunner it creates.

rjrjr commented 2 years ago

What about still having different managers?. Not one manager per screen type as of now, but one manager per "cover area". One will be the whole app, another will be the work space (everything except status/tutorial bars), another can be the detail pane in a master/detail.

Right, I've been assuming something like that too. But it's still kind of complex -- we have to gather up all those managers in to a manager-of-managers to solve the z problem -- and I'm starting to think it isn't any more descriptive than what we could accomplish with just a single container with the one and only list of dialogs, and the ability to simply label coverage areas.

For your scenario, main space does the same transfomation that you describe. But where you would have put a manager, I'd add a wrapper rendering that identifies particular target areas.

OverlayArea(
  tag = R.id.main_area,
  MainScreen(main = MasterDetail(master, detail.copy(sheets = null), sheets = myDetail.sheet))
)

The OverlayArea view factory might call setTag(R.id.overlay_area, rendering.tag) on the view built for the wrapped MainScreen rendering.

Our root workflow wraps everything in a BodyAndOverlays. And our app provides a custom view factory for BodyAndOverlays that sets up rectProviders:

view : BodyAndOverlays = ...
view.rectProviders = { overlay ->
  when (overlay) {
    is Alert -> ViewRectProvider(view)
    else -> ViewRectProvider(view.findViewTag(R.id.main_area))
}

(findViewTag a very inefficient, of course, I'm just trying to get the idea across. Between ViewEnvironment and Compose locals, we could do much better.)

rjrjr commented 2 years ago

Gah, my tag code there is complete gibberish, but hopefully it gets the idea across.

rjrjr commented 2 years ago

The one overlay container to rule them all idea doesn't look practical. All modals are overlays, but not all overlays are modal. Due to long standing issues like https://stackoverflow.com/questions/2886407/dealing-with-rapid-tapping-on-buttons, our production modal container puts a lot of effort into flushing and ignoring events that aren't reliably blocked while a dialog revs up. Those efforts would be very bad for non-modal overlays such as toasts and popups.

For the current round of work I'm going shoot for something like this example from above:

BodyAndModals(
  body = 
    BodyAndPopUp(
      body = HomeScreen(),
      popUp = TutorialPrompt(
        targetId = "magic tutorial tag value",
        content = NowClickChargeStep()
      )
    ),
  overlays: listOf(
    AlertModal("Oh Well", "Guess you screwed up", listOf(AlertButton("I sure did"))),
    CardModal(BackStackScreen(StepOne(), StepTwo())
  )
)

In that world @helios175's example would look something like this after the entire tree rendered:

BodyAndModals(
  body = StatusAndTutorialBars(
    wrapped = BodyAndModals(
      body = OverviewDetailScreen(...),
      modals = listOf(Sheet())
  )
)

I'm not trying to end the discussion at all, just trying to make incremental progress from the current world.

rjrjr commented 2 years ago

Another day, another round of Overlay ideas.

Modal Rectangles

Rather than building any notion of rectangles directly into DialogStack, DialogRunner, and DialogFactory, I think we should use the ViewEnvironment.

When BodyAndModalContainer is showing modal Overlays, it puts one of these into each of their environments:

class Bounds(t: Int, l: Int, r: Int, b: Int)

object ModalArea: ViewEnvironment.Key<StateFlow<Bounds>>

It uses View.listenToCoordinates to keep the StateFlow up to date. Well behaved DialogFactory implementations are expected to look for ModalArea and honor it, where that's practical (an AlertDialog would ignore it IMHO). I expect to write a DialogFactory base implementation for Screen-based custom dialogs that does this automatically, as a replacement for ModalViewContainer, something like:

abstract class ModalDialogFactory<O: Overlay>(
  protected val bounds: StateFlow<Bounds>
) : OverlayDialogFactory<O>

Anchor Rectangles

For anchor rectangles we use the ViewEnvironment again. As you read this, note that it doesn't require any special support in the library. An app could build this itself.

interface AnchoredOverlay: Overlay {
  val id: String
}

class BodyAndAnchoredOverlays<A: AnchoredOverlay>(
  val body: Screen,
  val overlays: List<A> = emptyList()
)

class DeclareAnchor {
   internal var requested: Map<String, () -> StateFlow<Bounds>> = emptyMap()
     private set
   fun request(
     id: String,
     bounds: () -> StateFlow<Bounds>
   ) { ... }
}

object AnchorArea: ViewEnvironment.Key<StateFlow<Bounds>>

The container view for BodyAndAnchoredOverlay preps the environment for its body with an instance of DeclareAnchor. Any child of the body that has a subview that should serve as an anchor for a popup can do something like the following. (We've done similar things to allow children to request orientation changes.)

viewEnvironment[DeclareAnchor].request(this.listenToCoordinates(), SOME_ID)

Next, the container view will render its overlay, if it has one, and DeclareAnchor.requested has matching entry. It will prep the ViewEnvironment for the overlay's DialogFactory with an AnchorArea

A well behaved DialogFactory for one of these is expected to honor both AnchorArea and ModalArea -- @helios175, I think you're going to tell me that's needed? Again, I imagine we'd build that into a base class of some kind.

Modal event handling

The dropLateEvents call in BodyAndModalsContainer, and its [dispatchTouchEvent] override, are sadly necessary to avoid occasional crashes when openinig a dialog in the middle of scroll gestures and such. We need similar protection if taps in a custom dialog are going to open more dialogs.

The good news is that, unlike with views, we can make that kind of change in a Dialog without subclassing. WindowCallback provides all the hooks we need to attach similar protections. I'm thinking that we take advantage this way:

Z Order maintenance

I think we might even be able to solve our z order woes without creating a manager-of-managers thing.

rjrjr commented 2 years ago

- fin -