简体   繁体   English

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

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

How can i create a chat bubble like telegram or whatsapp that has elevation and arrow on left or right side like in the image?我如何创建一个像电报或 whatsapp 这样的聊天气泡,它像图像中那样在左侧或右侧具有高度和箭头?

在此处输入图像描述

You can define your custom Shape .您可以定义自定义Shape

For example you can define a Triangle using:例如,您可以使用以下方法定义一个三角形:

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)
    }
}

You can also extending the RoundedCornerShape adding the little triangle in the bottom right corner.您还可以扩展RoundedCornerShape ,在右下角添加小三角形。

Then you can define something like:然后你可以定义类似的东西:

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()
              ){
      }

在此处输入图像描述

Create a custom shape.创建自定义形状。 This is a better solution than Gabriele's because it lets you maintain an elevation around the entire border.这是比 Gabriele 更好的解决方案,因为它可以让您保持整个边界周围的高程。 Here's a good article on creating custom shapes:这是一篇关于创建自定义形状的好文章:

Custom Shape with Jetpack Compose - Article 使用 Jetpack Compose 自定义形状 - 文章

and the source code:和源代码:

Custom Shape with Jetpack Compose - Source code 使用 Jetpack Compose 自定义形状 - 源代码

Building this with a shape, arrow, and shadow is quite complex.用形状、箭头和阴影构建它非常复杂。 I created it using custom Modifier, remember, canvas and drawing path.我使用自定义修改器创建它,记住,canvas 和绘图路径。 Full implementation is available in this repo .repo中提供了完整的实现。

在此处输入图像描述

I can sum the process as我可以将过程总结为

Step1步骤1

Create a state for wrapping properties创建一个 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()
}

Step 2 Create function that returns remember to not create BubbleState at each recomposition.第 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 We need to calculate space for arrow tip based on it's location, use Constraints.offset to limit placeable dimensions when measuring for our content and constrain width/height to not overflow parent. 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)
    }
}

Also we need a Rectangle to capture content position that does exclude arrow dimensions.我们还需要一个矩形来捕获不包括箭头尺寸的内容 position。

Step 4 Create path using state that wraps arrow direction, offset in y or x axis and with draw option and rectangle we got from previous step is bit long, you can check it in source code here if you wish.第 4 步使用 state 创建路径,它环绕箭头方向,在 y 轴或 x 轴上偏移并使用绘制选项,我们从上一步获得的矩形有点长,如果您愿意,可以在此处查看源代码。 Also still no rounded or curved paths, if you can help with it, it's more than welcome.也仍然没有圆形或弯曲的路径,如果你能帮助它,它非常受欢迎。

Step 5 Create a composed (stateful) Modifier to layout, and draw our bubble behind our content.第 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
            )
    }
)

How can i create a chat bubble like telegram or whatsapp that has elevation and arrow on left or right side like in the image?我如何创建一个像电报或whatsapp这样的聊天气泡,在左侧或右侧有高程和箭头,如图所示?

在此处输入图像描述

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

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