Repository navigation
feat: restore focus when covered overlay content is uncovered - #9
steve-the-edwards wants to merge 7 commits into
Conversation
An overlay that covers content takes focus, but nothing gives focus back when it is dismissed. The editor that had focus underneath stays unfocused, and an overlay that was added while another overlay covered it never claims the initial focus it asked for. Add experimental Modifier.overlayFocusLayer for content that overlays can cover. When a layer becomes covered, it saves its focused descendant through FocusRequesterModifierNode.saveFocusedChild. After the frame, it clears focus if focus is still inside, so views embedded in the layer keep focus while the frame applies and can save their own. While covered, focus cannot enter it. When uncovered, it restores the saved descendant once, after the frame in which the covering overlay is removed. Coverage is read in a snapshot observer, so a layer in another composition, such as content behind an embedded View, saves its focus before the covering overlay requests it. overlayFocusTarget now waits to request initial focus while it is inside a covered layer. It requests focus again when the layer is uncovered with nothing to restore. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: sedwards <sedwards@squareup.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 28e8f09635
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
|
||
| private fun clearFocusIfStillCovered() { | ||
| pendingClear = null | ||
| if (covered && hasFocus) currentValueOf(LocalFocusManager).clearFocus(force = true) |
There was a problem hiding this comment.
Retry initial focus after force-clearing a captured child
When the covered descendant has captured focus, the covering overlay's earlier requestFocus() is rejected because captured focus declines requests from other components (Compose FocusRequester documentation). This forced clear then removes the descendant's focus, but nothing reschedules the covering sibling's initial-focus request, leaving the window with no focused overlay until another state transition occurs. Retry the covering overlay's request after this clear or perform the forced clear before its initial request.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
🤖 Fixed in 81dcb83. When a covered layer force-clears focus, it now increments a per-window snapshot counter. overlayFocusTarget observes the counter and requests focus again if it still wants initial focus, does not have focus, and is not inside a covered layer. The overlay is therefore focused after the clear, whichever order the request and the clear ran in. coveringOverlayTakesFocusOnceCoveredContentReleasesCapturedFocus captures the editor, then covers it with the coverage read during composition, so the initial request runs first and is declined. It fails on 28e8f09 and passes on 81dcb83.
steve-the-edwards
left a comment
There was a problem hiding this comment.
🤖 Reviewed commit 28e8f09 against the relevant Compose 1.12.1 sources. These findings are source-traced; I have not reproduced them on a local device. The existing build and device CI checks pass, but the cases below need coverage.
| // Clear after the frame rather than while the change is being applied: the rest of the frame, | ||
| // such as embedded views saving their own focus or the covering overlay requesting focus, still | ||
| // sees focus where it was. | ||
| pendingClear = coroutineScope.launch { clearFocusIfStillCovered() } |
There was a problem hiding this comment.
🤖 [P2] Explicitly order clearing and restoration after changes apply
A plain coroutineScope.launch does not guarantee execution after the recomposition frame. With overlayFocusLayer { isCovered.value }, the snapshot observer can launch this clear before embedded Views apply their updates; the launch in uncover() can similarly attempt restoration before the covering overlay is detached. Compose 1.12.1's Android dispatcher runs queued coroutine work before frame callbacks. The new embeddedViewKeepsFocusWhileCoverIsApplied test captures isCoveredNow during composition, so it does not exercise the direct snapshot-reading path.
Could we explicitly order these operations after the relevant changes apply and add coverage using overlayFocusLayer { isCovered.value }, checking both View focus during cover application and restoration after overlay removal?
There was a problem hiding this comment.
🤖 Fixed in 81dcb83. Clearing and restoring now wait for withFrameNanos {} before they run. Recomposition and apply happen inside the same frame callback after sendFrame, and the continuation is dispatched, so it runs after that frame applies its changes. That holds on AndroidUiDispatcher and under the test clock (FrameDeferringContinuationInterceptor). A change observed between frames therefore waits for the frame that applies it. If the change is applied during a frame, the work waits one more frame. I added two tests that use overlayFocusLayer { showOverlay.value }:
embeddedViewKeepsFocusWhileSnapshotCoverageIsApplied: the embeddedEditTextstill has focus in anAndroidViewupdate during the frame that applies the coverage, and has lost focus once the overlay is focused.snapshotCoverageRestoresFocusAfterCoveringOverlayIsRemoved: the editor is refocused, and its focus gain is recorded after the overlay'sDisposableEffecthas disposed.
Both fail on 28e8f09 and pass on 81dcb83.
One related finding: restoring focus to a View embedded directly in a layer does not work on 28e8f09 or 81dcb83. restoreFocusedChild returns false for the AndroidView focus group, so the View does not get focus back. Compose content and a layer inside an embedded ComposeView do restore. I left this out of scope here and kept the restore test on Compose content.
| } | ||
|
|
||
| override fun applyFocusProperties(focusProperties: FocusProperties) { | ||
| if (covered) focusProperties.onEnter = { cancelFocusChange() } |
There was a problem hiding this comment.
🤖 [P2] Keep embedded View focus requests out of covered layers
This guard blocks Compose focus entry through onEnter, but a native EditText.requestFocus() can bypass it. In Compose 1.12.1, the embedded View's global-focus listener synchronizes native focus by calling performRequestFocus() directly, which skips custom enter callbacks. After the one-time clear finishes, the layer has no focus-change observer to eject focus that re-enters this way, so covered View content can take focus away from the overlay.
Could we add a regression that lets coverage settle, calls editText.requestFocus(), and verifies the covering overlay retains focus, then handle that native View entry path?
There was a problem hiding this comment.
🤖 Fixed in 81dcb83. The layer's focus group now has an onFocusChange. If focus enters while the layer is covered and no clear is pending, the layer launches a forced clear, which runs once the focus change that moved focus there has finished. The clear signals a release, so the covering overlay requests focus again. coveredEmbeddedViewCannotKeepFocusFromCoveringOverlay lets coverage settle, calls editText.requestFocus(), and asserts that the overlay is focused and the EditText is not. It fails on 28e8f09 and passes on 81dcb83.
|
|
||
| private fun clearFocusIfStillCovered() { | ||
| pendingClear = null | ||
| if (covered && hasFocus) currentValueOf(LocalFocusManager).clearFocus(force = true) |
There was a problem hiding this comment.
🤖 [P2] Retry the covering overlay's request after clearing captured focus
This supports the existing captured-focus discussion. When the covering overlay's initial request runs before this deferred clear, a captured descendant rejects it. The forced clear then releases the descendant, but nothing retries the covering sibling's one-shot request, leaving the overlay unfocused.
Could we retry the covering overlay's request after clearing, or guarantee clearing precedes that request? A regression should focus and capture the underlying editor, open a covering overlay, and assert that the overlay becomes focused once clearing completes. This should exercise the ordering where the initial request precedes the clear.
There was a problem hiding this comment.
🤖 Same fix as the thread above (81dcb83): the forced clear signals a release and the covering overlay requests focus again. The regression focuses and captures the editor, then opens the overlay. Coverage is read during composition, so the initial request runs before the clear and is declined. The test asserts the overlay ends up focused and the editor does not.
A covered layer cleared focus and restored it from a plain coroutine launch. When coverage is read from snapshot state, that launch can run before the frame that applies the change, so views embedded in the layer lose focus before they apply their updates, and restoration can run before the covering overlay is removed. Wait for the next frame to apply its changes before clearing or restoring. Clearing focus also left two cases without a focused overlay. A descendant that captured focus declines the covering overlay's initial request, and the forced clear that later releases it does not ask again. A View embedded in a covered layer can take focus directly, which skips the layer's enter check. A covered layer now clears focus that enters it this way, and each time it releases focus it increments a per-window snapshot counter. Overlay focus targets that want initial focus observe the counter and request focus again when it changes. Signed-off-by: Stephen Edwards <sedwards@squareup.com>
steve-the-edwards
left a comment
There was a problem hiding this comment.
🤖 Follow-up review of 81dcb83 after device validation. All 13 existing focus-layer tests passed on Android 9 / API 28. A local Market catalog consumer using published Overstory and Market snapshots passed four integration controls, but reproduced the stale-focus P2 twice. The two other inline notes are non-blocking follow-ups.
| // A restore request only reaches the nearest focus target below a node, so every participant | ||
| // that leads to the focused descendant saves its own step on the way down. | ||
| traverseDescendants(OverlayFocusTraverseKey) { participant -> | ||
| if ((participant as OverlayFocusParticipant).hasFocus) participant.saveFocusedChild() |
There was a problem hiding this comment.
🤖 [P2] Discard stale child focus when a later cover starts with the root focused
Reproduced twice on Android 9 / API 28 through the Market catalog consuming the snapshot AARs:
- Focus an editor inside an
overlayFocusTarget. - Cover and uncover it; the editor is correctly restored.
- Explicitly request focus on the overlay target itself and verify that the target is focused and the editor is not.
- Cover and uncover again.
The second uncover focuses the old editor instead of the target that was focused immediately before this cover. A control that starts with the target focused, without the earlier editor-focus cycle, passes.
Here, the participant has focus, but saveFocusedChild() returns false when the target itself is focused. That does not clear Compose's previously saved child record. The ancestor's later recursive restoreFocusedChild() can therefore follow the editor record from the previous cycle. Could we make restoration use the path saved for the current cover cycle, rather than reuse an earlier descendant after a failed save, and add this repeated-cycle regression? uncoveringRestoresEditorInsideOverlayInsteadOfOverlay is a useful starting fixture.
There was a problem hiding this comment.
🤖 Fixed in 14e4c99. When an overlayFocusTarget is itself focused at cover time, the layer now saves that target for this cover cycle and requests focus on it directly on uncover instead of following the stale recursive restoreFocusedChild() path. Added uncoveringRestoresOverlayFocusedSinceEarlierEditorRestore (your repeated-cycle repro built on uncoveringRestoresEditorInsideOverlayInsteadOfOverlay); it failed on 81dcb83 at the final overlay.assertIsFocused() and passes now on API 28.
| } | ||
|
|
||
| private fun observeFocusReleases() { | ||
| observeReads { overlayFocusReleases().count.intValue } |
There was a problem hiding this comment.
🤖 Non-blocking: cover two independent hosts sharing a window
Could we add a regression with two independent overlay hosts in the same window, each with an uncovered target whose requestInitialFocus remains true? The window-wide counter reaches targets in both hosts, and requestWantedFocus() does not associate the release with the overlay covering that layer. Targets that lack focus may therefore compete when one host releases captured or native View focus, making the eventual recipient depend on observer notification order.
This is a coverage concern, not a device-reproduced defect. A useful test would verify that the intended covering overlay gets focus while an unrelated host does not steal it; if simultaneous independent hosts are unsupported, documenting that constraint would also clarify the contract.
There was a problem hiding this comment.
🤖 Good call, this reproduced. With two hosts in sibling ComposeViews, one order let the independent host's requestInitialFocus target take released focus from the covering overlay. In 14e4c99 releases no longer go window-wide: a layer notifies targets whose composition view is the layer's own or an ancestor of it (walking the View parent chain), so overlays covering a view that hosts the layer still respond and sibling hosts do not. Added releasedFocusGoesToCoveringOverlayRatherThanHostBeforeIt / ...AfterIt covering both orders. The KDoc and CHANGELOG now state the contract; two independent hosts inside one composition still share the notification.
| * enter it, and [overlayFocusTarget] descendants wait to request initial focus. If focus enters | ||
| * anyway, for example because a View embedded in the layer requests focus, the layer clears it | ||
| * again. Each time the layer clears focus, [overlayFocusTarget]s outside covered layers that want | ||
| * initial focus request it. When it is uncovered, it restores the saved descendant once after the |
There was a problem hiding this comment.
🤖 Non-blocking: document the native View restoration limitation in the public contract
The frame-ordering reply and PR description acknowledge that a native View embedded directly in a layer loses focus on cover but is not restored on uncover, while Compose editors and content in an embedded ComposeView do restore. This KDoc currently promises restoration of the saved descendant without making that distinction.
Could we carry the limitation into the public overlayFocusLayer KDoc and the engine guide or changelog? Keeping native View restoration out of scope is reasonable, but consumers reading the API documentation should be able to tell which interop cases the restoration contract covers.
…releases A layer restored focus by following the child each focus target saved last. When an overlay focus target itself had focus at cover time, saving recorded nothing new, so restoring could follow a child saved in an earlier cover. The layer now requests focus on such a target directly. Released focus was announced to every overlay focus target in the window, so an unrelated host's target that wanted focus could take it from the overlay covering the layer. Releases now reach targets in the layer's composition and in compositions that contain it. Signed-off-by: Stephen Edwards <sedwards@squareup.com>
Signed-off-by: Stephen Edwards <sedwards@squareup.com>
When a target inside a layer started wanting initial focus in the same change that uncovered the layer, its request ran first. The layer then saw focus and dropped the descendant it had saved. Targets now wait until an enclosing layer has tried to restore its saved focus; the layer asks them to request focus when it restores none. Signed-off-by: Stephen Edwards <sedwards@squareup.com>
Compose runs a modifier node's onDetach before it marks the node detached. A focus search can run in between, for example when Android hands back the focus of a view removed with the same content. If that search picks an overlay focus target, or focusable content of a target or layer, Compose keeps the detached node as its focus and crashes on the next focus change with "visitAncestors called on an unattached node". Overlay focus targets now refuse focus once they start detaching, and targets and layers cancel focus entering their content. Signed-off-by: Stephen Edwards <sedwards@squareup.com>
A layer that is uncovered waits for the frame to apply before it restores focus. If that frame also removes the layer, the wait still resumes, and the restore walked the ancestors of a detached node. Signed-off-by: Stephen Edwards <sedwards@squareup.com>
| * composition containing it wants, such as an overlay covering a view that hosts the layer. | ||
| */ | ||
| private class OverlayFocusReleases { | ||
| val count = mutableIntStateOf(0) |
There was a problem hiding this comment.
Is the only purpose of this state to trigger one of the modifier nodes to invalidate when it changes? I noticed the value isn't actually used when it's ready in the observeReads. If so, it would be more efficient (no snapshot overhead) and simpler (conceptually, if not directly code-wise) to just make a plain old observer pattern.
An overlay that covers content takes focus, but nothing gives focus back when it is dismissed. The editor that had focus underneath stays unfocused. An overlay that was added while another overlay covered it never claims the initial focus it asked for.
This adds the experimental
Modifier.overlayFocusLayerfor content that overlays can cover:Cover: the layer saves its focused descendant through
FocusRequesterModifierNode.saveFocusedChild. After the next frame applies its changes, it force-clears focus if focus is still inside the layer. Views embedded in the layer therefore keep focus while that frame applies and can save their own focus.While covered: focus requests cannot enter the layer. If focus gets in anyway, for example because an embedded View calls
requestFocus(), the layer clears it again.Release: each time a covered layer clears focus, it increments a snapshot counter on its composition's view and on each view containing it.
overlayFocusTargets that want initial focus, are not inside a covered layer, and observe one of those counters request focus again when it changes. Hosts in sibling views do not compete for the released focus. The covering overlay therefore ends up focused even if covered content had captured focus or took focus through an embedded View.Uncover: after the next frame applies its changes, so the covering overlay is already gone, the layer restores the focus saved in that cover once: the saved descendant, or the
overlayFocusTargetitself if that target had focus.overlayFocusTargetdescendants wait for this restore before requesting initial focus, and request it only if nothing was restored.Removal: Compose runs a node's
onDetachbefore it marks the node detached, and a focus search can run in between, for example when Android hands back the focus of a View removed with the same content. A detachingoverlayFocusTargetrefuses focus, and detaching targets and layers cancel focus entering their content. Otherwise Compose keeps the detached node as its focus and the next focus change crashes withvisitAncestors called on an unattached node.Coverage is read in a snapshot observer, so a layer in another composition, such as content behind an embedded View, saves its focus before the covering overlay requests it.
overlayFocusTargetwaits to request initial focus while it is inside a covered layer.Review order:
OverlayFocus.kt, thenOverlayFocusLayerTest.kt. Later commits address review feedback: frame ordering, captured focus, focus entering through an embedded View, stale saved focus across repeated covers, scoping releases to containing views, initial focus waiting for a pending restore, and refusing focus while detaching.Known limitation
Focus held by a View embedded directly in a layer, such as an
EditTextin anAndroidView, is not restored.restoreFocusedChildreturns false for theAndroidViewfocus group. Compose content and a layer inside an embeddedComposeVieware restored. Compose has an internalinteractionBarrierthat may eventually replace parts of this. It is not public in 1.12.1.