Closed rjrjr closed 2 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).
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()
}
Or even:
interface ViewRendering {
fun asViewFactory(
viewEnvironment: ViewEnvironment
) = viewEnvironment[ViewRegistry].getFactoryFor(this)
}
interface ModalRendering {
fun asDialogRunner(
viewEnvironment: ViewEnvironment
) : StealDialogRunnerInterfaceFromHelios = andADefaultImplementationToo()
}
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>
)
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.
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.
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.
@WorkflowUiExperimentalApi
interface ViewRendering<RenderT : ViewRendering<RenderT>>
@WorkflowUiExperimentalApi
fun <RenderT: ViewRendering<RenderT>> RenderT.asViewFactory(
env: ViewEnvironment
): ViewFactory<RenderT> = env[ViewRegistry].getFactoryFor(this::class)
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.
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
.
Took a few side trips, but I think I'm closing on it.
We introduce the ViewRendering
and ModalRendering
interfaces suggested above.
ViewRegistry
changes to be able to hold a collection of more arbitrary entry types, not just ViewFactory
. Its buildView
method is deprecated.
There are three implementations of ViewRegistry.Entry
ViewFactory<T: Any>
, now deprecated.ViewBuilder<T: ViewRendering>
, which replaces ViewFactory
DialogBuilder<T: ModalRendering>
Extension method ViewRendering.buildView()
replaces ViewRegistry.buildView()
. We also introduce ModalRendering.buildDialog()
.
ViewRegistry
, based on the concrete type of the rendering, just like ViewRegistry.buildView()
did. ViewRegistry
to hold arbitrary entries means that we can add as many rendering:view-machinery pairings as we want to. Maybe cleans up Compose integration?LayoutRunner<T: Any>
is deprecated, replaced by ViewRunner<T: ViewRendering>
. That's going to be a tedious migration for existing LayoutRunner
implementations, but it avoids a huge breaking change.
WorkflowViewStub.show(rendering: ViewRendering)
replaces WorkflowViewStub.update(rendering: Any)
, now deprecated. But the latter is able to delegate to the former so incremental migration is possible.
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( ) {
}
}
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:
Screen
and WithEnvironment : Screen
ViewEnvironment
and ViewRegistry
from AndroidViewEnvironment.updateFrom
to simplify combining environments and their nested ViewRegistry
entries.Screen
marker interface, similar to the one in workflow-swift
WithEnvironment
rendering type as the preferred way to manage the ViewEnvironment
, and makes WorkflowLayout
use itBackStackScreen
to workflow-ui:core-commonThis 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.
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())
)
)
Hoping this lets us do something cleaner than extending AndroidViewRendering<Nothing>
. Current strawman:
ScreenComposable<T: Screen> : ViewRegistry.Entry<T>
Screen.buildView
ScreenComposable
if it fails to find ScreenViewFactory
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)
WorkflowRendering
, which uses Box()
but didn't document that factWorkflowViewStub
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:
workflow-ui:android
depend upon the Compose runtime, which would be rude to library authorsOn 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.
android.view.View
@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.
android.app.Dialog
at the momentFrameLayout
that acted window-like. That might happen again.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 Screen
s, it can render Overlay
s, 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.
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
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.
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.
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.
Overlay
interface improves thingsThe 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.
@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?
@rjrjr The RectProvider
s 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.
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.
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 The
RectProvider
s are meant to be only one perRunner
. EachRunner
sets theonChange
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.
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.)
Gah, my tag code there is complete gibberish, but hopefully it gets the idea across.
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.
Another day, another round of Overlay
ideas.
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 Overlay
s, 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>
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.
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:
DialogStack
to be configured with optional modality supportobject DisableClicks: ViewEnvironment.Key<Boolean>
is set to true for every managed dialog except for the topmost onedropLateEvents
thingI think we might even be able to solve our z order woes without creating a manager-of-managers thing.
- fin -
@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…
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:
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:
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: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:
So some tutorial-aware outer workflow will need to filter the
modals
lists of its children, and moveAlertModal
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?