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.