Home > database >  Modifier factory functions should not be marked as @Composable
Modifier factory functions should not be marked as @Composable

Time:04-19

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;

  1. Why Modifier factory functions should not be marked as @Composable ?
  2. 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
  3. 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
        )
    }
)
  • Related