Jetpack Compose: Vertical align drawText with other drawn elements - android

I'm drawing a custom progress bar, the idea is to have a vertical "line" of circles with space between them like this:
As you progress, the circles change color and size, and the text ("60" in the image I shared)is displayed alongside the top level circle. With the current text size it seems centered enough, but if I change the text size it is noticeable that is bottom aligned, like this:
Here is the code:
Box(
modifier
.aspectRatio(1f)
.drawWithCache {
onDrawBehind {
val height = size.height
val space = height / 10
for (i in 1..numberOfCircles) {
val onBrush =
if (i <= progressLevel) progressBrush(progressLevel) else Color.Gray.toBrush()
val circleSize =
if (i == progressLevel) circleRadius.value * 2.5F else if (i == progressLevel - 1) circleRadius.value * 1.5F else circleRadius.value
drawProgressCircle(
onBrush,
sizeArray[i - 1].value,
size.height - (space * i)
)
if (i == state.level) {
drawText(
onBrush,
circleSize,
size.height - (space * i),
(progressLevel * 10).toString()
)
}
}
}
},
contentAlignment = Alignment.Center
)
The draw circles function:
fun DrawScope.drawProgressCircle(
brush: Brush,
radius: Float,
place: Float
) {
drawCircle(
brush = brush,
radius = radius,
center = Offset(x = size.width/2, y = place),
)
}
The draw text function:
fun DrawScope.drawText(
brush: Brush,
radius: Float,
place: Float,
text: String
) {
drawContext.canvas.nativeCanvas.apply {
drawText(
text,
size.width/2.2F,
place + radius,
Paint().apply {
textSize = 20.sp.toPx()
color = Color.White.toArgb()
textAlign = Paint.Align.RIGHT
}
)
}
}
How can I keep the text vertically aligned at the last circle center with any text size?

Related

How to implement smooth scrolling Canvas animation in Compose?

I send a stream of shapes to the drawing pipeline. At any point in time, shapes contains the number of items that can fill the screen's width or less.
Here's the drawing widget:
#Composable
fun ShapeWidget(shapes: List<Shape>, size: Float, spacing: Float, screenWidth: Float) {
val color = MaterialTheme.colorScheme.tertiary
Canvas(
modifier = Modifier.fillMaxWidth(),
onDraw = {
shapes.forEachIndexed { i, s ->
val x = screenWidth - ((size + spacing) * i)
when (s.type) {
Type.RECT -> {
drawRect(
color = color,
size = Size(size, size),
topLeft = Offset(x, this.center.y - size / 2),
)
}
Type.CIRCLE -> {
drawCircle(
color = color,
radius = size / 2,
center = Offset(x + spacing * 2, center.y)
)
}
}
}
}
)
}
I want to achieve an effect similar to the marquee text effect in Android Views. The shapes start drawing from the right (of the screen) and scroll to the left as more shapes are drawn.
The code above does that, but the animation is janky; it doesn't give the illusion that the shapes are moving. It looks like the shapes are jumping, which is not what I want. This is what it looks like currently.
Please, do you have an idea how to fix this?

Border stroke not same on left and right side in custom shape

I'm trying to create an arrow without bottom line but when I'm applying border stroke. left side and right side border width and color are showing up different. Any idea why this is happening or any different approach to get arrow with border and a color to fill inside the arrow
Box(
modifier
.fillMaxWidth()
.height(height)
.border(border = BorderStroke(1.dp,borderColor), shape = DrawTriangleShape())
.background(color = color, shape = DrawTriangleShape())
)
class DrawTriangleShape : Shape {
override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline {
val trianglePath = Path().apply {
moveTo((size.width / 2 ) - with(density) { 10.dp.toPx() }, size.height)
lineTo((size.width / 2), 0f)
lineTo((size.width / 2 ) + with(density) { 10.dp.toPx() }, size.height)
}
return Outline.Generic(path = trianglePath)
}
}

Wavy box in Jetpack compose

Is there a way to make a box with a wavy top with Canvas?
I would like to know if this effect can be achieved directly with a Canvas, it is not necessary to have a scrolling animation.
It's not quite clear why you're talking about Canvas. To crop a view like this, you can use a custom Shape and apply it to your view with Modifier.clip. Here's a shape you can use:
class WavyShape(
private val period: Dp,
private val amplitude: Dp,
) : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density,
) = Outline.Generic(Path().apply {
val wavyPath = Path().apply {
val halfPeriod = with(density) { period.toPx() } / 2
val amplitude = with(density) { amplitude.toPx() }
moveTo(x = -halfPeriod / 2, y = amplitude)
repeat(ceil(size.width / halfPeriod + 1).toInt()) { i ->
relativeQuadraticBezierTo(
dx1 = halfPeriod / 2,
dy1 = 2 * amplitude * (if (i % 2 == 0) 1 else -1),
dx2 = halfPeriod,
dy2 = 0f,
)
}
lineTo(size.width, size.height)
lineTo(0f, size.height)
}
val boundsPath = Path().apply {
addRect(Rect(offset = Offset.Zero, size = size))
}
op(wavyPath, boundsPath, PathOperation.Intersect)
})
}
If you really need to use this inside Canvas for some reason, you can pass the same Path that I create inside WavyShape to DrawScope.clipPath, so that the contents of the clipPath block will be clipped.
Apply custom shape to your Image or any other view:
Image(
painter = painterResource(id = R.drawable.my_image_1),
contentDescription = null,
contentScale = ContentScale.FillBounds,
modifier = Modifier
.clip(WavyShape(period = 100.dp, amplitude = 50.dp))
)
Result:

Jetpack Compose Draw Arc with Dot Circle

I've looking to draw an arc on a canvas in Jetpack Compose with a little circle on the edge of progress like this picture:
I found how to draw the progress bar with arc canvas but don't know yet how to draw the circle to match with the edge of the arc line. This is my progress code:
#Composable
fun ComposeCircularProgressBar(
modifier: Modifier = Modifier,
percentage: Float,
fillColor: Color,
backgroundColor: Color,
strokeWidth: Dp
) {
Canvas(
modifier = modifier
.padding(strokeWidth / 2)
) {
// Background Line
drawArc(
color = backgroundColor,
135f,
270f,
false,
style = Stroke(strokeWidth.toPx(), cap = StrokeCap.Butt)
)
// Fill Line
drawArc(
color = fillColor,
135f,
270f * percentage,
false,
style = Stroke(strokeWidth.toPx(), cap = StrokeCap.Round)
)
}
}
Noted: for now I know to draw that circle is with Canvas.drawCircle(offset = Offset) but I don't know yet how to calculate the Offset(x,y) to match with the edge of progress.
This piece of code below will generate the arc with the circular dot based on the percentage you provide. You did get most of the part right, it was just about solving the Math Equation to find the point on the circle.
I assumed the radius of the circle as Height / 2 of the widget.
Since we are not drawing the full circle, the start angle is at 140 degrees and the maximum sweep angle is 260 degrees. (I found this by hit and trial, so that it looks as close to your image)
Now to draw the small white circle the center a.k.a offset has to be at (x,y) where x & y are given by the formula
x = radius * sin (angle in radians)
y = radius * cos (angle in radians)
#Composable
fun ComposeCircularProgressBar(
modifier: Modifier = Modifier,
percentage: Float,
fillColor: Color,
backgroundColor: Color,
strokeWidth: Dp
) {
Canvas(
modifier = modifier
.size(150.dp)
.padding(10.dp)
) {
// Background Line
drawArc(
color = backgroundColor,
140f,
260f,
false,
style = Stroke(strokeWidth.toPx(), cap = StrokeCap.Round),
size = Size(size.width, size.height)
)
drawArc(
color = fillColor,
140f,
percentage * 260f,
false,
style = Stroke(strokeWidth.toPx(), cap = StrokeCap.Round),
size = Size(size.width, size.height)
)
var angleInDegrees = (percentage * 260.0) + 50.0
var radius = (size.height / 2)
var x = -(radius * sin(Math.toRadians(angleInDegrees))).toFloat() + (size.width / 2)
var y = (radius * cos(Math.toRadians(angleInDegrees))).toFloat() + (size.height / 2)
drawCircle(
color = Color.White,
radius = 5f,
center = Offset(x, y)
)
}
}
Here are some examples I tried with
#Preview
#Composable
fun PreviewPorgressBar() {
ComposeCircularProgressBar(
percentage = 0.80f,
fillColor = Color(android.graphics.Color.parseColor("#4DB6AC")),
backgroundColor = Color(android.graphics.Color.parseColor("#90A4AE")),
strokeWidth = 10.dp
)
}
#Preview
#Composable
fun PreviewPorgressBar() {
ComposeCircularProgressBar(
percentage = 0.45f,
fillColor = Color(android.graphics.Color.parseColor("#4DB6AC")),
backgroundColor = Color(android.graphics.Color.parseColor("#90A4AE")),
strokeWidth = 10.dp
)
}
#Preview
#Composable
fun PreviewPorgressBar() {
ComposeCircularProgressBar(
percentage = 1f,
fillColor = Color(android.graphics.Color.parseColor("#4DB6AC")),
backgroundColor = Color(android.graphics.Color.parseColor("#90A4AE")),
strokeWidth = 10.dp
)
}
[Update] If you're interested in a step-by-step tutorial you can read it here :
https://blog.droidchef.dev/custom-progress-with-jetpack-compose-tutorial/

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?
You can define your custom 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.
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. Here's a good article on creating custom shapes:
Custom Shape with Jetpack Compose - Article
and the source code:
Custom Shape with Jetpack Compose - Source code
Building this with a shape, arrow, and shadow is quite complex. I created it using custom Modifier, remember, canvas and drawing path. Full implementation is available in this repo.
I can sum the process as
Step1
Create a state for wrapping properties
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.
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.
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.
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. 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.
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
)
}
)

Categories

Resources