From 8a7e1eca9b8eac661376035e61437c678d6985e1 Mon Sep 17 00:00:00 2001 From: skydoves Date: Sat, 29 Aug 2026 21:19:42 +0900 Subject: [PATCH] Cover the layouts that were reported broken against 1.x Three open issues describe layouts the View and balloon-compose implementations got wrong. The rewrite makes all three structural rather than incidental, but that is exactly the kind of claim that quietly stops being true, so each one gets a test before the issues are closed. - A height on the anchor's PARENT clamping the balloon (#952). The body is measured in a `Popup` against the window now, and `setHeight` maps to `requiredHeight`, so a 44dp parent no longer produces a 44dp balloon. - An anchor inside a `Dialog` (#918), which used to crash casting layout params and then showed nothing once it stopped crashing. The test asserts the body is really displayed, not just that `isVisible` flipped. - A full-bleed body against a large corner radius (#970). 1.x had `setIsClipArrowEnabled`, off by default, which is what let a custom `setLayout` paint square corners over a rounded background. The clip is unconditional here, and the new golden fills its corners in with the body colour if that ever changes. --- .../skydoves/balloon/golden/GoldenCases.kt | 13 +++ .../content-full-bleed-large-radius.png | Bin 0 -> 4369 bytes .../skydoves/balloon/ReportedScenarioTest.kt | 105 ++++++++++++++++++ 3 files changed, 118 insertions(+) create mode 100644 balloon/src/desktopTest/resources/golden/content-full-bleed-large-radius.png create mode 100644 balloon/src/skiaTest/kotlin/com/skydoves/balloon/ReportedScenarioTest.kt diff --git a/balloon/src/desktopTest/kotlin/com/skydoves/balloon/golden/GoldenCases.kt b/balloon/src/desktopTest/kotlin/com/skydoves/balloon/golden/GoldenCases.kt index f80c51b6..6457515a 100644 --- a/balloon/src/desktopTest/kotlin/com/skydoves/balloon/golden/GoldenCases.kt +++ b/balloon/src/desktopTest/kotlin/com/skydoves/balloon/golden/GoldenCases.kt @@ -1071,6 +1071,19 @@ private val overlayCases: List = listOf( */ private val contentCases: List = listOf( GoldenCase("content-large", style(), content = { GoldenBody(240, 160) }), + // A full-bleed body against a large radius: with no padding the content reaches the corners, + // so it is only the shape clip that keeps them round. 1.x had a `setIsClipArrowEnabled` knob + // that defaulted to off, which is what made a custom `setLayout` render square corners over a + // rounded background. There is no such knob here and the clip is unconditional; if that ever + // regresses, the corners of this golden fill in with the body colour. + GoldenCase( + name = "content-full-bleed-large-radius", + style = style { + setPadding(0.dp) + setCornerRadius(28.dp) + }, + content = { GoldenBody(180, 120) }, + ), GoldenCase("content-tiny", style(), content = { GoldenBody(8, 8) }), GoldenCase("content-tall-narrow", style(), content = { GoldenBody(24, 220) }), GoldenCase("content-wide-flat", style(), content = { GoldenBody(320, 12) }), diff --git a/balloon/src/desktopTest/resources/golden/content-full-bleed-large-radius.png b/balloon/src/desktopTest/resources/golden/content-full-bleed-large-radius.png new file mode 100644 index 0000000000000000000000000000000000000000..a1ff4a21fac38fc4a3072cebaddf9fccd1adaf63 GIT binary patch literal 4369 zcmeI0YgAKL7RPU5EZ_))(lHeR1W+t4C9TRc2%$iEs9^Dd6isBHJOoWCV-OOOFrZKv z3~R81;bCkM@xePl3J4b;1gk=-fk+r0Vl*}gi6KA|2;8~Be(0C^KFh_od-mDqzwbJC z@84eg{%|lX$aI6%1^@s|LxKY$002=80LVMWAHkNpo^mVzpoc>O{0^TZ&5Z49Aid5K z#5nEOA?fP{pMLh)Zw`FNYd1s=+s__zjyQZF>F~{4+t;`DUloNVUJp6aY8`87=Qcik z8R*4sH2Z66W1v+;TH-#(FHUYv-uCL<4#%IO{YaY5`h6YM56=nIWZ&$jw8tnQJ6d1| zu!R0ZAm7*+us|Si4mcnHg<|Ei0JH@fa6lr70c)FUm#~%zYvu5N#)RA>OEP{pPky(| z#Q3%w;~?Md*ltA3kidLO{86$Ku69K0H#TyzAC-H=+V@7ZbuEY0cuHz?HaW-D5`FZv z{_2_2jq%8Pw~Fir>rBP_iN{)1IUTJZyx5D~6&|TCzw0S7F{WQ2D1MQyK92TC*}gtw z-#^}&R}Q&1Zoz%7)k@hl|Dk}(kq8c?(Z}{sEYY8OFAT?m1${N;%#GU9NF+FU-`uz2 zOy^8nAW>#l4q3fRHW;4<*`0VG74XKtjI!WLe%al#Q4CE-4t0VOmu_eet9WJFmAoPJ%Sjj=}3iFFpFLsH*dnA|YoNGD30hkOpris*O=| zD+ApT&I{u5giS$7;yug)3cRFdD#I~GN#Yh!xL%^Ib#}n@x_Hl5_xhE&&gfN3eF+CF z;4_oa4C&Rg%CYB4EY~#d`z9fe3Rlmw9^xu;_dbg=cs=l7f zx|BINn6+xrbkio@Ym5N7S{UbFBFOs(-OstjvF_%~%upfbZneBE(mIWBXIoq}jwS_|p%vNrYyP z0G`06`&&3iZn|^T;kY(y0{t!X&}HVd|)OtuUnBmnf2%w-@$}W5DegWXyySzMXM~q46NMO z{?mC2j_?wu&(4d3DMd6zC)tXNuSUfM(P2!Yp0G7h|*y^zn&&1V_qT%*`L}aI)HzQEP49{D6mU{YDWpR~GPV-UIko2M`h%7Eu4?$$$J8*~`A; literal 0 HcmV?d00001 diff --git a/balloon/src/skiaTest/kotlin/com/skydoves/balloon/ReportedScenarioTest.kt b/balloon/src/skiaTest/kotlin/com/skydoves/balloon/ReportedScenarioTest.kt new file mode 100644 index 00000000..a30fb7c2 --- /dev/null +++ b/balloon/src/skiaTest/kotlin/com/skydoves/balloon/ReportedScenarioTest.kt @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2019 skydoves + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.skydoves.balloon + +import androidx.compose.foundation.layout.Box +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.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +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.assertTrue + +/** + * Layouts that were reported as broken against the 1.x implementations. + * + * 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 + * once already. They are cheap to keep. + */ +class ReportedScenarioTest { + + /** + * A height on the anchor's PARENT must not clamp the balloon. + * + * Reported as #952 against `balloon-compose`, which measured the body against + * `constraints.maxHeight`, so wrapping the anchor in a 44dp-tall Box gave a 44dp-tall balloon. + * Here the body lives in a `Popup` and is measured against the window, and `setHeight` maps to + * `requiredHeight`, so neither the anchor's parent nor an incoming constraint can squeeze it. + */ + @OptIn(ExperimentalTestApi::class) + @Test + fun aHeightOnTheAnchorsParentDoesNotClampTheBalloon() = runComposeUiTest { + lateinit var state: BalloonState + setContent { + BalloonHost(modifier = Modifier.fillMaxSize()) { + state = rememberBalloonState(BalloonStyle(animation = BalloonAnimation.NONE)) + Box(Modifier.height(44.dp).width(80.dp)) { + Balloon( + state = state, + balloonContent = { Box(Modifier.size(120.dp, 200.dp).testTag("body")) }, + ) { Box(Modifier.size(40.dp).testTag("anchor")) } + } + } + } + runOnUiThread { state.showAlignBottom() } + waitForIdle() + + val body = onNodeWithTag("body").fetchSemanticsNode().size + assertEquals(200, body.height, "the 44dp parent must not clamp the balloon body") + assertEquals(120, body.width, "nor the 80dp parent width") + } + + /** + * An anchor inside a `Dialog` still gets a balloon. + * + * Reported as #918, where `balloon-compose` crashed casting the dialog's layout params to + * `FrameLayout.LayoutParams`, and then, once that no longer crashed, showed nothing at all. + * There are no layout params to cast here, but a dialog is still its own window, so this + * checks the balloon is really composed rather than just flagged visible. + */ + @OptIn(ExperimentalTestApi::class) + @Test + fun anAnchorInsideADialogStillGetsABalloon() = runComposeUiTest { + lateinit var state: BalloonState + setContent { + Dialog(onDismissRequest = {}) { + BalloonHost { + state = rememberBalloonState(BalloonStyle(animation = BalloonAnimation.NONE)) + Balloon( + state = state, + balloonContent = { Box(Modifier.size(120.dp, 40.dp).testTag("body")) }, + ) { Box(Modifier.size(40.dp).testTag("anchor")) } + } + } + } + runOnUiThread { state.showAlignBottom() } + waitForIdle() + + assertTrue(state.isVisible) + onNodeWithTag("body").assertIsDisplayed() + } +}