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