繁体   English   中英

Jetpack Compose 使用箭头和边框/高度创建聊天气泡

[英]Jetpack Compose create chat bubble with arrow and border/elevation

我如何创建一个像电报或 whatsapp 这样的聊天气泡,它像图像中那样在左侧或右侧具有高度和箭头?

在此处输入图像描述

您可以定义自定义Shape

例如,您可以使用以下方法定义一个三角形:

class TriangleEdgeShape(val offset: Int) : Shape {

    override fun createOutline(
        size: Size,
        layoutDirection: LayoutDirection,
        density: Density
    ): Outline {
        val trianglePath = Path().apply {
            moveTo(x = 0f, y = size.height-offset)
            lineTo(x = 0f, y = size.height)
            lineTo(x = 0f + offset, y = size.height)
        }
        return Outline.Generic(path = trianglePath)
    }
}

您还可以扩展RoundedCornerShape ,在右下角添加小三角形。

然后你可以定义类似的东西:

Row(Modifier.height(IntrinsicSize.Max)) {
      Column(
          modifier = Modifier.background(
              color = Color.xxx,
              shape = RoundedCornerShape(4.dp,4.dp,0.dp,4.dp)
          ).width(xxxx)
      ) {
          Text("Chat")
      }
      Column(
          modifier = Modifier.background(
                        color = Color.xxx,
                        shape = TriangleEdgeShape(10))
                     .width(8.dp)
                     .fillMaxHeight()
              ){
      }

在此处输入图像描述

创建自定义形状。 这是比 Gabriele 更好的解决方案,因为它可以让您保持整个边界周围的高程。 这是一篇关于创建自定义形状的好文章:

使用 Jetpack Compose 自定义形状 - 文章

和源代码:

使用 Jetpack Compose 自定义形状 - 源代码

用形状、箭头和阴影构建它非常复杂。 我使用自定义修改器创建它,记住,canvas 和绘图路径。 repo中提供了完整的实现。

在此处输入图像描述

我可以将过程总结为

步骤1

创建一个 state 用于包装属性

class BubbleState internal constructor(
    var backgroundColor: Color = DefaultBubbleColor,
    var cornerRadius: BubbleCornerRadius = BubbleCornerRadius(
        topLeft = 8.dp,
        topRight = 8.dp,
        bottomLeft = 8.dp,
        bottomRight = 8.dp,
    ),
    var alignment: ArrowAlignment = ArrowAlignment.None,
    var arrowShape: ArrowShape = ArrowShape.TRIANGLE_RIGHT,
    var arrowOffsetX: Dp = 0.dp,
    var arrowOffsetY: Dp = 0.dp,
    var arrowWidth: Dp = 14.dp,
    var arrowHeight: Dp = 14.dp,
    var arrowRadius: Dp = 0.dp,
    var drawArrow: Boolean = true,
    var shadow: BubbleShadow? = null,
    var padding: BubblePadding? = null,
    var clickable: Boolean = false
) {

    /**
     * Top position of arrow. This is read-only for implementation. It's calculated when arrow
     * positions are calculated or adjusted based on width/height of bubble,
     * offsetX/y, arrow width/height.
     */
    var arrowTop: Float = 0f
        internal set

    /**
     * Bottom position of arrow.  This is read-only for implementation. It's calculated when arrow
     * positions are calculated or adjusted based on width/height of bubble,
     * offsetX/y, arrow width/height.
     */

    var arrowBottom: Float = 0f
        internal set

    /**
     * Right position of arrow.  This is read-only for implementation. It's calculated when arrow
     * positions are calculated or adjusted based on width/height of bubble,
     * offsetX/y, arrow width/height.
     */
    var arrowLeft: Float = 0f
        internal set

    /**
     * Right position of arrow.  This is read-only for implementation. It's calculated when arrow
     * positions are calculated or adjusted based on width/height of bubble,
     * offsetX/y, arrow width/height.
     */
    var arrowRight: Float = 0f
        internal set


    /**
     * Arrow is on left side of the bubble
     */
    fun isHorizontalLeftAligned(): Boolean =
        (alignment == ArrowAlignment.LeftTop
                || alignment == ArrowAlignment.LeftBottom
                || alignment == ArrowAlignment.LeftCenter)


    /**
     * Arrow is on right side of the bubble
     */
    fun isHorizontalRightAligned(): Boolean =
        (alignment == ArrowAlignment.RightTop
                || alignment == ArrowAlignment.RightBottom
                || alignment == ArrowAlignment.RightCenter)


    /**
     * Arrow is on top left or right side of the bubble
     */
    fun isHorizontalTopAligned(): Boolean =
        (alignment == ArrowAlignment.LeftTop || alignment == ArrowAlignment.RightTop)


    /**
     * Arrow is on top left or right side of the bubble
     */
    fun isHorizontalBottomAligned(): Boolean =
        (alignment == ArrowAlignment.LeftBottom || alignment == ArrowAlignment.RightBottom)

    /**
     * Check if arrow is horizontally positioned either on left or right side
     */
    fun isArrowHorizontallyPositioned(): Boolean =
        isHorizontalLeftAligned()
                || isHorizontalRightAligned()


    /**
     * Arrow is at the bottom of the bubble
     */
    fun isVerticalBottomAligned(): Boolean =
        alignment == ArrowAlignment.BottomLeft ||
                alignment == ArrowAlignment.BottomRight ||
                alignment == ArrowAlignment.BottomCenter

    /**
     * Arrow is at the yop of the bubble
     */
    fun isVerticalTopAligned(): Boolean =
        alignment == ArrowAlignment.TopLeft ||
                alignment == ArrowAlignment.TopRight ||
                alignment == ArrowAlignment.TopCenter

    /**
     * Arrow is on left side of the bubble
     */
    fun isVerticalLeftAligned(): Boolean =
        (alignment == ArrowAlignment.BottomLeft) || (alignment == ArrowAlignment.TopLeft)


    /**
     * Arrow is on right side of the bubble
     */
    fun isVerticalRightAligned(): Boolean =
        (alignment == ArrowAlignment.BottomRight) || (alignment == ArrowAlignment.TopRight)


    /**
     * Check if arrow is vertically positioned either on top or at the bottom of bubble
     */
    fun isArrowVerticallyPositioned(): Boolean = isVerticalBottomAligned() || isVerticalTopAligned()
}

第 2 步创建返回的 function 记住不要在每次重组时创建 BubbleState。

fun rememberBubbleState(
    backgroundColor: Color = DefaultBubbleColor,
    cornerRadius: BubbleCornerRadius = BubbleCornerRadius(
        topLeft = 8.dp,
        topRight = 8.dp,
        bottomLeft = 8.dp,
        bottomRight = 8.dp
    ),
    alignment: ArrowAlignment = ArrowAlignment.None,
    arrowShape: ArrowShape = ArrowShape.TRIANGLE_RIGHT,
    arrowOffsetX: Dp = 0.dp,
    arrowOffsetY: Dp = 0.dp,
    arrowWidth: Dp = 14.dp,
    arrowHeight: Dp = 14.dp,
    arrowRadius: Dp = 0.dp,
    drawArrow: Boolean = true,
    shadow: BubbleShadow? = null,
    padding: BubblePadding? = null,
    clickable:Boolean = false
): BubbleState {

    return remember {
        BubbleState(
            backgroundColor = backgroundColor,
            cornerRadius = cornerRadius,
            alignment = alignment,
            arrowShape = arrowShape,
            arrowOffsetX = arrowOffsetX,
            arrowOffsetY = arrowOffsetY,
            arrowWidth = arrowWidth,
            arrowHeight = arrowHeight,
            arrowRadius = arrowRadius,
            drawArrow = drawArrow,
            shadow = shadow,
            padding = padding,
            clickable = clickable
        )
    }
} 

Step 3 Measuring layout 我们需要根据它的位置计算箭头尖端的空间,在测量我们的内容时使用Constraints.offset来限制可放置的尺寸,并限制宽度/高度不溢出父级。

internal fun MeasureScope.measureBubbleResult(
    bubbleState: BubbleState,
    measurable: Measurable,
    constraints: Constraints,
    rectContent: BubbleRect,
    path: Path
): MeasureResult {

    val arrowWidth = (bubbleState.arrowWidth.value * density).roundToInt()
    val arrowHeight = (bubbleState.arrowHeight.value * density).roundToInt()

    // Check arrow position
    val isHorizontalLeftAligned = bubbleState.isHorizontalLeftAligned()
    val isVerticalTopAligned = bubbleState.isVerticalTopAligned()
    val isHorizontallyPositioned = bubbleState.isArrowHorizontallyPositioned()
    val isVerticallyPositioned = bubbleState.isArrowVerticallyPositioned()

    // Offset to limit max width when arrow is horizontally placed
    // if we don't remove arrowWidth bubble will overflow from it's parent as much as arrow
    // width is. So we measure our placeable as content + arrow width
    val offsetX: Int = if (isHorizontallyPositioned) {
        arrowWidth
    } else 0

    // Offset to limit max height when arrow is vertically placed

    val offsetY: Int = if (isVerticallyPositioned) {
        arrowHeight
    } else 0

    val placeable = measurable.measure(constraints.offset(-offsetX, -offsetY))

    val desiredWidth = constraints.constrainWidth(placeable.width + offsetX)
    val desiredHeight: Int = constraints.constrainHeight(placeable.height + offsetY)

    setContentRect(
        bubbleState,
        rectContent,
        desiredWidth,
        desiredHeight,
        density = density
    )

    getBubbleClipPath(
        path = path,
        state = bubbleState,
        contentRect = rectContent,
        density = density
    )


    // Position of content(Text or Column/Row/Box for instance) in Bubble
    // These positions effect placeable area for our content
    // if xPos is greater than 0 it's required to translate background path(bubble) to match total
    // area since left of  xPos is not usable(reserved for arrowWidth) otherwise
    val xPos = if (isHorizontalLeftAligned) arrowWidth else 0
    val yPos = if (isVerticalTopAligned) arrowHeight else 0


    return layout(desiredWidth, desiredHeight) {


        placeable.place(xPos, yPos)
    }
}

我们还需要一个矩形来捕获不包括箭头尺寸的内容 position。

第 4 步使用 state 创建路径,它环绕箭头方向,在 y 轴或 x 轴上偏移并使用绘制选项,我们从上一步获得的矩形有点长,如果您愿意,可以在此处查看源代码。 也仍然没有圆形或弯曲的路径,如果你能帮助它,它非常受欢迎。

第 5 步为布局创建一个组合(有状态)修改器,并在我们的内容后面绘制我们的气泡。

fun Modifier.drawBubble(bubbleState: BubbleState) = composed(

    // pass inspector information for debug
    inspectorInfo = debugInspectorInfo {
        // name should match the name of the modifier
        name = "drawBubble"
        // add name and value of each argument
        properties["bubbleState"] = bubbleState
    },

    factory = {

        val rectContent = remember { BubbleRect() }
        val path = remember { Path() }
        var pressed by remember { mutableStateOf(false) }

        Modifier
            .layout { measurable, constraints ->
//                println("Modifier.drawBubble() LAYOUT align:${bubbleState.alignment}")
                measureBubbleResult(bubbleState, measurable, constraints, rectContent, path)
            }

            .materialShadow(bubbleState, path, true)
            .drawBehind {
//                println(
//                    "✏️ Modifier.drawBubble() DRAWING align:${bubbleState.alignment}," +
//                            " size: $size, path: $path, rectContent: $rectContent"
//                )
                val left = if (bubbleState.isHorizontalLeftAligned())
                    -bubbleState.arrowWidth.toPx() else 0f

                translate(left = left) {
                    drawPath(
                        path = path,
                        color = if (pressed) bubbleState.backgroundColor.darkenColor(.9f)
                        else bubbleState.backgroundColor,
                    )

                }
            }
            .then(
                if (bubbleState.clickable) {
                    this.pointerInput(Unit) {
                        forEachGesture {
                            awaitPointerEventScope {
                                val down: PointerInputChange = awaitFirstDown()
                                pressed = down.pressed
                                waitForUpOrCancellation()
                                pressed = false
                            }
                        }
                    }
                } else this
            )
            .then(
                bubbleState.padding?.let { padding ->
                    this.padding(
                        padding.start,
                        padding.top,
                        padding.end,
                        padding.bottom
                    )
                } ?: this
            )
    }
)

我如何创建一个像电报或whatsapp这样的聊天气泡,在左侧或右侧有高程和箭头,如图所示?

在此处输入图像描述

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM