I wanted to implement a drawing app written in Jetpack compose, I wanted to achieve goal like below in kotlin canvas:
I have tried to build a simple app base on Compose Multiplatform Canvas, but the functionability is pretty bad, My experimental code is below. What I want to know are:
(1) How can I detect mouse entering the drown shapes?
(2) Is there any canvas API can draw rotated line on canvas?
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.forEachGesture
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.*
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.onPointerEvent
import androidx.compose.ui.input.pointer.pointerInput
import kotlin.math.cos
import kotlin.math.sin
data class PathProperties(val Angle: Float, val length: Float, val startPoint: Pair<Float, Float>, val endPoint: Pair<Float, Float>)
#OptIn(ExperimentalComposeUiApi::class)
#Composable
fun customCanvas(){
var currentPosition by remember { mutableStateOf(Offset.Unspecified) }
var previousPosition by remember { mutableStateOf(Offset.Unspecified) }
val randomAngle = listOf(45f, -45f)
val paths = remember { mutableStateListOf<Pair<Path, PathProperties>>() }
var currentPath by remember { mutableStateOf(Path()) }
val lineLength = 30f
var cPaths = remember { mutableStateListOf<Rect>() }
var dotList = remember { mutableStateListOf<Color>() }
Canvas(
modifier = Modifier
.fillMaxSize()
.background(color = Color.Gray)
.pointerInput(Unit) {
forEachGesture {
awaitPointerEventScope {
awaitFirstDown().also {
currentPosition = it.position
previousPosition = currentPosition
currentPath.moveTo(currentPosition.x, currentPosition.y)
val angle = randomAngle.random()
val startPoint = Pair(currentPosition.x, currentPosition.y)
val endPoint = getPointByAngle(lineLength, angle, startPoint)
currentPath.lineTo(endPoint.first, endPoint.second)
paths.add(Pair(currentPath, PathProperties(angle, 30f, startPoint, endPoint)))
cPaths.add(Rect(
left = currentPosition.x - 4,
right = currentPosition.x + 4,
top = currentPosition.y - 4,
bottom = currentPosition.y + 4,
))
dotList.add(Color.Cyan)
}
}
}
}
.onPointerEvent(PointerEventType.Move) {
val position = it.changes.first().position
for ((idx, rect) in cPaths.withIndex()) {
if (rect.contains(position)) {
dotList[idx] = Color.Black
break
} else {
dotList[idx] = Color.Cyan
}
}
}
){
with(drawContext.canvas.nativeCanvas) {
val checkPoint = saveLayer(null, null)
paths.forEachIndexed() { idx,it: Pair<Path, PathProperties> ->
drawPath(
color = Color.Black,
path = it.first,
style = Stroke(
width = 3f,
cap = StrokeCap.Round,
join = StrokeJoin.Round,
)
)
drawCircle(
color = dotList[idx],
radius = 8f,
center = Offset(it.second.startPoint.first, it.second.startPoint.second),
)
drawCircle(
color = dotList[idx],
radius = 8f,
center = Offset(it.second.endPoint.first, it.second.endPoint.second),
)
}
}
}
}
//calculate the end point x and y coordinate by cos() and sin()
fun getPointByAngle(length: Float, angle: Float, startPoint: Pair<Float, Float>): Pair<Float, Float> {
return Pair(startPoint.first + length * cos(angle), startPoint.second + length * sin(angle))
}
Related
I want to know how can I implement this animation?
Small image frame and text movement is independent from the animation. It's just like simple pager action with some scale and alpha transformation. Only problem is the color change of background like this.
I'm open to both XML and Jetpack Compose way solutions. Please..
The solution
After lots of hours searching, I've found the perfect one;
https://github.com/2307vivek/BubblePager
My Solution
import android.graphics.Path
import android.view.MotionEvent
import androidx.annotation.FloatRange
import androidx.compose.animation.core.*
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.*
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.asComposePath
import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import kotlin.math.hypot
#Composable
fun <T> CircularReveal(
targetState: T,
modifier: Modifier = Modifier,
animationSpec: FiniteAnimationSpec<Float> = tween(),
content: #Composable (T) -> Unit,
) {
val transition = updateTransition(targetState, label = "Circular reveal")
transition.CircularReveal(modifier, animationSpec, content = content)
}
#Composable
fun <T> Transition<T>.CircularReveal(
modifier: Modifier = Modifier,
animationSpec: FiniteAnimationSpec<Float> = tween(),
content: #Composable (targetState: T) -> Unit,
) {
var offset: Offset? by remember { mutableStateOf(null) }
val currentlyVisible = remember { mutableStateListOf<T>().apply { add(currentState) } }
val contentMap = remember {
mutableMapOf<T, #Composable () -> Unit>()
}
if (currentState == targetState) {
// If not animating, just display the current state
if (currentlyVisible.size != 1 || currentlyVisible[0] != targetState) {
// Remove all the intermediate items from the list once the animation is finished.
currentlyVisible.removeAll { it != targetState }
contentMap.clear()
}
}
if (!contentMap.contains(targetState)) {
// Replace target with the same key if any
val replacementId = currentlyVisible.indexOfFirst {
it == targetState
}
if (replacementId == -1) {
currentlyVisible.add(targetState)
} else {
currentlyVisible[replacementId] = targetState
}
contentMap.clear()
currentlyVisible.forEach { stateForContent ->
contentMap[stateForContent] = {
val progress by animateFloat(
label = "Progress",
transitionSpec = { animationSpec }
) {
val targetedContent = stateForContent != currentlyVisible.last() || it == stateForContent
if (targetedContent) 1f else 0f
}
Box(Modifier.circularReveal(progress = progress, offset = offset)) {
content(stateForContent)
}
}
}
}
Box(
modifier = modifier.pointerInteropFilter {
if (it.action == MotionEvent.ACTION_DOWN) {
if (!started) offset = Offset(it.x, it.y)
}
started
}
) {
currentlyVisible.forEach {
key(it) {
contentMap[it]?.invoke()
}
}
}
}
private val <T> Transition<T>.started get() =
currentState != targetState || isRunning
fun Modifier.circularReveal(
#FloatRange(from = 0.0, to = 1.0) progress: Float,
offset: Offset? = null,
) = clip(CircularRevealShape(progress, offset))
class CircularRevealShape(
#FloatRange(from = 0.0, to = 1.0) private val progress: Float,
private val offset: Offset? = null,
) : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density,
): Outline {
return Outline.Generic(Path().apply {
addCircle(
offset?.x ?: (size.width / 2f),
offset?.y ?: (size.height / 2f),
longestDistanceToACorner(size, offset) * progress,
Path.Direction.CW
)
}.asComposePath())
}
private fun longestDistanceToACorner(size: Size, offset: Offset?): Float {
if (offset == null) {
return hypot(size.width / 2f, size.height / 2f)
}
val topLeft = hypot(offset.x, offset.y)
val topRight = hypot(size.width - offset.x, offset.y)
val bottomLeft = hypot(offset.x, size.height - offset.y)
val bottomRight = hypot(size.width - offset.x, size.height - offset.y)
return topLeft.coerceAtLeast(topRight).coerceAtLeast(bottomLeft).coerceAtLeast(bottomRight)
}
}
#Preview
#Composable
fun CircularRevealAnimationPreview() {
val isSystemDark = isSystemInDarkTheme()
var darkTheme by remember { mutableStateOf(isSystemDark) }
val onThemeToggle = { darkTheme = !darkTheme }
CircularReveal(
targetState = darkTheme,
animationSpec = tween(1500)
) { isDark ->
MyAppTheme(darkTheme = isDark) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background,
onClick = onThemeToggle
) {
Box(
contentAlignment = Alignment.Center
) {
Icon(
modifier = Modifier.size(120.dp),
imageVector = if (isDark) Icons.Default.DarkMode else Icons.Default.LightMode,
contentDescription = "Toggle",
)
}
}
}
}
}
I have one Image composable, then I retrieve its boundary box using onGloballyPositioned listener. When I press a button a new Image is displayed, that have the same resId and initial position and size, so it matches the size of the original Image. And the original image gets hidden, while the copy image changes it locating using absoluteOffset and its size using the width and height attributes. I am using LaunchedEffect, to generate float values from 0f to 1f, and then use them to change the position and size of the copied image.
Here is the result:
Everything works well, except the fact that there is some flickering, since we hide the original and show the copy image immediately, and probably there is a empty frame, when both images are recomposed at the same time. So the original image is hidden, but the copied image is still not show, so there is a frame where both images are invisible.
Is there a way I can set the the order in which the images are recomposed, so the copied image get its visible state, before the original image is hidden?
I saw that there is way to use key inside columns/rows from here. But I am not so sure it is related.
The other idea I got is to use opacity animation, so there can be a delay, something like
time | Original Image (opacity) | Copy Image (opacity)
-------|---------------------------|-----------------------
0s | 1 | 0
0.2s | 0.75 | 0.25
0.4s | 0.5 | 0.5
0.6s | 0.25 | 0.75
0.8s | 0.0 | 1
Also I know I can use single image to achieve the same effect, but I want to have separate image, that is not part of the compose navigation. So if I transition to another destination, I want the image to be transferred to that destination with smooth animation.
Here is the source code:
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.animation.core.TargetBasedAnimation
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.material.Button
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import com.slaviboy.myapplication.ui.theme.MyApplicationTheme
class MainActivity : ComponentActivity() {
val viewModel by viewModels<ViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val left = with(LocalDensity.current) { 200.dp.toPx() }
val top = with(LocalDensity.current) { 300.dp.toPx() }
val width = with(LocalDensity.current) { 100.dp.toPx() }
viewModel.setSharedImageToCoord(Rect(left, top, left + width, top + width))
Box(modifier = Modifier.fillMaxSize()) {
if (!viewModel.isSharedImageVisible.value) {
Image(painter = painterResource(id = viewModel.setSharedImageResId.value),
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.width(130.dp)
.height(130.dp)
.onGloballyPositioned { coordinates ->
coordinates.parentCoordinates
?.localBoundingBoxOf(coordinates, false)
?.let {
viewModel.setSharedImageFromCoord(it)
}
})
}
SharedImage(viewModel)
}
Button(onClick = {
viewModel.setIsSharedImageVisible(true)
viewModel.triggerAnimation()
}) {
}
}
}
}
#Composable
fun SharedImage(viewModel: ViewModel) {
var left by remember { mutableStateOf(0f) }
var top by remember { mutableStateOf(0f) }
var width by remember { mutableStateOf(330f) }
val anim = remember {
TargetBasedAnimation(
animationSpec = tween(1700, 0),
typeConverter = Float.VectorConverter,
initialValue = 0f,
targetValue = 1f
)
}
var playTime by remember { mutableStateOf(0L) }
LaunchedEffect(viewModel.triggerAnimation.value) {
val from = viewModel.sharedImageFromCoord.value
val to = viewModel.sharedImageToCoord.value
val fromLeft = from.left
val fromTop = from.top
val fromSize = from.width
val toLeft = to.left
val toTop = to.top
val toSize = to.width
val startTime = withFrameNanos { it }
do {
playTime = withFrameNanos { it } - startTime
val animationValue = anim.getValueFromNanos(playTime)
left = fromLeft + animationValue * (toLeft - fromLeft)
top = fromTop + animationValue * (toTop - fromTop)
width = fromSize + animationValue * (toSize - fromSize)
} while (playTime < anim.durationNanos)
}
if (viewModel.isSharedImageVisible.value) {
Image(
painterResource(id = viewModel.setSharedImageResId.value),
contentDescription = null,
modifier = Modifier
.absoluteOffset {
IntOffset(left.toInt(), top.toInt())
}
.width(
with(LocalDensity.current) { width.toDp() }
)
.height(
with(LocalDensity.current) { width.toDp() }
)
)
}
}
class ViewModel : androidx.lifecycle.ViewModel() {
private val _isSharedImageVisible = mutableStateOf(false)
val isSharedImageVisible: State<Boolean> = _isSharedImageVisible
fun setIsSharedImageVisible(isSharedImageVisible: Boolean) {
_isSharedImageVisible.value = isSharedImageVisible
}
private val _sharedImageFromCoord = mutableStateOf(Rect.Zero)
val sharedImageFromCoord: State<Rect> = _sharedImageFromCoord
fun setSharedImageFromCoord(sharedImageFromCoord: Rect) {
_sharedImageFromCoord.value = sharedImageFromCoord
}
private val _sharedImageToCoord = mutableStateOf(Rect.Zero)
val sharedImageToCoord: State<Rect> = _sharedImageToCoord
fun setSharedImageToCoord(sharedImageToCoord: Rect) {
_sharedImageToCoord.value = sharedImageToCoord
}
private val _setSharedImageResId = mutableStateOf(R.drawable.ic_launcher_background)
val setSharedImageResId: State<Int> = _setSharedImageResId
fun setSharedImageResId(setSharedImageResId: Int) {
_setSharedImageResId.value = setSharedImageResId
}
private val _triggerAnimation = mutableStateOf(false)
val triggerAnimation: State<Boolean> = _triggerAnimation
fun triggerAnimation() {
_triggerAnimation.value = !_triggerAnimation.value
}
}
Well I manage to fix that problem by applying 200ms delay to the transition animation and also apply the same delay to the navigation transition animation!
During those 200ms, I start another animation that changes the opacity of the shared (copied) image from [0,1]. So basically I show the shared image, during those 200ms, and it get drawn on top of the item (original) image. Then on the last frame I hide the item (original) image, and only the transition image it displayed.
Then after those 200ms delay, I start the transition of the shared(copied) image to its new location. Here is simple graph to demonstrate the animations, during the 200ms delay and the 700ms duration.
#Composable
fun SharedImage(viewModel: ViewModel) {
// opacity animation for the shared image
// if triggered from Home -> change the opacity of the shared image [0,1]
// if triggered from Detail -> change the opacity of the shared image [1,0]
LaunchedEffect(viewModel.changeSharedImagePositionFrom.value) {
val duration: Int
val delay: Int
val opacityFrom: Float
val opacityTo: Float
if (viewModel.changeSharedImagePositionFrom.value is Screen.Home) {
duration = 200
delay = 0
opacityFrom = 0f
opacityTo = 1f
} else {
duration = 200
delay = 700 + 200
opacityFrom = 1f
opacityTo = 0f
}
val animation = TargetBasedAnimation(
animationSpec = tween(duration, delay),
typeConverter = Float.VectorConverter,
initialValue = opacityFrom,
targetValue = opacityTo
)
var playTime = 0L
val startTime = withFrameNanos { it }
do {
playTime = withFrameNanos { it } - startTime
val animationValue = animation.getValueFromNanos(playTime)
viewModel.setSharedImageOpacity(animationValue)
} while (playTime <= animation.durationNanos)
// on last frame set item opacity to 0
if (viewModel.changeSharedImagePositionFrom.value is Screen.Home) {
viewModel.setItemImageOpacity(0f)
}
}
var left by remember { mutableStateOf(0f) }
var top by remember { mutableStateOf(0f) }
var width by remember { mutableStateOf(0f) }
// transition animation for the shared image
// it changes the position and size of the shared image
LaunchedEffect(viewModel.changeSharedImagePositionFrom.value) {
val animation = TargetBasedAnimation(
animationSpec = tween(700, 200),
typeConverter = Float.VectorConverter,
initialValue = 0f,
targetValue = 1f
)
val from = if (viewModel.changeSharedImagePositionFrom.value is Screen.Home) {
viewModel.sharedImageFromCoord.value
} else viewModel.sharedImageToCoord.value
val to = if (viewModel.changeSharedImagePositionFrom.value is Screen.Home) {
viewModel.sharedImageToCoord.value
} else viewModel.sharedImageFromCoord.value
// offset and size for changing the shared image position and size
val fromLeft = from.left
val fromTop = from.top
val fromSize = from.width
val toLeft = to.left
val toTop = to.top
val toSize = to.width
var playTime = 0L
val startTime = withFrameNanos { it }
do {
playTime = withFrameNanos { it } - startTime
val animationValue = animation.getValueFromNanos(playTime)
left = fromLeft + animationValue * (toLeft - fromLeft)
top = fromTop + animationValue * (toTop - fromTop)
width = fromSize + animationValue * (toSize - fromSize)
} while (playTime <= animation.durationNanos)
// on last frame set item opacity to 1
if (viewModel.changeSharedImagePositionFrom.value is Screen.Detail) {
viewModel.setItemImageOpacity(1f)
viewModel.setEnableItemsScroll(true)
}
}
Image(
painterResource(id = viewModel.setSharedImageResId.value),
contentDescription = null,
modifier = Modifier
.absoluteOffset { IntOffset(left.toInt(), top.toInt()) }
.width(with(LocalDensity.current) { width.toDp() })
.height(with(LocalDensity.current) { width.toDp() }),
alpha = viewModel.sharedImageOpacity.value
)
}
Here is the result
I am trying to animate the float value using animateFloatAsState but while I am updating the target value the animated value gives me random different value .
For eg:
This is the animatedValue :
val animatedOffsetValue by animateFloatAsState(
targetValue = titleBarOffsetHeightPx.value
)
This is target value which is being updated over time :
val titleBarOffsetHeightPx = remember { mutableStateOf(0f) }
So when I updated the value as titleBarOffsetHeightPx.value = "new value"
And I check the value of animatedOffsetValue , it gives different than titleBarOffsetHeightPx.
full code :
package com.example.productivitytrackergoalscheduler.features.history.presentation
import android.util.Log
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.example.productivitytrackergoalscheduler.business.util.DateConverter
import com.example.productivitytrackergoalscheduler.business.util.Extensions.isScrolledToTheEnd
import com.example.productivitytrackergoalscheduler.features.core.presentation.navigation.Screen
import com.example.productivitytrackergoalscheduler.features.core.util.SnackbarController
import com.example.productivitytrackergoalscheduler.features.history.presentation.components.DayGoalList
import com.example.productivitytrackergoalscheduler.features.history.presentation.components.HistoryTitleBar
import com.example.productivitytrackergoalscheduler.features.home.presentation.components.CustomNavBar
import com.example.productivitytrackergoalscheduler.features.home.presentation.components.ITEM_WIDTH
import com.example.productivitytrackergoalscheduler.features.home.presentation.util.NavItems
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
const val NAV_BAR_HEIGHT = ITEM_WIDTH + 110
#Composable
fun HistoryScreen(
...
) {
/** Height of title bar in dp */
val titleBarHeight = 66.dp
/** Height of title bar converted into float for offset */
val titleBarHeightPx = with(LocalDensity.current) { titleBarHeight.roundToPx().toFloat() }
/** Offset value of title bar. How much to move up or down */
val titleBarOffsetHeightPx = remember { mutableStateOf(0f) }
/** Is the scrolling is up or down */
val isUpScrolled = remember { mutableStateOf(false) }
val animatedOffsetValue by animateFloatAsState(
targetValue = titleBarOffsetHeightPx.value
)
val nestedScrollConnection = remember {
object: NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
isUpScrolled.value = available.y < 0
val delta = available.y
val newOffset = titleBarOffsetHeightPx.value + delta
titleBarOffsetHeightPx.value = newOffset.coerceIn(-titleBarHeightPx, 0f)
Log.d("TAG", "onPostFling: animated ${animatedOffsetValue}")
Log.d("TAG", "onPostFling: no animated ${titleBarOffsetHeightPx.value}")
return Offset.Zero
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
val scrolledTo = if(isUpScrolled.value) titleBarHeightPx/3 else titleBarHeightPx/2
if(-titleBarOffsetHeightPx.value < scrolledTo)
{
titleBarOffsetHeightPx.value = 0f
}else{
titleBarOffsetHeightPx.value = -titleBarHeightPx
}
return super.onPostFling(consumed, available)
}
}
}
val endOfScrollList by remember {
derivedStateOf {
scrollState.isScrolledToTheEnd()
}
}
Scaffold(
modifier = Modifier.nestedScroll(nestedScrollConnection),
scaffoldState = scaffoldState,
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(color = MaterialTheme.colors.background)
) {
Box(
modifier = Modifier.fillMaxHeight()
) {
Box(
modifier = Modifier
/** this offset id for while title bar hides or shows
* The lazyColumn content also should move towards the
* up and down as to fill empty space */
.offset { IntOffset(x = 0, y = animatedOffsetValue.roundToInt()) }
.padding(horizontal = 8.dp)
) {
LazyColumn(
state = scrollState,
/** This modifier offset gives the space for title bar */
modifier = Modifier.offset{ IntOffset(x = 0, y = titleBarHeightPx.toInt())}
){
...
}
}
}
/** Title Bar with z-index top */
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.offset { IntOffset(x = 0, y = titleBarOffsetHeightPx.value.roundToInt()) }
) {
HistoryTitleBar(navController = navController)
}
/** end title bar*/
...
}
}
}
I have created a custom layout to create a hexagonal grid, however, when I place the grid with other elements, for example inside a Row or Column, the grid won't update its position and stays on the top left corner of the map.
Here is my code:
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.GenericShape
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
import com.example.mygame.ui.theme.MyGameTheme
private val HexagonalShape = GenericShape { size, _ ->
moveTo(size.width / 2f, 0f)
lineTo(size.width, size.height / 4f)
lineTo(size.width, 3 * size.height / 4f)
lineTo(size.width / 2f, size.height)
lineTo(0f, 3 * size.height / 4f)
lineTo(0f, size.height / 4f)
}
#Composable
fun StandardTile(color: Color) {
Box(
modifier = Modifier
.padding(horizontal = 1.dp, vertical = 1.dp)
.wrapContentSize(Alignment.Center)
.size(50.dp)
.clip(HexagonalShape)
.background(color)
.clickable { /* TODO */ }
.border(
width = 1.dp,
brush = SolidColor(Color.Black),
shape = HexagonalShape
)
) { }
}
#Composable
fun Grid(
modifier: Modifier,
lineLength: Int,
content: #Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
val placeables = measurables.map { measurable ->
measurable.measure(constraints)
}
layout(constraints.maxWidth, constraints.maxHeight) {
var yPosition = 0
var xPosition = 0
val index = mutableListOf(0, 0)
placeables.forEach { placeable ->
placeable.placeRelative(x = xPosition, y = yPosition)
if (index[0] != lineLength - 1) {
index[0]++
xPosition += placeable.width
} else {
xPosition = if (index[1] % 2 == 0) placeable.width / 2 else 0
yPosition += 3 * placeable.height / 4
index[0] = 0
index[1]++
}
}
}
}
}
#Preview
#Composable
fun ShapePreview() {
MyGameTheme {
ConstraintLayout {
val (gridRef, leftMenu) = createRefs()
val margin = 0.10f
val leftConstraint = createGuidelineFromStart(fraction = margin)
val rightConstraint = createGuidelineFromEnd(fraction = margin)
Column(
modifier = Modifier
.constrainAs(gridRef) {
linkTo(start = rightConstraint, end = leftConstraint)
start.linkTo(rightConstraint)
}
.fillMaxWidth()) {
Grid(
modifier = Modifier,
lineLength = 8
) {
for (i in 1..64) {
StandardTile(Color.Blue)
}
}
}
val leftMenuScrollState = rememberScrollState()
Column(
modifier = Modifier
.constrainAs(leftMenu) {
linkTo(start = parent.start, end = leftConstraint)
start.linkTo(parent.start)
}
.background(Color.Black)
.fillMaxWidth(fraction = margin)
.fillMaxHeight()
.verticalScroll(leftMenuScrollState)
) {
Text("Help Please, (this is inside the left menu btw)")
}
}
}
}
As you can see that I am using a ConstraintLayout. The relative position of the grid won't change and I don't know why, and I am having the same issue when I am using a row or column.
So here is my question, how do I implement it so that the grid goes to its relative attributed place and not at the top left corner and what's the reason for the same?
There is a post here that might answer my question but I'm not sure how to implement it in my case.
Use width = Dimension.fillToConstraints in addition to specifying start and end constraint on grid.
Also, in your code leftContraint (constraint from parent's start) was used for end and rightConstraint (constraint from parent's end) for start. Just swapped those two.
Column(
modifier = Modifier
.constrainAs(gridRef) {
// corrected start and end constraints
linkTo(start = leftConstraint, end = rightConstraint)
width = Dimension.fillToConstraints
}
) {
Grid(
modifier = Modifier,
lineLength = 8
) {
for (i in 1..64) {
StandardTile(Color.Blue)
}
}
}
I want to draw a simple XY chart using my data parsed from JSON, but every answer here is redirecting to using some sort of library. I want to draw it without any library usage, is there is a possible way to do this in Kotlin ?
PS No, it's NOT a homework or smth.
There is one simple way to integrate a graph by writing custom view
( original:https://github.com/SupahSoftware/AndroidExampleGraphView )
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.util.AttributeSet
import android.view.View
class GraphView(context: Context, attributeSet: AttributeSet) : View(context, attributeSet) {
private val dataSet = mutableListOf<DataPoint>()
private var xMin = 0
private var xMax = 0
private var yMin = 0
private var yMax = 0
private val dataPointPaint = Paint().apply {
color = Color.BLUE
strokeWidth = 7f
style = Paint.Style.STROKE
}
private val dataPointFillPaint = Paint().apply {
color = Color.WHITE
}
private val dataPointLinePaint = Paint().apply {
color = Color.BLUE
strokeWidth = 7f
isAntiAlias = true
}
private val axisLinePaint = Paint().apply {
color = Color.RED
strokeWidth = 10f
}
fun setData(newDataSet: List<DataPoint>) {
xMin = newDataSet.minBy { it.xVal }?.xVal ?: 0
xMax = newDataSet.maxBy { it.xVal }?.xVal ?: 0
yMin = newDataSet.minBy { it.yVal }?.yVal ?: 0
yMax = newDataSet.maxBy { it.yVal }?.yVal ?: 0
dataSet.clear()
dataSet.addAll(newDataSet)
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
dataSet.forEachIndexed { index, currentDataPoint ->
val realX = currentDataPoint.xVal.toRealX()
val realY = currentDataPoint.yVal.toRealY()
if (index < dataSet.size - 1) {
val nextDataPoint = dataSet[index + 1]
val startX = currentDataPoint.xVal.toRealX()
val startY = currentDataPoint.yVal.toRealY()
val endX = nextDataPoint.xVal.toRealX()
val endY = nextDataPoint.yVal.toRealY()
canvas.drawLine(startX, startY, endX, endY, dataPointLinePaint)
}
canvas.drawCircle(realX, realY, 7f, dataPointFillPaint)
canvas.drawCircle(realX, realY, 7f, dataPointPaint)
}
canvas.drawLine(0f, 0f, 0f, height.toFloat(), axisLinePaint)
canvas.drawLine(0f, height.toFloat(), width.toFloat(), height.toFloat(), axisLinePaint)
}
private fun Int.toRealX() = toFloat() / xMax * width
private fun Int.toRealY() = toFloat() / yMax * height
}
data class DataPoint(
val xVal: Int,
val yVal: Int
)