I'm trying to implement a rating bar. I refer to https://gist.github.com/vitorprado/0ae4ad60c296aefafba4a157bb165e60 but I don't understand anything from this code. It works but when I use this code the stars don't have rounded corners. I want to implement something like the following :
I made very basic sample for this, it would give the basic idea for creating rating bar with sample border and filled png files.
#Composable
private fun RatingBar(
modifier: Modifier = Modifier,
rating: Float,
spaceBetween: Dp = 0.dp
) {
val image = ImageBitmap.imageResource(id = R.drawable.star)
val imageFull = ImageBitmap.imageResource(id = R.drawable.star_full)
val totalCount = 5
val height = LocalDensity.current.run { image.height.toDp() }
val width = LocalDensity.current.run { image.width.toDp() }
val space = LocalDensity.current.run { spaceBetween.toPx() }
val totalWidth = width * totalCount + spaceBetween * (totalCount - 1)
Box(
modifier
.width(totalWidth)
.height(height)
.drawBehind {
drawRating(rating, image, imageFull, space)
})
}
private fun DrawScope.drawRating(
rating: Float,
image: ImageBitmap,
imageFull: ImageBitmap,
space: Float
) {
val totalCount = 5
val imageWidth = image.width.toFloat()
val imageHeight = size.height
val reminder = rating - rating.toInt()
val ratingInt = (rating - reminder).toInt()
for (i in 0 until totalCount) {
val start = imageWidth * i + space * i
drawImage(
image = image,
topLeft = Offset(start, 0f)
)
}
drawWithLayer {
for (i in 0 until totalCount) {
val start = imageWidth * i + space * i
// Destination
drawImage(
image = imageFull,
topLeft = Offset(start, 0f)
)
}
val end = imageWidth * totalCount + space * (totalCount - 1)
val start = rating * imageWidth + ratingInt * space
val size = end - start
// Source
drawRect(
Color.Transparent,
topLeft = Offset(start, 0f),
size = Size(size, height = imageHeight),
blendMode = BlendMode.SrcIn
)
}
}
private fun DrawScope.drawWithLayer(block: DrawScope.() -> Unit) {
with(drawContext.canvas.nativeCanvas) {
val checkPoint = saveLayer(null, null)
block()
restoreToCount(checkPoint)
}
}
Usage
Column {
RatingBar(rating = 3.7f, spaceBetween = 3.dp)
RatingBar(rating = 2.5f, spaceBetween = 2.dp)
RatingBar(rating = 4.5f, spaceBetween = 2.dp)
RatingBar(rating = 1.3f, spaceBetween = 4.dp)
}
Result
Also created a library that uses gestures, other png files and vectors as rating items is available here.
RatingBar(
rating = rating,
space = 2.dp,
imageBackground = imageBackground,
imageForeground = imageForeground,
animationEnabled = false,
gestureEnabled = true,
itemSize = 60.dp
) {
rating = it
}
You can pass the custom drawable as icon. check this code.
Replace your RatingStar() function as it is using canvas to draw star, instead pass the custom drawable.
#Composable
private fun starShow(){
val icon = if (isSelected)
//your selected drawable
else
//your unselected drawable
Icon(
painter = painterResource(id = icon),
contentDescription = null,
tint = MyColor.starColor)
}
Related
I am using this tutorial
https://danielrampelt.com/blog/jetpack-compose-custom-schedule-layout-part-1/ and part 2
to draw a custom schedule. How can I want to use draggable inside of it so I can change the color of the dragged surface in jetpack compose. My aim is to drag an time slot vertically. This is my composable function. I have put the draggable function but I don't know how to implement it. Now my screen freezes, I can't scroll vertically.
#Composable
fun DynamicSchedule(
viewModel: CalenderViewModel,
modifier: Modifier = Modifier,
appointmentContent: #Composable (appointment: Appointment) -> Unit = {
ScheduleCard(
appointment = it
)
},
minDate: LocalDate,
maxDate: LocalDate,
dayWidth: Dp,
hourHeight: Dp
) {
val numDays = ChronoUnit.DAYS.between(minDate, maxDate).toInt() + 1
val dividerColor = if (MaterialTheme.colors.isLight) Color.LightGray else Color.DarkGray
var offsetY by remember { mutableStateOf(0f) }
Layout(
content = {
viewModel.state.value.appointmentList.sortedBy { it.startDate }
.forEach { appointment ->
Box(modifier = Modifier.appointmentData(appointment)) {
appointmentContent(appointment)
}
}
},
modifier = modifier
.drawBehind {
repeat(23) {
drawLine(
dividerColor,
start = Offset(0f, (it + 1) * hourHeight.toPx()),
end = Offset(size.width, (it + 1) * hourHeight.toPx()),
strokeWidth = 1.dp.toPx()
)
}
repeat(numDays - 1) {
drawLine(
dividerColor,
start = Offset((it + 1) * dayWidth.toPx(), 0f),
end = Offset((it + 1) * dayWidth.toPx(), size.height),
strokeWidth = 1.dp.toPx()
)
}
}
.pointerInput(Unit) {
detectTapGestures {
val x = it.x.toDp()
val y = it.y.toDp()
val time = y.value.toInt() / hourHeight.value
val date = (x.value.toInt() / dayWidth.value)
println("X: $x, Y: $y")
println("Day: $date, Time: $time")
}
}
.draggable(
orientation = Orientation.Vertical,
state = rememberDraggableState { delta ->
offsetY += delta
println("Delta: $offsetY")
}
),
) { measurables, constraints ->
println("i got recomposed ======== ")
val height = hourHeight.roundToPx() * 24
val width = dayWidth.roundToPx() * numDays
val placeablesWithAppointment = measurables.map { measurable ->
val appointment = measurable.parentData as Appointment
val appointmentDurationInMinutes =
ChronoUnit.MINUTES.between(
appointment.startDate.time.toJavaLocalTime(),
appointment.endDate.time.toJavaLocalTime()
)
val appointmentHeight =
((appointmentDurationInMinutes / 60f) * hourHeight.toPx()).roundToInt()
val placeable = measurable.measure(
constraints.copy(
minWidth = dayWidth.roundToPx(),
maxWidth = dayWidth.roundToPx(),
minHeight = appointmentHeight,
maxHeight = appointmentHeight
)
)
Pair(placeable, appointment)
}
layout(width, height) {
placeablesWithAppointment.forEach { (placeable, appointment) ->
//appointment time - midnight
val appointmentOffsetMinutes =
ChronoUnit.MINUTES.between(
LocalTime.MIN,
appointment.startDate.time.toJavaLocalTime()
)
val appointmentY =
((appointmentOffsetMinutes / 60f) * hourHeight.toPx()).roundToInt()
val appointmentOffsetDays =
ChronoUnit.DAYS.between(
minDate,
appointment.startDate.date.toJavaLocalDate()
).toInt()
val appointmentX = appointmentOffsetDays * dayWidth.roundToPx()
placeable.place(appointmentX, appointmentY)
}
}
}
}
I would like to create a watermark effect in my app using text as shown in the picture below.
I achieved this by using canvas and bitmap, is there any other reliable way to do this?
Here is my composable function
#Composable
fun WaterMark(
modifier: Modifier = Modifier,
content: (#Composable BoxScope.() -> Unit)? = null,
) {
val watermarkText: String = "some mutable text"
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
paint.textSize = LocalContext.current.dpToPx(24).toFloat()
paint.color = PSCoreColours.psCoreColours.onSurface.hashCode()
paint.textAlign = Paint.Align.LEFT
paint.alpha = (255 * 0.25).toInt()
val baseline: Float = -paint.ascent()
val image: Bitmap = Bitmap.createBitmap(paint.measureText(watermarkText).toInt(),
(baseline + paint.descent()).toInt(),
Bitmap.Config.ARGB_8888)
val canvas = android.graphics.Canvas(image)
canvas.drawText(watermarkText, 0f, baseline, paint)
val rotationMatrix: Matrix = Matrix().apply { postRotate(-45f) }
val rotatedImage: Bitmap = Bitmap.createBitmap(image, 0, 0, image.width, image.height, rotationMatrix, true)
val pattern: ImageBitmap = rotatedImage.asImageBitmap()
Box {
content?.let { it() }
Canvas(
modifier = modifier
) {
val totalWidth = size.width / pattern.width
val totalHeight = size.height / pattern.height
var x = 0f
var y = 0f
for (i in 0..totalHeight.toInt()) {
y = (i * pattern.height).toFloat()
for (j in 0..totalWidth.toInt()) {
x = (j * pattern.width).toFloat()
drawImage(
pattern,
colorFilter = null,
topLeft = Offset(x, y),
)
}
}
}
}
}
You can do custom layouts in compose for this
private const val SPACING = 100
#Composable
fun Watermark(
content: #Composable BoxScope.() -> Unit,
) {
Box {
content()
Layout(
content = {
// Repeating the placeables, 6 should do for now but we should be able to calculate this too
repeat(6) {
Text(
text = watermarkText,
..
)
}
}
) { measurables, constraints ->
// Measuring all the placables
val placeables: List<Placeable> = measurables
.map { measurable -> measurable.measure(constraints) }
layout(constraints.maxWidth, constraints.maxHeight) {
// Calculating the max width of a placable
val maxWidth: Double = placeables.maxOf { it.width }.toDouble()
// Calculating the max width of a tile given the text is rotated
val tileSize: Int = (constraints.maxWidth / atan(maxWidth)).toInt()
placeables
.chunked(2) // Placing 2 columns
.forEachIndexed { index, (first, second) ->
val indexedTileSize: Int = index * tileSize
first.placeRelativeWithLayer(-SPACING, indexedTileSize + SPACING) { rotationZ = -45f }
second.placeRelativeWithLayer(tileSize, indexedTileSize) { rotationZ = -45f }
}
}
}
}
}
Watermark function creates instance of Paint and Bitmap on each recomposition. You should wrap them with remember as in this answer.
However you might, i think, do what you do fully Compose way without Paint and Bitmap either using Modifier.drawWithContent{} and drawText function of DrawScope and using translate or rotate inside DrawScope.
This is a drawText sample to understand how you can create and store TextLayoutResult remember.
And another sample using Modifier.drawWithContent
You can also try using Modifier.drawWithCache to cache TextLayoutResult in layout phase instead of composition phase which is suggested by Google Compose developer works on Text here
how can I make some parts of canvas transparent? I want user to be able to erase parts of an photo like this link shows to be transparent. my canvas code:
Canvas(
modifier = modifier
.background(Color.Transparent)
) {
with(drawContext.canvas.nativeCanvas) {
val checkPoint = saveLayer(null, null)
drawImage(
image = bitmap,
srcSize = IntSize(bitmap.width, bitmap.height),
dstSize = IntSize(canvasWidth, canvasHeight)
)
drawPath(
path = erasePath,
style = Stroke(
width = 30f,
cap = StrokeCap.Round,
join = StrokeJoin.Round
),
blendMode = BlendMode.Clear,
color = Color.Transparent,
)
restoreToCount(checkPoint)
}
}
What you get as Transparent is Color(0x00000000), white you get is the color of your background, even if you Canvas has transparent background, color of your root or parent Composable is white.
You need to draw checker layout or checker image first, inside Layer you should draw your image and path with BlendMode.Clear
val width = this.size.width
val height = this.size.height
val checkerWidth = 10.dp.toPx()
val checkerHeight = 10.dp.toPx()
val horizontalSteps = (width / checkerWidth).toInt()
val verticalSteps = (height / checkerHeight).toInt()
for (y in 0..verticalSteps) {
for (x in 0..horizontalSteps) {
val isGrayTile = ((x + y) % 2 == 1)
drawRect(
color = if (isGrayTile) Color.LightGray else Color.White,
topLeft = Offset(x * checkerWidth, y * checkerHeight),
size = Size(checkerWidth, checkerHeight)
)
}
}
val space = 20.dp.roundToPx()
with(drawContext.canvas.nativeCanvas) {
val checkPoint = saveLayer(null, null)
// Destination
drawImage(
image = dstBitmap,
dstOffset = IntOffset(
space / 2,
space / 2
),
dstSize = IntSize(
canvasWidth - space, canvasHeight - space
)
)
// Source
drawPath(
color = Color.Transparent,
path = erasePath,
style = Stroke(
width = 30f,
cap = StrokeCap.Round,
join = StrokeJoin.Round
),
blendMode = BlendMode.Clear
)
restoreToCount(checkPoint)
}
Full implementation
#Composable
private fun MyImageDrawer(modifier: Modifier) {
// This is the image to draw onto
val dstBitmap = ImageBitmap.imageResource(id = R.drawable.landscape1)
// Path used for erasing. In this example erasing is faked by drawing with canvas color
// above draw path.
val erasePath = remember { Path() }
var motionEvent by remember { mutableStateOf(MotionEvent.Idle) }
// This is our motion event we get from touch motion
var currentPosition by remember { mutableStateOf(Offset.Unspecified) }
// This is previous motion event before next touch is saved into this current position
var previousPosition by remember { mutableStateOf(Offset.Unspecified) }
val drawModifier = modifier
.pointerMotionEvents(Unit,
onDown = { pointerInputChange ->
motionEvent = MotionEvent.Down
currentPosition = pointerInputChange.position
pointerInputChange.consume()
},
onMove = { pointerInputChange ->
motionEvent = MotionEvent.Move
currentPosition = pointerInputChange.position
pointerInputChange.consume()
},
onUp = { pointerInputChange ->
motionEvent = MotionEvent.Up
pointerInputChange.consume()
}
)
Canvas(modifier = drawModifier) {
val canvasWidth = size.width.roundToInt()
val canvasHeight = size.height.roundToInt()
// Draw or erase depending on erase mode is active or not
when (motionEvent) {
MotionEvent.Down -> {
erasePath.moveTo(currentPosition.x, currentPosition.y)
previousPosition = currentPosition
}
MotionEvent.Move -> {
erasePath.quadraticBezierTo(
previousPosition.x,
previousPosition.y,
(previousPosition.x + currentPosition.x) / 2,
(previousPosition.y + currentPosition.y) / 2
)
previousPosition = currentPosition
}
MotionEvent.Up -> {
erasePath.lineTo(currentPosition.x, currentPosition.y)
currentPosition = Offset.Unspecified
previousPosition = currentPosition
motionEvent = MotionEvent.Idle
}
else -> Unit
}
val width = this.size.width
val height = this.size.height
val checkerWidth = 10.dp.toPx()
val checkerHeight = 10.dp.toPx()
val horizontalSteps = (width / checkerWidth).toInt()
val verticalSteps = (height / checkerHeight).toInt()
for (y in 0..verticalSteps) {
for (x in 0..horizontalSteps) {
val isGrayTile = ((x + y) % 2 == 1)
drawRect(
color = if (isGrayTile) Color.LightGray else Color.White,
topLeft = Offset(x * checkerWidth, y * checkerHeight),
size = Size(checkerWidth, checkerHeight)
)
}
}
val space = 20.dp.roundToPx()
with(drawContext.canvas.nativeCanvas) {
val checkPoint = saveLayer(null, null)
// Destination
drawImage(
image = dstBitmap,
dstOffset = IntOffset(
space / 2,
space / 2
),
dstSize = IntSize(
canvasWidth - space, canvasHeight - space
)
)
// Source
drawPath(
color = Color.Transparent,
path = erasePath,
style = Stroke(
width = 30f,
cap = StrokeCap.Round,
join = StrokeJoin.Round
),
blendMode = BlendMode.Clear
)
restoreToCount(checkPoint)
}
}
}
Outcome
I had a BadgeView written with View using onMeasure, onLayout and OnDraw
I'm trying to migrate this View to Jetpack Compose.
Since drawing shapes is easier with compose i thought there is no need to use canvas or Layout functions at all, but size of Text or Surface wrapping it is not set properly before text size is calculated, and circle is not drawn properly.
Also checked out Badge component, it uses static sizes BadgeWithContentRadius, since in my design size depends on text size it's not possible to set a static size.
Surface(
shape = CircleShape,
contentColor = Color.White,
color = Color.Red
) {
Text(
text = "0",
modifier = Modifier.padding(4.dp),
fontSize = 34.sp,
)
}
Then tried using
var size: Dp by remember { mutableStateOf(40.dp) }
val density = LocalDensity.current
Surface(
shape = CircleShape,
modifier = Modifier.size(size),
contentColor = Color.Yellow,
color = Color.Red
){
Text(
text = "0",
modifier = Modifier.padding(4.dp),
fontSize = 24.sp,
onTextLayout = { textLayoutResult: TextLayoutResult ->
val textSize = textLayoutResult.size
val circleRadius = textSize.width.coerceAtLeast(textSize.height)
size = with(density) {
circleRadius.toDp()
}
println("Size: $size")
}
)
}
Both of the implementations are not working, then tried doing it with Layout
#Composable
private fun Badge(text: String, badgeState: BadgeState, modifier: Modifier = Modifier) {
Surface(shape = CircleShape, color = Color.Red, contentColor = Color.White) {
BadgeLayout(text = text, badgeState = badgeState, modifier = modifier)
}
}
#Composable
private fun BadgeLayout(text: String, badgeState: BadgeState, modifier: Modifier = Modifier) {
var circleRadius = 0
var size: IntSize by remember {
mutableStateOf(IntSize(0, 0))
}
val content = #Composable {
Text(
text = text,
modifier = Modifier.padding(4.dp),
fontSize = 34.sp,
onTextLayout = { textLayoutResult: TextLayoutResult ->
size = textLayoutResult.size
circleRadius = size.width.coerceAtLeast(size.height)
},
)
}
Layout(
modifier = modifier,
content = content
) { measurables: List<Measurable>, constraints: Constraints ->
val placeables = measurables.map { measurable ->
measurable.measure(constraints)
}
println("🔥 Badge: $circleRadius, size: $size")
layout(width = circleRadius, height = circleRadius) {
placeables.first().placeRelative(0, 0)
}
}
}
Shape seems to be applied correctly but couldn't find exact way to get text size to set number to center of Surface or Text.
How can a component, should have circle shape when it's one or digit number then turning it into RoundedCornerShape can be implemented with considering performance be implemented?
I've made the following modifier using Modifier.layout:
fun Modifier.badgeLayout() =
layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
// based on the expectation of only one line of text
val minPadding = placeable.height / 4
val width = maxOf(placeable.width + minPadding, placeable.height)
layout(width, placeable.height) {
placeable.place((width - placeable.width) / 2, 0)
}
}
Usage:
Text(
text,
modifier = Modifier
.background(MaterialTheme.colors.error, shape = CircleShape)
.badgeLayout()
)
Result:
I would look into using the Material Badge that is already available for Compose:
Material Badge for Compose
Solution is to use textHeight with onTextLayout callback of Text. Since placeable.height returns full text height with font padding
onTextLayout = { textLayoutResult: TextLayoutResult ->
textSize = textLayoutResult.size
// 🔥🔥 This is text height without padding, result size returns height with font padding
textHeight = textLayoutResult.firstBaseline.toInt()
}
Layout implementation is as
#Composable
fun Badge(
modifier: Modifier = Modifier,
badgeState: BadgeState = rememberBadgeState(),
) {
BadgeComponent(badgeState = badgeState, modifier = modifier)
}
#Composable
private fun BadgeComponent(badgeState: BadgeState, modifier: Modifier = Modifier) {
// TODO Question: Why does this not survive recompositions without mutableState?
var textSize = remember { IntSize(0, 0) }
var textHeight = remember(badgeState) { 0 }
var badgeHeight = remember { 0 }
val density = LocalDensity.current
val text = badgeState.text
val isCircleShape = badgeState.isCircleShape
val shape =
if (isCircleShape) CircleShape else RoundedCornerShape(badgeState.roundedRadiusPercent)
println(
"✅ BadgeComponent: text: $text, " +
"isCircleShape: $isCircleShape, " +
"textHeight: $textHeight, " +
"badgeHeight: $badgeHeight, " +
"textSize: $textSize"
)
val content = #Composable {
Text(
text = badgeState.text,
color = badgeState.textColor,
fontSize = badgeState.fontSize,
lineHeight = badgeState.fontSize,
onTextLayout = { textLayoutResult: TextLayoutResult ->
textSize = textLayoutResult.size
// 🔥🔥 This is text height without padding, result size returns height with font padding
textHeight = textLayoutResult.firstBaseline.toInt()
println("✏️ BadgeComponent textHeight: $textHeight, textSize: $textSize")
},
)
}
val badgeModifier = modifier
.materialShadow(badgeState = badgeState)
.then(
badgeState.borderStroke?.let { borderStroke ->
modifier.border(borderStroke, shape = shape)
} ?: modifier
)
.background(
badgeState.backgroundColor,
shape = shape
)
Layout(
modifier = badgeModifier,
content = content
) { measurables: List<Measurable>, constraints: Constraints ->
val placeables = measurables.map { measurable ->
measurable.measure(constraints)
}
val placeable = placeables.first()
if (badgeHeight == 0) {
// Space above and below text, this is drawing area + empty space
val verticalSpaceAroundText = with(density) {
textHeight * .12f + 6 + badgeState.verticalPadding.toPx()
}
badgeHeight = (textHeight + 2 * verticalSpaceAroundText).toInt()
if (isCircleShape) {
// Use bigger dimension to have circle that covers 2 digit counts either
badgeHeight = textSize.width.coerceAtLeast(badgeHeight)
layout(width = badgeHeight, height = badgeHeight) {
placeable.placeRelative(
(badgeHeight - textSize.width) / 2,
(badgeHeight - textSize.height) / 2
)
}
} else {
// Space left and right of the text, this is drawing area + empty space
val horizontalSpaceAroundText = with(density) {
textHeight * .12f + 6 + badgeState.horizontalPadding.toPx()
}
val width = (textSize.width + 2 * horizontalSpaceAroundText).toInt()
layout(width = width, height = badgeHeight) {
placeable.placeRelative(
x = (width - textSize.width) / 2,
y = (-textSize.height + badgeHeight) / 2
)
}
}
}
}
Also used custom Modifier and rememberable to set colored shadow, paddings, font properties, shapes and more which full implementation can be found in github repository.
Final result
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: