【问题标题】:Jetpack Compose create chat bubble with arrow and border/elevationJetpack Compose 创建带有箭头和边框/高度的聊天气泡
【发布时间】:2021-01-30 08:00:08
【问题描述】:

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

【问题讨论】:

  • @Anshul1507 检查了它。它具有仅用于聊天行的圆形形状,即Box( shape = RoundedCornerShape(8.dp)) 。我的问题是要有一个带箭头的气泡,如果可能的话,就像 Telegram 的治疗方法一样,并且带有阴影/边框/海拔。
  • 您必须创建自己的自定义形状并将其应用到Modifier.background,这应该是一个不错的尝试。

标签: android android-jetpack-compose


【解决方案1】:

您可以定义您的自定义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()
              ){
      }

【讨论】:

  • 先生,此解决方案有效,但生成的形状中没有阴影
【解决方案2】:

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

Custom Shape with Jetpack Compose - Article

及源代码:

Custom Shape with Jetpack Compose - Source code

【讨论】:

    【解决方案3】:

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

    我可以把这个过程总结为

    第一步

    为包装属性创建一个状态

    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 步 创建返回的函数记住不要在每次重组时创建 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
            )
        }
    } 
    

    第 3 步 测量布局 我们需要根据它的位置计算箭头提示的空间,在测量我们的内容时使用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)
        }
    }
    

    我们还需要一个 Rectangle 来捕获不包括箭头尺寸的内容位置。

    步骤 4 使用包含箭头方向的状态创建路径,在 y 或 x 轴上偏移,并使用绘制选项和我们从上一步获得的矩形有点长,您可以在源代码中查看它here如果你希望。也仍然没有圆形或弯曲的路径,如果你能提供帮助,那就太受欢迎了。

    第 5 步 创建一个composed(有状态)修饰符来布局,并在我们的内容后面绘制气泡。

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

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2017-10-13
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2014-04-23
      • 2014-05-05
      相关资源
      最近更新 更多