square / workflow-swift

A Swift and Kotlin library for making composable state machines, and UIs driven by those state machines.

Home Page:https://square.github.io/workflow

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

[x-platform] Modal containers can't handle being unrendered

zach-klippenstein opened this issue · comments

This problem affects both Swift and Kotlin libraries.

Description

When a modal screen is shown (ModalContainerScreen in Swift, ModalContainer/AlertContainerScreen in Kotlin), the workflow specifies a base screen to show under the modal, and a list of modals to show above the base screen. These screens are bound to special containers that hook into each platform's modal/dialog mechanisms to show standard dialogs when the modal list is non-empty. They both correctly support empty modal lists, going from empty to non-empty modal lists, and going back from non-empty to empty modal lists.

They also both support going from an arbitrary rendering to a modal rendering (with zero or more modals). However, neither of them support going from a modal rendering with a non-empty modal list to any other rendering. The workflow infrastructure tears down the modal container without telling it that it's going away, and the containers have no chance to clean up after themselves.

On iOS, this manifests as the modal disappearing without animation. On Android, the dialog is never dismissed but nobody has a handle so it stays up forever and gets leaked (until the user tries interacting with it, at which point it will crash because it has a stale rendering).

Workaround

If you have a workflow that is rendering a ModalContainerScreen/AlertContainerScreen, ensure that you're always rendering that screen. If you don't have any modals to show, just use an empty list.

Non-Solution 1: Force consistent renderings

This is a non-starter. Even if we can force a particular workflow to either always or never render a modal screen, we can't propagate that invariant to the parent. We definitely can't at compile time, and doing so at runtime is brittle and very easy to accidentally trigger. A significant part of the workflow architecture is that workflows can render whatever they want and either their parent or the view system will just deal with it.

Non-Solution 2: Automatically wrap renderings with modal screens

The infrastructure can insert a root workflow above whatever root "user" workflow is passed in that monitors for modal screens, and once it sees once always wraps all renderings in modal screens.

This is also a non-starter. It's hacky and brittle, and would require the platform-specific runtimes to know how to create instances of all possible modal screens, which isn't possible at least on Android (would need to have constructor configured for any HasModals implementations that could possibly be rendered).

Proposed Solution: Provide cleanup hook in view bindings

I think this solution would work for iOS, but I'm not sure it would for Android. On iOS, the hook would simply need to animate any existing modals away.

On Android, it depends how the hook is implemented. If we just hook into the view lifecycle, we can dismiss all dialogs whenever the container is detached, but that would include rotation. The system already handles preserving dialogs across rotation, so this might cause non-standard behavior since we're manually dismissing and then recreating dialogs. Alternatively, if there is a higher-level hook in the binding itself that is only triggered when a particular ViewRegistry-view is going away, it should work as long as containers actually handle rotation correctly to begin with. I don't think the extra complexity exists on iOS because the lifecycle is simpler – the ViewController lifecycle matches the view binding lifecycle 1-to-1.

On Android, we can just hook into the container's view lifecycle and dismiss on detach.

@bencochran Tells me Swift's modal container isn't even in github yet – but this will be something we need to ensure is addressed when that day comes.

Solution (1) is the intended design of this. There is nothing in the WorkflowUI infrastructure that has understanding of what "containers" are being used. If a container is intended to be used, it must be the same base screen type being rendered. It's the same case for (for instance) a backstack, where if BackStackScreen was emitted, than a totally different screen, it won't animate it away.

I also worry about the cleanup hooks approach essentially requiring every screen being able to gracefully transition into any other screen.

e.g.
Going from

AnyScreen(
    ModalContainer(
        baseScreen: MyScreen(),
        modals: [Modal(OtherScreen()]))

to just

AnyScreen(MyScreen())

would require the modal screen to go notice that the new screen is the same type as the old screen’s base screen and animate accordingly. But what if the next screen is of a different type entirely? Or what if I transition from that modal container to a

AnyScreen(
    AlertContainer(
        base: MyScreen()
        alert: someAlert))

Does it now need to know how to simultaneously animate from a modal to an alert? (which is even a non-starter currently in Swift because we’d have the AnyScreen type erasure between us)

Leaving these things as a consistent tree greatly reduces the inference that infrastructure has to do (and likely get wrong). To the extent that things currently behave in an unexpected way, I think I’d lean toward unexpected but simple over trying to infer intent.

Ok, I definitely conflated two very different issues. I talked to @davidapgar offline and he pointed out that the iOS behavior isn't actually problematic. You can nest modal containers if you really want and get nested views, because modals in iOS workflows are entirely userspace. So if you want your app to have a single modal display at the top level, your workflows need to enforce that.

Meanwhile on Android, we're not just dealing with styling preferences, it's a matter of correctness – we have to satisfy a certain contract required by the OS, and so we do need to do the extra work in our view layer to ensure we're following that contract.

Note that the first proposed non-solution is not quite what iOS wants to do, because it's more strict. Rather, iOS expects that for a sane app you'll just follow the pattern where your root workflow is the one that assembles screens into base/modal layers, and thus enforces that pattern. It requires discipline in the root workflow. Android should do the same thing, but we also shouldn't blow up if you happen to not be disciplined.

I don't think this needs to block 1.0, it's an edge-case behavior fix.