I have created an extension function for Modifier that will show a BalloonPopup to the user when clicked. I am using @Composable tag for this function because the content i will be showing inside the Balloon is also @Composable. However the compiler gives me the warning below;
"Modifier factory functions should not be marked as @Composable, and should use composed instead"
I have also applied the suggested change, but then the balloon simply not shown when user clicks on the view.
My questions are;
- Why Modifier factory functions should not be marked as @Composable ?
- What is the difference between using @Composable and composed { ... } for such an extension function. Because for the time being, i haven't seen any downside using @Composable tag
- Why the Balloon is not shown, even the code passes the if (showTooltip) condition when i debug my code.
Below are the codes for the function i used, both before and after the suggestion is applied;
Before:
@Composable
fun Modifier.setPopup(enabled: Boolean = false, content: @Composable BoxScope.() -> Unit): Modifier {
if (enabled) {
var anchorOffset by remember { mutableStateOf<LayoutCoordinates?>(null) }
var showTooltip by remember { mutableStateOf(false) }
if (showTooltip) {
BalloonPopup(
onDismissRequest = {
showTooltip = false
},
content = content,
anchorCoordinates = anchorOffset
)
}
return this.clickable {
showTooltip = true
}.onGloballyPositioned {
anchorOffset = it
}
} else {
return this
}
}
After:
fun Modifier.setPopup(enabled: Boolean = false, content: @Composable BoxScope.() -> Unit): Modifier = composed {
if (enabled) {
var anchorOffset by remember { mutableStateOf<LayoutCoordinates?>(null) }
var showTooltip by remember { mutableStateOf(false) }
if (showTooltip) {
BalloonPopup(
onDismissRequest = {
showTooltip = false
},
content = content,
anchorCoordinates = anchorOffset
)
}
this.clickable {
showTooltip = true
}.onGloballyPositioned {
anchorOffset = it
}
} else {
this
}
}
This is how i call the extension function;
Image(
modifier = Modifier
.setPopup(enabled = true) {
Text(
modifier = Modifier.padding(4.dp),
text = "-30 rssi",
fontSize = 13.sp
)
},
painter = painterResource(id = android.R.drawable.ic_secure),
contentDescription = "Signal Strength"
)
And this is the BalloonPopup class that is used inside the extension function;
suspend fun initTimer(time: Long, onEnd: () -> Unit) {
delay(timeMillis = time)
onEnd()
}
@Composable
fun BalloonPopup(
cornerRadius: Float = 8f,
arrowSize: Float = 32f,
dismissTime: Long = 3,
onDismissRequest: (() -> Unit)? = null,
anchorCoordinates: LayoutCoordinates? = null,
content: @Composable BoxScope.() -> Unit
) {
if (anchorCoordinates != null) {
var arrowPosition by remember { mutableStateOf(BalloonShape.ArrowPosition.TOP_RIGHT) }
/**
* copied from AlignmentOffsetPositionProvider of android sdk and added the calculation for
* arrowPosition
* */
class BalloonPopupPositionProvider(
val alignment: Alignment,
val offset: IntOffset
) : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize
): IntOffset {
// TODO: Decide which is the best way to round to result without reimplementing Alignment.align
var popupPosition = IntOffset(0, 0)
// Get the aligned point inside the parent
val parentAlignmentPoint = alignment.align(
IntSize.Zero,
IntSize(anchorBounds.width, anchorBounds.height),
layoutDirection
)
// Get the aligned point inside the child
val relativePopupPos = alignment.align(
IntSize.Zero,
IntSize(popupContentSize.width, popupContentSize.height),
layoutDirection
)
// Add the position of the parent
popupPosition = IntOffset(anchorBounds.left, anchorBounds.top)
// Add the distance between the parent's top left corner and the alignment point
popupPosition = parentAlignmentPoint
// Subtract the distance between the children's top left corner and the alignment point
popupPosition -= IntOffset(relativePopupPos.x, relativePopupPos.y)
// Add the user offset
val resolvedOffset = IntOffset(
offset.x * (if (layoutDirection == LayoutDirection.Ltr) 1 else -1),
offset.y
)
popupPosition = resolvedOffset
arrowPosition =
if (anchorBounds.left > popupPosition.x) {
BalloonShape.ArrowPosition.TOP_RIGHT
} else {
BalloonShape.ArrowPosition.TOP_LEFT
}
return popupPosition
}
}
var isVisible by remember { mutableStateOf(false) }
AnimatedVisibility(visible = isVisible) {
Popup(
popupPositionProvider = BalloonPopupPositionProvider(
alignment = Alignment.TopCenter,
offset = IntOffset(
x = anchorCoordinates.positionInParent().x.roundToInt(),
y = anchorCoordinates.positionInParent().y.roundToInt() anchorCoordinates.size.height
)
),
onDismissRequest = onDismissRequest
) {
Box(
modifier = Modifier
.wrapContentSize()
.shadow(
dimensionResource(id = R.dimen.cardview_default_elevation),
shape = BalloonShape(
cornerRadius = cornerRadius,
arrowSize = arrowSize,
arrowPosition = arrowPosition,
anchorCoordinates = anchorCoordinates
)
)
.border(
Dp.Hairline, CCTechAppDefaultTheme.primary,
shape = BalloonShape(
cornerRadius = cornerRadius,
arrowSize = arrowSize,
arrowPosition = arrowPosition,
anchorCoordinates = anchorCoordinates
)
)
.background(
shape = BalloonShape(
cornerRadius = cornerRadius,
arrowSize = arrowSize,
arrowPosition = arrowPosition,
anchorCoordinates = anchorCoordinates
),
color = CCTechAppDefaultTheme.surface
)
.padding(top = arrowSize.toDP())
) {
content()
}
}
}
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(key1 = Unit, block = {
isVisible = true
coroutineScope.launch {
initTimer(dismissTime * 1000) {
isVisible = false
(onDismissRequest ?: {}).invoke()
}
}
})
}
}
class BalloonShape(
private val cornerRadius: Float,
private val arrowSize: Float,
var arrowPosition: ArrowPosition = ArrowPosition.TOP_RIGHT,
private val anchorCoordinates: LayoutCoordinates
) : Shape {
enum class ArrowPosition {
TOP_LEFT, TOP_RIGHT
}
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Outline {
val balloonLeft = 0f
val balloonRight = size.width
val balloonTop = 0f arrowSize
val balloonBottom = size.height
val arrowTopY = 0f
val arrowTopX =
if (arrowPosition == ArrowPosition.TOP_LEFT) balloonLeft anchorCoordinates.size.width / 2
else balloonRight - anchorCoordinates.size.width / 2
val arrowLeftX = arrowTopX - (arrowSize / 2f)
val arrowLeftY = arrowTopY arrowSize
val arrowRightX = arrowTopX (arrowSize / 2f)
val arrowRightY = arrowTopY arrowSize
val path = Path().apply {
moveTo(
x = arrowTopX,
y = arrowTopY
) // Start point on the top of the arrow
lineTo(
x = arrowLeftX,
y = arrowLeftY
) // Left edge of the arrow
lineTo(
x = balloonLeft,
y = balloonTop
) // TopLeft edge of the rectangle
arcTo(
rect = Rect(
left = balloonLeft,
top = balloonTop,
right = balloonLeft cornerRadius * 2f,
bottom = balloonTop cornerRadius * 2f
),
startAngleDegrees = 270f,
sweepAngleDegrees = -90f,
forceMoveTo = false
)
lineTo(
x = balloonLeft,
y = balloonBottom
) // Left edge of the rectangle
arcTo(
rect = Rect(
left = balloonLeft,
top = balloonBottom - cornerRadius * 2f,
right = balloonLeft cornerRadius * 2f,
bottom = balloonBottom
),
startAngleDegrees = 180f,
sweepAngleDegrees = -90f,
forceMoveTo = false
)
lineTo(
x = balloonRight,
y = balloonBottom
) // Bottom edge of the rectangle
arcTo(
rect = Rect(
left = balloonRight - cornerRadius * 2f,
top = balloonBottom - cornerRadius * 2f,
right = balloonRight,
bottom = balloonBottom
),
startAngleDegrees = 90f,
sweepAngleDegrees = -90f,
forceMoveTo = false
)
lineTo(
x = balloonRight,
y = balloonTop
) // Right edge of the rectangle
arcTo(
rect = Rect(
left = balloonRight - cornerRadius * 2f,
top = balloonTop,
right = balloonRight,
bottom = balloonTop cornerRadius * 2f
),
startAngleDegrees = 0f,
sweepAngleDegrees = -90f,
forceMoveTo = false
)
lineTo(
x = arrowRightX,
y = arrowRightY
) // TopRight edge of the rectangle
close()
}
return Outline.Generic(path)
}
}
Thanks in advance.
CodePudding user response:
According to Compose modifier guidelines:
As a result, Jetpack Compose framework development and Library development SHOULD use
Modifier.composed {}
to implement composition-aware modifiers, and SHOULD NOT declare modifier extension factory functions as@Composable
functions themselves.
Why
Composed modifiers may be created outside of composition, shared across elements, and declared as top-level constants, making them more flexible than modifiers that can only be created via a
@Composable
function call, and easier to avoid accidentally sharing state across elements.
Using a view inside a modifier is kind of hacky. The modifier should change the state of the view to which it is applied, and should not affect the environment.
Your code works with @Composable
because it is called instantly, and the popup is added to the view tree as if it had been added before the calling view.
With composed
, the content is called later, when it starts measuring the view's position before render - since this code is not part of the view tree, your popup is not added to it.
The composed
code is used so that you can save state with remember
and also use local values such as LocalDensity
, but not for adding views.
You can do almost anything you want in your codebase, after all, you can silence this warning, but most people who see your code won't expect such the modifier adding a view in the view tree - that's what guidelines are for.
I think the expected way to implement such a feature is as follows (not sure about the naming, through):
@Composable
fun BalloonPopupRequester(
requesterView: @Composable (Modifier) -> Unit,
popupContent: @Composable BoxScope.() -> Unit
) {
var anchorOffset by remember { mutableStateOf<LayoutCoordinates?>(null) }
var showTooltip by remember { mutableStateOf(false) }
if (showTooltip) {
BalloonPopup(
onDismissRequest = {
showTooltip = false
},
content = popupContent,
anchorCoordinates = anchorOffset
)
}
requesterView(
Modifier
.clickable {
println("clickable")
showTooltip = true
}
.onGloballyPositioned {
anchorOffset = it
}
)
}
Usage:
BalloonPopupRequester(
requesterView = { modifier ->
Image(
modifier = modifier,
painter = painterResource(id = android.R.drawable.ic_secure),
contentDescription = "Signal Strength"
)
},
popupContent = {
Text(
modifier = Modifier.padding(4.dp),
text = "-30 rssi",
fontSize = 13.sp
)
}
)