Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Introduces ScreenOverlay and ModalScreenOverlayDialogFactory #595

Merged
merged 1 commit into from
Dec 7, 2021

Conversation

rjrjr
Copy link
Contributor

@rjrjr rjrjr commented Dec 2, 2021

ScreenOverlay marks an Overlay with a Screen defining its content.

ModalScreenOverlayDialogFactory can show a ScreenOverlay as an
android.app.Dialog, taking care of updating the content view and managing
the Back button. It's the much simpler replacement for ModalViewContainer.

ModalScreenOverlayDialogFactory works with BodyAndModalsContainer
and LayeredDialogs to perform two important tricks:

  • updateBounds(Dialog, Rect), which allows dialogs to be restrictred to a subset
    of the screen. Implemented via the private ModalArea value in ViewEnvironment

  • Touch and keyboard events are blocked by views that are covered by
    dialogs managed by a ModalScreenOverlayDialogFactory. Same for
    dialogs with a lower z order. We use another private ViewEnvironment
    value for that, CoveredByModal.

In sample code, PanelOverlay replaces PanelContainerScreen, and the Tic
Tac Toe sample is updated to use the new hotness. I think the diff of the
sample code really highlights how much the new marker interfaces improve our
composition story.

That said, the ScrimScreen bit in the sample is a little rough. In the old
code we magically swizzled the scrim into place automatically. This PR
seems big enough already, so I'll follow up with a clean up that does
something similar and deletes all the deprecated sample code.

Fixes #259, #138, #99, #204, #314, #589

@rjrjr rjrjr changed the title wip: Introduces ScreenOverlay, support for custom dialogs wip: Introduces ScreenOverlay and ModalScreenOverlayDialogFactory Dec 2, 2021
@rjrjr rjrjr force-pushed the ray/custom-dialog branch 5 times, most recently from eed7a6a to 0bb52cb Compare December 2, 2021 20:13
@rjrjr rjrjr force-pushed the ray/custom-dialog branch 2 times, most recently from ce0b3be to a1e8335 Compare December 3, 2021 17:13
@rjrjr
Copy link
Contributor Author

rjrjr commented Dec 3, 2021

That said, the ScrimScreen bit in the sample is a little rough. In the old
code we magically swizzled the scrim into place automatically, and I think in
prod I would still do the same, but the sample is complex enough already.

I think I actually need to address that in this PR. Panel is a pretty high fidelity
sample, used all over the place. Now is the time to figure out how to make it usable.

@rjrjr
Copy link
Contributor Author

rjrjr commented Dec 3, 2021

Or maybe the thing to do is undelete the old PanelContainer and get this merged, since the PR is already too big. Then follow up with updating all the other samples that use the old modal stuff.

@rjrjr rjrjr changed the title wip: Introduces ScreenOverlay and ModalScreenOverlayDialogFactory Introduces ScreenOverlay and ModalScreenOverlayDialogFactory Dec 3, 2021
@rjrjr
Copy link
Contributor Author

rjrjr commented Dec 3, 2021

Green enough, failure is due to ##590.

@rjrjr rjrjr marked this pull request as ready for review December 3, 2021 20:25
@rjrjr rjrjr requested review from zach-klippenstein and a team as code owners December 3, 2021 20:25
@rjrjr
Copy link
Contributor Author

rjrjr commented Dec 3, 2021

Update deletes some more unused stuff.

Comment on lines 25 to 33
val typedValue = TypedValue()
context.theme.resolveAttribute(android.R.attr.windowBackground, typedValue, true)
if (typedValue.type in TypedValue.TYPE_FIRST_COLOR_INT..TypedValue.TYPE_LAST_COLOR_INT) {
dialog.window!!.setBackgroundDrawable(ColorDrawable(typedValue.data))
}
Copy link
Contributor

Choose a reason for hiding this comment

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

😵 . Is this a common thing we do? Could use a why comment for me at least.

Also could the val be maybeWindowBackgroundColour not typedValue?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sample code, so don't worry too much. And I'm afraid it's pretty common Android boilerplate -- I'd even argue idiomatic. Compose can't come fast enough.

But yes, that's a better name, and a comment can help. Not inclined to write a dissertation, though, b/c it's just how Android life is.

Copy link
Contributor Author

@rjrjr rjrjr Dec 6, 2021

Choose a reason for hiding this comment

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

// Welcome to Android. Nothing workflow-related here, this is just how one
// finds the window background color for the theme. I sure hope it's better in Compose.
val maybeWindowColor = TypedValue()
context.theme.resolveAttribute(android.R.attr.windowBackground, maybeWindowColor, true)
if (maybeWindowColor.type in TypedValue.TYPE_FIRST_COLOR_INT..TypedValue.TYPE_LAST_COLOR_INT) {
  dialog.window!!.setBackgroundDrawable(ColorDrawable(maybeWindowColor.data))
}

Comment on lines +45 to +49
data class RunGameRendering(
val gameScreen: Screen,
val namePrompt: ScreenOverlay<*>? = null,
val alerts: List<AlertOverlay> = emptyList()
)
Copy link
Contributor

Choose a reason for hiding this comment

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

❤️


override fun onDetachedFromWindow() {
job?.cancel()
job = null
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think we should use the job and then leak the scope until callbacks are cleared off the window. Was there a reason you went this way?

Why not just create the scope onAttach and use that?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Because I'm still really bad at Coroutines. Thanks.

Note that we use the same technique in WorkflowLayout.takeWhileAttached, will fix that too.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Does this look right?

  window.callback = object : Window.Callback by window.callback {
    var scope : CoroutineScope? = null

    override fun onAttachedToWindow() {
      scope  = CoroutineScope(Dispatchers.Main.immediate).also {
        bounds.onEach { b -> onBoundsChange(this@maintainBounds, b) }
          .launchIn(it)
      }
    }

    override fun onDetachedFromWindow() {
      scope?.cancel()
      scope = null
    }
  }

Comment on lines 40 to 53
private val boundsListener = OnGlobalLayoutListener {
getGlobalVisibleRect(boundsRect)
if (!boundsRect.matches(bounds.value)) bounds.value = boundsRect.toBounds()
}
Copy link
Contributor

Choose a reason for hiding this comment

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

here we are just extracting the 'Global Visible Rect' right? - what does that represent?

Copy link
Contributor Author

@rjrjr rjrjr Dec 3, 2021

Choose a reason for hiding this comment

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

It's the bounds of the view in global coordinates -- the screen's coordinate system, not the parent view's. But I just noticed that this method returns false "If the view is completely clipped or translated out". I imagine the contents of the Rect are nonsense in that case.

Not even sure what to do if that happens. Probably should close all the dialogs, treat it like onDetach

Copy link
Contributor

Choose a reason for hiding this comment

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

Ok. Sounds good.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

  // The bounds of this view in global (display) coordinates, as reported
  // by getGlobalVisibleRect.
  //
  // Made available to managed ModalScreenOverlayDialogFactory instances
  // via the ModalArea key in ViewEnvironment. When this updates their
  // updateBounds methods will fire. They should resize themselves to
  // avoid covering peers of this view.
  private val bounds = MutableStateFlow(Rect())
  private val boundsRect = Rect()

  private val boundsListener = OnGlobalLayoutListener {
    if (getGlobalVisibleRect(boundsRect) && boundsRect != bounds.value) {
      bounds.value = Rect(boundsRect)
    }
    // Should we close the dialogs if getGlobalVisibleRect returns false?
    // https://github.com/square/workflow-kotlin/issues/599
  }

Copy link
Contributor Author

@rjrjr rjrjr Dec 6, 2021

Choose a reason for hiding this comment

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

Note that I punted handling a false return in any interesting way. We've been shipping the equivalent code for more than a year and it's not been an issue. I'd like to dig into that case separately and carefully -- see if it's actually an issue at all.

@rjrjr rjrjr force-pushed the ray/custom-dialog branch 2 times, most recently from c7d84cf to 727928c Compare December 6, 2021 22:47
@rjrjr
Copy link
Contributor Author

rjrjr commented Dec 6, 2021

Updated to honor the feedback from @steve-the-edwards, especially around eliminating Bounds and making the two new ViewEnvironment.Keys private.

Also notice the updated commit message, and improved kdoc on updateBounds.

PTAL

@rjrjr rjrjr force-pushed the ray/custom-dialog branch 2 times, most recently from f1fa90d to 1092624 Compare December 6, 2021 23:09
@rjrjr
Copy link
Contributor Author

rjrjr commented Dec 6, 2021

@steve-the-edwards Oops, I was too quick to ask PTAL, I missed some of your comments. Will ping again soon.

@rjrjr
Copy link
Contributor Author

rjrjr commented Dec 6, 2021

@steve-the-edwards Oops, I was too quick to ask PTAL, I missed some of your comments. Will ping again soon.

Actually, okay to review. Tracking the most interesting thing I missed separately, #600.

`ScreenOverlay` marks an `Overlay` with a `Screen` defining its content.

`ModalScreenOverlayDialogFactory` can show a `ScreenOverlay` as an
`android.app.Dialog`, taking care of updating the content view and managing
the Back button. It's the much simpler replacement for `ModalViewContainer`.

`ModalScreenOverlayDialogFactory` works with `BodyAndModalsContainer`
and `LayeredDialogs` to perform two important tricks:

 - `updateBounds(Dialog, Rect)`, which allows dialogs to be restrictred to a subset
   of the screen. Implemented via the private `ModalArea` value in `ViewEnvironment`

 - Touch and keyboard events are blocked by views that are covered by
   dialogs managed by a `ModalScreenOverlayDialogFactory`. Same for
   dialogs with a lower z order. We use another private `ViewEnvironment`
   value for that, `CoveredByModal`.

In sample code, `PanelOverlay` replaces `PanelContainerScreen`, and the Tic
Tac Toe sample is updated to use the new hotness. I think the diff of the
sample code really highlights how much the new marker interfaces improve our
composition story.

That said, the `ScrimScreen` bit in the sample is a little rough. In the old
code we magically swizzled the scrim into place automatically. This PR
seems big enough already, so I'll follow up with a clean up that does
something similar and deletes all the deprecated sample code.

Fixes #259, #138, #99, #204, #314, #589
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.

Awesome!

?: TakeTurnsProps.newGame(renderState.playerInfo)
) { stopPlaying(it) }

RunGameRendering(takeTurnsScreen)
Copy link
Contributor

Choose a reason for hiding this comment

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

I guess I was suggesting to name the arg here still

Suggested change
RunGameRendering(takeTurnsScreen)
RunGameRendering(gameScreen = takeTurnsScreen)

but its not a big deal.

Comment on lines +30 to +41
// We want to be able to update the alert while it's showing, including to maybe
// show more buttons than were there originally. The API for Android's `AlertDialog`
// makes you think you can do that, but it actually doesn't work. So we force
// `AlertDialog.Builder` to show every possible button; then we hide them all;
// and then we manage their visibility ourselves at update time.
//
// We also don't want Android to tear down the dialog without our say so --
// again, we might need to update the thing. But there is a dismiss call
// built in to click handers put in place by `AlertDialog`. So, when we're
// preflighting every possible button, we put garbage click handlers in place.
// Then we replace them with our own, again at update time, by setting each live
// button's click handler directly, without letting `AlertDialog` interfere.
Copy link
Contributor

Choose a reason for hiding this comment

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

Ahh - now I understand. This is very helpful.

Comment on lines +60 to +74
window.callback = object : Window.Callback by window.callback {
var scope: CoroutineScope? = null

override fun onAttachedToWindow() {
scope = CoroutineScope(Dispatchers.Main.immediate).also {
bounds.onEach { b -> onBoundsChange(this@maintainBounds, b) }
.launchIn(it)
}
}

override fun onDetachedFromWindow() {
scope?.cancel()
scope = null
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

👍🏻 .

Other than the injected immediate main dispatcher - but I see thats covered by #600 .

If we want debugging help we could add a name into the context for the scope.

Suggested change
window.callback = object : Window.Callback by window.callback {
var scope: CoroutineScope? = null
override fun onAttachedToWindow() {
scope = CoroutineScope(Dispatchers.Main.immediate).also {
bounds.onEach { b -> onBoundsChange(this@maintainBounds, b) }
.launchIn(it)
}
}
override fun onDetachedFromWindow() {
scope?.cancel()
scope = null
}
}
window.callback = object : Window.Callback by window.callback {
var scope: CoroutineScope? = null
override fun onAttachedToWindow() {
val id = window.decorView.id
scope = CoroutineScope(Dispatchers.Main.immediate + CoroutineName(id)).also {
bounds.onEach { b -> onBoundsChange(this@maintainBounds, b) }
.launchIn(it)
}
}
override fun onDetachedFromWindow() {
scope?.cancel()
scope = null
}
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I like the CoroutineName() idea a lot. I think we can do better than the id, though, those things are so opaque. Remember that I snuck the content view into a R.id.workflow_modal_dialog_content on the DecorView. If that's present I can use the classname of its rendering, say. And if it's not, maybe the class name of the dialog itself.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

s/classname/toString()/, 10x more useful. I was shying away from that since it can be big, and that burned us in workflowAction {}. But I think that's okay for infrequent events like creating a dialog.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Meh, I'm over engineering this.

Gonna merge as is and add a note to #600 to consider useful scope name at the same time.

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.

3 participants