From a703b351b43c282577b02ca2976a18c968f9d9d0 Mon Sep 17 00:00:00 2001 From: skydoves Date: Sun, 13 Sep 2026 10:37:13 +0900 Subject: [PATCH] Place balloons against unclipped anchor bounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both anchor capture sites read `boundsInWindow()`, which clips: an anchor scrolled out of a `Modifier.verticalScroll` column (clipped rather than disposed, unlike a `LazyColumn` item) collapses to `Rect.Zero`, so the balloon was placed against the window origin — the arrow jumping to the top-left corner reported in #1022. The "anchor left the window" dismissal never fired either, because it skipped empty rects. `toBalloonAnchor()` now maps the anchor's own four corners through `localToWindow` for the real rect, and reads the clipped rect only for the separate verdict on whether the anchor is still on screen. That verdict also covers an anchor clipped away inside the window, which the old window-coordinate comparison could not see at all, so it replaces it. Dismissal is latched on an anchor that has been seen: `AnimatedVisibility` with `expandVertically()` clips its content to nothing on the frame it starts, and a balloon shown in that frame must wait for its anchor rather than dismiss itself permanently. --- .../com/skydoves/balloon/BalloonHost.kt | 13 +- .../com/skydoves/balloon/BalloonPopup.kt | 152 ++++++++++--- .../skydoves/balloon/ReportedScenarioTest.kt | 206 +++++++++++++++++- docs/showing.md | 5 +- 4 files changed, 331 insertions(+), 45 deletions(-) diff --git a/balloon/src/commonMain/kotlin/com/skydoves/balloon/BalloonHost.kt b/balloon/src/commonMain/kotlin/com/skydoves/balloon/BalloonHost.kt index 474927ac..b38870d9 100644 --- a/balloon/src/commonMain/kotlin/com/skydoves/balloon/BalloonHost.kt +++ b/balloon/src/commonMain/kotlin/com/skydoves/balloon/BalloonHost.kt @@ -31,7 +31,6 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.unit.IntOffset @@ -45,8 +44,8 @@ import kotlin.math.roundToInt */ @Stable internal class BalloonEntry(val state: BalloonState) { - /** Anchor bounds in window coordinates, updated by the modifier's `onGloballyPositioned`. */ - var anchorBounds: IntRect? by mutableStateOf(null) + /** The anchor's geometry, updated by the modifier's `onGloballyPositioned`. */ + var anchor: BalloonAnchor? by mutableStateOf(null) /** * The balloon body; set once to a stable lambda that always reads the latest content, and @@ -173,7 +172,7 @@ public fun BalloonHost( entry.content?.let { balloonContent -> BalloonPopupLayer( state = entry.state, - anchorBounds = entry.anchorBounds, + anchor = entry.anchor, balloonContent = balloonContent, ) } @@ -227,9 +226,9 @@ public fun Modifier.balloon( onDispose { registry.unregister(entry) } } return this.onGloballyPositioned { coordinates -> - val bounds = coordinates.boundsInWindow().toIntRect() - if (entry.anchorBounds != bounds) { - entry.anchorBounds = bounds + val newAnchor = coordinates.toBalloonAnchor() + if (entry.anchor != newAnchor) { + entry.anchor = newAnchor } } } diff --git a/balloon/src/commonMain/kotlin/com/skydoves/balloon/BalloonPopup.kt b/balloon/src/commonMain/kotlin/com/skydoves/balloon/BalloonPopup.kt index 76754f7d..7a5044f5 100644 --- a/balloon/src/commonMain/kotlin/com/skydoves/balloon/BalloonPopup.kt +++ b/balloon/src/commonMain/kotlin/com/skydoves/balloon/BalloonPopup.kt @@ -21,6 +21,7 @@ import androidx.compose.animation.core.MutableTransitionState import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable @@ -30,7 +31,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.findRootCoordinates import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalWindowInfo @@ -99,14 +104,14 @@ public fun Balloon( balloonContent: @Composable () -> Unit, content: @Composable () -> Unit, ) { - val anchorBoundsState: MutableState = remember(key) { mutableStateOf(null) } + val anchorState: MutableState = remember(key) { mutableStateOf(null) } Box( modifier = modifier.onGloballyPositioned { coordinates -> - val newBounds = coordinates.boundsInWindow().toIntRect() - // Avoid recomposition cascades: only push when bounds actually change. - if (anchorBoundsState.value != newBounds) { - anchorBoundsState.value = newBounds + val newAnchor = coordinates.toBalloonAnchor() + // Avoid recomposition cascades: only push when the anchor actually changes. + if (anchorState.value != newAnchor) { + anchorState.value = newAnchor } }, ) { @@ -116,7 +121,7 @@ public fun Balloon( // claim a spacing slot and shift the anchor. BalloonPopupLayer( state = state, - anchorBounds = anchorBoundsState.value, + anchor = anchorState.value, balloonContent = balloonContent, ) } @@ -124,7 +129,7 @@ public fun Balloon( /** * Emits the balloon [Popup] (with enter/exit animation and auto-dismiss) for [state], - * positioned against [anchorBounds]. Shared by the [Balloon] anchor wrapper and by + * positioned against [anchor]. Shared by the [Balloon] anchor wrapper and by * [BalloonHost] (the `Modifier.balloon` path) so both render identically. * * Must be hosted inside a container that is NOT a spacing-based `Column`/`Row` @@ -134,7 +139,7 @@ public fun Balloon( @Composable internal fun BalloonPopupLayer( state: BalloonState, - anchorBounds: IntRect?, + anchor: BalloonAnchor?, balloonContent: @Composable () -> Unit, ) { val style = state.style @@ -168,7 +173,7 @@ internal fun BalloonPopupLayer( "if you want the scrim to dim the whole window." } val request = remember(state) { BalloonOverlayRequest(state) } - request.anchorBounds = anchorBounds + request.anchorBounds = anchor?.rect DisposableEffect(registry, request) { registry.registerOverlay(request) onDispose { registry.unregisterOverlay(request) } @@ -187,30 +192,36 @@ internal fun BalloonPopupLayer( // The framework's `windowSize` is NOT in the same coordinate space as the anchor // rectangles: on Android it is derived from the popup window's own metrics and excludes - // the system bars, while `boundsInWindow()` measures from the top of an edge-to-edge + // the system bars, while the anchor rects are measured from the top of an edge-to-edge // window. Mixing them makes a balloon flip above its anchor although there is room below, // and makes the bottom strip of the window unreachable by the final clamp. The container // size is the app window's own size, i.e. exactly the space the anchor rectangles live // in, so we use that and ignore the framework's value. val windowSize = LocalWindowInfo.current.containerSize - // An anchor can also be scrolled clean out of the window while the balloon is up. Left - // alone the balloon would sit clamped against a window edge pointing at nothing, so it is - // dismissed the moment its anchor is fully outside — the same outcome as the anchor - // leaving the composition, just triggered by geometry. - LaunchedEffect(anchorBounds, windowSize, state.isVisible) { - val bounds = anchorBounds - // `WindowInfo.containerSize` starts at `IntSize.Zero` and is filled in by the platform, - // so on a target that sizes its scene after the first composition every on-screen anchor - // would momentarily read as "past the right edge" and the balloon would dismiss itself - // the frame it was shown. An unmeasured window tells us nothing; wait for a real one. - if (windowSize.width <= 0 || windowSize.height <= 0) return@LaunchedEffect - if (state.isVisible && bounds != null && !bounds.isEmpty && - ( - bounds.right <= 0 || bounds.bottom <= 0 || - bounds.left >= windowSize.width || bounds.top >= windowSize.height - ) - ) { + // Latched once this show's anchor has been seen on screen; read only by the effect below, + // which is why writing it costs no recomposition. Re-created per `showGeneration` so each + // show makes up its own mind. + val anchorSeenOnScreen = remember(state, state.showGeneration) { mutableStateOf(false) } + + // An anchor can also go out of view while the balloon is up WITHOUT leaving the + // composition: `Modifier.verticalScroll` clips its children rather than disposing them + // (unlike a `LazyColumn` item), and a layout is free to push one off the window. Left alone + // the balloon would sit there pointing at nothing, so it is dismissed the moment its anchor + // goes away — the same outcome as the anchor leaving the composition, just triggered by + // geometry. `isOnScreen` is the whole test, covering the clipped and the off-window cases + // alike and already guarded against the degenerate ones; see [toBalloonAnchor]. + // + // "Goes away", though, and not merely "is not there": an anchor is allowed to arrive late. + // `AnimatedVisibility(enter = expandVertically())` clips its content to nothing on the frame + // it starts, so a balloon shown in that same frame would be dismissed before its anchor was + // ever visible — and, having been dismissed, would never come back. The balloon therefore + // waits for an anchor it has not seen yet, and only dismisses one it is watching disappear. + LaunchedEffect(anchor, state.isVisible) { + if (anchor == null) return@LaunchedEffect + if (anchor.isOnScreen) { + anchorSeenOnScreen.value = true + } else if (state.isVisible && anchorSeenOnScreen.value) { state.dismiss() } } @@ -223,7 +234,7 @@ internal fun BalloonPopupLayer( visibleState.targetState = state.isVisible val popupActive = visibleState.currentState || visibleState.targetState || !visibleState.isIdle - if (popupActive && anchorBounds != null) { + if (popupActive && anchor != null) { val offsetPx = with(density) { IntOffset( state.offset.x.roundToPx(), @@ -244,7 +255,7 @@ internal fun BalloonPopupLayer( placement, state.align, state.centerAlign, - anchorBounds, + anchor.rect, offsetPx, style, windowSize, @@ -253,7 +264,7 @@ internal fun BalloonPopupLayer( BalloonPopupPositionProvider( state = state, placement = placement, - anchorBounds = anchorBounds, + anchorBounds = anchor.rect, align = state.align, centerAlign = state.centerAlign, userOffsetPx = offsetPx, @@ -361,17 +372,90 @@ internal fun resolveArrowOrientation( } /** - * Helper to convert a `Rect` (window-pixel coordinates) into an [IntRect] using + * Helper to convert a [Rect] (window-pixel coordinates) into an [IntRect] using * [Float.roundToInt] on each edge. Mirrors the rounding the framework uses * internally for popup placement. */ -internal fun androidx.compose.ui.geometry.Rect.toIntRect(): IntRect = IntRect( +internal fun Rect.toIntRect(): IntRect = IntRect( left = left.roundToInt(), top = top.roundToInt(), right = right.roundToInt(), bottom = bottom.roundToInt(), ) +/** + * What one layout pass told us about a balloon's anchor: where the anchor is, and whether any + * of it is still on screen. + * + * One value rather than two parallel parameters, so the rect and the verdict — both read from + * the same [LayoutCoordinates] in the same callback — cannot drift apart. + */ +@Immutable +internal data class BalloonAnchor( + /** The anchor's bounds in window coordinates, NOT clipped. See [toBalloonAnchor]. */ + val rect: IntRect, + /** + * Whether any part of the anchor survives the clipping of its ancestors and of the window. + * False once a scrolling container — or the window edge — has taken it out of view. + */ + val isOnScreen: Boolean, +) + +/** + * Captures the anchor geometry a balloon is placed against, out of the anchor's layout + * coordinates. + * + * ## Why not `boundsInWindow()` + * + * Because it is clipped, and a balloon needs to know where its anchor *is*, not how much of it + * survived. `boundsInWindow()` intersects the rect with every clipping ancestor, coerces the + * result into the root's bounds, and returns `Rect.Zero` when that leaves nothing — so an + * anchor scrolled out of a `Modifier.verticalScroll` column (which clips its children rather + * than disposing them, unlike a `LazyColumn` item) reports bounds of `0, 0, 0, 0`. The balloon + * was then placed against the window's origin: the arrow jumping to the top-left corner + * reported in #1022. The same collapse loses the position of a zero-sized anchor, which has no + * area to survive clipping in the first place. + * + * Mapping the anchor's own four corners through [LayoutCoordinates.localToWindow] keeps its + * true position in every one of those cases. Going straight from local to window space also + * avoids the intermediate axis-aligned box in root space that the framework helper builds, so + * a rotated ancestor yields a tighter rect rather than a looser one. + * + * The clipped rect is still exactly the right question to ask about VISIBILITY, which is what + * [BalloonAnchor.isOnScreen] reads it for. + */ +internal fun LayoutCoordinates.toBalloonAnchor(): BalloonAnchor { + val width = size.width.toFloat() + val height = size.height.toFloat() + // All four corners, not just two: an ancestor may rotate or scale the anchor, in which case + // the extremes of the window-space box are not the images of the local top-left and + // bottom-right. `minOf`/`maxOf` are nested in pairs to stay off the vararg overloads, which + // would allocate an array on every layout pass of a scrolling anchor. + val topLeft = localToWindow(Offset.Zero) + val topRight = localToWindow(Offset(width, 0f)) + val bottomLeft = localToWindow(Offset(0f, height)) + val bottomRight = localToWindow(Offset(width, height)) + val rect = Rect( + left = minOf(minOf(topLeft.x, topRight.x), minOf(bottomLeft.x, bottomRight.x)), + top = minOf(minOf(topLeft.y, topRight.y), minOf(bottomLeft.y, bottomRight.y)), + right = maxOf(maxOf(topLeft.x, topRight.x), maxOf(bottomLeft.x, bottomRight.x)), + bottom = maxOf(maxOf(topLeft.y, topRight.y), maxOf(bottomLeft.y, bottomRight.y)), + ).toIntRect() + + // Being clipped is exactly what makes `boundsInWindow()` the right answer to the VISIBILITY + // question: it empties out when a clipping ancestor has cut the anchor away, and when the + // window has. It also empties out for two reasons that say nothing about visibility, so those + // are ruled out first — an anchor with no area of its own clips to nothing wherever it sits, + // and an unmeasured root (some targets size their scene after the first composition) empties + // out every anchor in the tree, which would dismiss a balloon on the frame it was shown. + val rootSize = findRootCoordinates().size + val isOnScreen = size.width == 0 || size.height == 0 || + rootSize.width == 0 || rootSize.height == 0 || + !boundsInWindow().isEmpty + + return BalloonAnchor(rect = rect, isOnScreen = isOnScreen) +} + /** * Computes the popup offset from the captured anchor bounds, the requested * alignment, the arrow size and the user-supplied offset, and writes back the @@ -473,8 +557,8 @@ internal class BalloonPopupPositionProvider( val anchorCenterX = captured.left + halfAnchorW val anchorCenterY = captured.top + halfAnchorH - // Same reasoning as the visibility effect: an unmeasured window would clamp every balloon - // to the origin. Fall back to the popup's own extent, which makes the clamp a no-op. + // An unmeasured window would clamp every balloon to the origin. Fall back to the popup's + // own extent, which makes the clamp a no-op until a real size arrives. val maxX = (windowSize.width - popupW).coerceAtLeast(0) .let { if (windowSize.width <= 0) Int.MAX_VALUE else it } val maxY = (windowSize.height - popupH).coerceAtLeast(0) diff --git a/balloon/src/skiaTest/kotlin/com/skydoves/balloon/ReportedScenarioTest.kt b/balloon/src/skiaTest/kotlin/com/skydoves/balloon/ReportedScenarioTest.kt index a30fb7c2..90fcbedc 100644 --- a/balloon/src/skiaTest/kotlin/com/skydoves/balloon/ReportedScenarioTest.kt +++ b/balloon/src/skiaTest/kotlin/com/skydoves/balloon/ReportedScenarioTest.kt @@ -16,28 +16,44 @@ package com.skydoves.balloon +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ComposeUiTest import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.runComposeUiTest import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue /** - * Layouts that were reported as broken against the 1.x implementations. + * Layouts and interactions that were reported as broken — the first two against the 1.x + * implementations, the rest against 2.0.0. * - * The rewrite makes both of these structural rather than incidental, but "structural" is exactly - * the kind of claim that quietly stops being true, and each of these cost a user a bug report + * The rewrite makes the 1.x pair structural rather than incidental, but "structural" is exactly + * the kind of claim that quietly stops being true, and every case here cost a user a bug report * once already. They are cheap to keep. */ class ReportedScenarioTest { @@ -102,4 +118,188 @@ class ReportedScenarioTest { assertTrue(state.isVisible) onNodeWithTag("body").assertIsDisplayed() } + + // ------------------------------------------------------- #1022: scrolled-away anchors + + /** + * An anchor scrolled out of a scrolling container must not strand the balloon at the + * window's origin. + * + * Reported as #1022: the balloon's arrow jumped to the top-left corner of the window as soon + * as the anchor was scrolled off-screen. The anchor bounds were captured with + * `boundsInWindow()`, which clips, and clipping an anchor away entirely yields `Rect.Zero` — + * so the balloon was positioned against a 0 x 0 anchor at the window origin, and the + * "anchor left the window" dismissal never fired because it skipped empty rects. + * [toBalloonAnchor] now reports the anchor's true rect plus a separate verdict on whether it + * is still on screen. + */ + @OptIn(ExperimentalTestApi::class) + @Test + fun anAnchorScrolledOutOfAScrollingColumnDismissesTheBalloon() = runComposeUiTest { + lateinit var state: BalloonState + val scroll = ScrollState(0) + setContent { + Column(Modifier.fillMaxSize().verticalScroll(scroll)) { + Spacer(Modifier.height(600.dp)) + state = rememberBalloonState(BalloonStyle(animation = BalloonAnimation.NONE)) + ScrollAnchoredBalloon(state) + Spacer(Modifier.height(2000.dp)) + } + } + runOnUiThread { state.showAlignBottom() } + waitForIdle() + onNodeWithTag("body").assertIsDisplayed() + + // Far enough that the anchor is well clear of the top of the window. + runOnUiThread { scroll.dispatchRawDelta(2000f) } + waitForIdle() + + assertFalse(state.isVisible, "a balloon whose anchor scrolled away must dismiss itself") + assertEquals(0, nodeCount("body"), "and its body must not be left sitting in the window") + } + + /** + * A balloon points at where its anchor really is, even while a scrolling container is + * clipping part of the anchor away. + * + * The other half of #1022, and the half that is invisible in a full-window scroll: with + * clipped bounds a half-scrolled anchor reports the visible remainder, so the balloon drifts + * off the anchor's real edge as it scrolls rather than staying glued to it. A 200dp viewport + * sitting 300dp down the window keeps the arithmetic clear of the final on-screen clamp, + * which would otherwise mask the difference. + */ + @OptIn(ExperimentalTestApi::class) + @Test + fun aBalloonTracksItsAnchorsRealEdgeWhileTheAnchorIsPartlyClipped() = runComposeUiTest { + lateinit var state: BalloonState + val scroll = ScrollState(0) + setContent { + Column(Modifier.fillMaxSize()) { + Spacer(Modifier.height(300.dp)) + Column(Modifier.height(200.dp).verticalScroll(scroll)) { + state = rememberBalloonState(BalloonStyle(animation = BalloonAnimation.NONE)) + ScrollAnchoredBalloon(state) + Spacer(Modifier.height(1000.dp)) + } + } + } + runOnUiThread { state.showAlignTop() } + waitForIdle() + // The gap the balloon holds off its anchor: the body tag is on the content box, which sits + // inside the card's margin and the space reserved for the arrow. Its exact value is the + // shape suite's business — all that matters here is that scrolling does not change it. + val gapWhenFullyVisible = anchorTopMinusBodyBottom() + + // Scroll the anchor half out of the top of the viewport: its real top edge is now 30dp + // above the viewport, while the part of it that survives clipping starts at the viewport. + runOnUiThread { scroll.dispatchRawDelta(30f) } + waitForIdle() + + assertEquals( + gapWhenFullyVisible, + anchorTopMinusBodyBottom(), + absoluteTolerance = 0.5f, + message = "the balloon should stay the same distance off the anchor's real top edge " + + "once the anchor is partly clipped, not follow the edge of the clipping viewport", + ) + } + + /** + * An anchor clipped away by a scrolling container it sits inside still dismisses the balloon, + * even though the anchor never leaves the window. + * + * This is the case the old geometry test could not see at all: it compared the anchor rect + * against the window, and an anchor scrolled out of a 200dp viewport in the middle of the + * screen is still very much inside the window. It is also the one place where a + * `Modifier.verticalScroll` column behaves unlike a `LazyColumn`, which disposes the item and + * so has always dismissed through `onDispose`. + */ + @OptIn(ExperimentalTestApi::class) + @Test + fun anAnchorClippedAwayWithoutLeavingTheWindowAlsoDismissesTheBalloon() = runComposeUiTest { + lateinit var state: BalloonState + val scroll = ScrollState(0) + setContent { + Column(Modifier.fillMaxSize()) { + Spacer(Modifier.height(300.dp)) + Column(Modifier.height(200.dp).verticalScroll(scroll)) { + state = rememberBalloonState(BalloonStyle(animation = BalloonAnimation.NONE)) + ScrollAnchoredBalloon(state) + Spacer(Modifier.height(1000.dp)) + } + } + } + runOnUiThread { state.showAlignBottom() } + waitForIdle() + onNodeWithTag("body").assertIsDisplayed() + + // Past the anchor's own height, so nothing of it survives the viewport's clip — but the + // anchor's real rect is still inside the window, 150dp down from the top. + runOnUiThread { scroll.dispatchRawDelta(150f) } + waitForIdle() + + assertTrue( + onNodeWithTag("anchor").getUnclippedBoundsInRoot().top.value > 0f, + "the anchor should still be within the window for this to test what it claims", + ) + assertFalse(state.isVisible, "a balloon whose anchor was clipped away must dismiss itself") + assertEquals(0, nodeCount("body")) + } + + /** + * The boundary of that dismissal: an anchor is allowed to arrive late. + * + * `AnimatedVisibility(enter = expandVertically())` clips its content to nothing on the frame + * the animation starts, so "the anchor is clipped away" is also true of an anchor that has not + * appeared yet. A balloon shown in that frame must wait for it — dismissing would be + * permanent, leaving `show()` looking like it did nothing at all. + */ + @OptIn(ExperimentalTestApi::class) + @Test + fun aBalloonWaitsForAnAnchorThatIsStillAnimatingIn() = runComposeUiTest { + lateinit var state: BalloonState + var revealed by mutableStateOf(false) + setContent { + Column(Modifier.fillMaxSize()) { + Spacer(Modifier.height(200.dp)) + state = rememberBalloonState(BalloonStyle(animation = BalloonAnimation.NONE)) + AnimatedVisibility( + visible = revealed, + enter = expandVertically(animationSpec = tween(durationMillis = 400)), + ) { ScrollAnchoredBalloon(state) } + } + } + + mainClock.autoAdvance = false + // The anchor begins appearing and the balloon is shown in the very same frame. + runOnUiThread { + revealed = true + state.showAlignBottom() + } + mainClock.advanceTimeByFrame() + mainClock.advanceTimeByFrame() + assertTrue(state.isVisible, "the balloon must not dismiss itself while its anchor expands in") + + mainClock.advanceTimeBy(500) + assertTrue(state.isVisible, "and must still be up once the anchor has finished appearing") + onNodeWithTag("body").assertIsDisplayed() + } + + /** How far the balloon body sits above the anchor's real (unclipped) top edge. */ + @OptIn(ExperimentalTestApi::class) + private fun ComposeUiTest.anchorTopMinusBodyBottom(): Float = + onNodeWithTag("anchor").getUnclippedBoundsInRoot().top.value - + onNodeWithTag("body").getUnclippedBoundsInRoot().bottom.value + + @Composable + private fun ScrollAnchoredBalloon(state: BalloonState) { + Balloon( + state = state, + balloonContent = { Box(Modifier.size(120.dp, 40.dp).testTag("body")) }, + ) { Box(Modifier.size(60.dp).testTag("anchor")) } + } } + +@OptIn(ExperimentalTestApi::class) +private fun ComposeUiTest.nodeCount(tag: String): Int = + onAllNodes(hasTestTag(tag)).fetchSemanticsNodes().size diff --git a/docs/showing.md b/docs/showing.md index 496b3d77..f4420416 100644 --- a/docs/showing.md +++ b/docs/showing.md @@ -68,7 +68,10 @@ It returns `false` and schedules nothing when the balloon is not showing. A balloon also dismisses itself when: - its anchor leaves the composition, for example a `LazyColumn` item scrolling out of the pool -- its anchor scrolls entirely out of the window +- its anchor goes entirely out of view: off the window, or clipped away by a scrolling + container it sits inside, such as a `Column` with `Modifier.verticalScroll`. An anchor that + has not appeared yet does not count — a balloon shown while its anchor is still animating in + waits for it. - `setAutoDismissDuration` elapses - the user taps outside it, presses back or Escape, or taps the body, depending on the style