[英]Jetpack Compose create chat bubble with arrow and border/elevation
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
)
}
)
声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.