Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Interacting with Composable Canvas

Piotr Prus
November 07, 2022

Interacting with Composable Canvas

Making a custom view using Android Canvas is associated with a big challenge. After all, it is this scary and complicated math that controls everything, and we usually want to reach for simpler solutions. In this session, we will learn how to create a beautiful, animated, and interactive bar chart using a jetpack compose canvas. I will guide you through the scary math and try to convince you there is nothing to be scared about. With jetpack compose, UI is easier to write and test, even the canvas.

Piotr Prus

November 07, 2022
Tweet

More Decks by Piotr Prus

Other Decks in Programming

Transcript

  1. • Android Canvas recap • Composable Canvas • Key di

    ff erences • Implementation • Interaction • Animation • State management • Test composable Agenda:
  2. Composable Canvas @Composable fun Canvas(modifier: Modifier, onDraw: DrawScope.() -> Unit)

    = Spacer(modifier.drawBehind(onDraw)) @Composable fun MyCanvas() { Box(modifier = Modifier .size(100.dp) .background(color = Color.White) .drawBehind { drawRect(color = Color.Blue, size = Size(width = 20f, height = 20f)) }) }
  3. Composable Canvas @Composable fun MyCanvas() { Box(modifier = Modifier .size(100.dp)

    .background(color = Color.White) .drawBehind { drawRect(color = Color.Blue, size = Size(width = 20f, height = 20f)) }) } Result:
  4. Composable Canvas Result: @Composable fun MyCanvas() { Box(modifier = Modifier

    .background(color = Color.White) .drawBehind { drawRect(color = Color.Blue, size = Size(width = 20f, height = 20f)) } ) }
  5. Composable Canvas @Composable fun MyCanvas() { Box(modifier = Modifier .background(color

    = Color.White) .drawBehind { drawRect(color = Color.Blue, size = Size(width = 20f, height = 20f)) } ) } You MUST specify size with modi fi er, whether with exact sizes via Modi fi er.size modi fi er, or relative to parent, via Modi fi er. fi llMaxSize, ColumnScope.weight, etc. If parent wraps this child, only exact sizes must be speci fi ed.
  6. Composable Canvas @Composable fun MyCanvas() { Box(modifier = Modifier .size(100.dp)

    .background(color = Color.White) .drawBehind { drawRect(color = Color.Blue, size = Size(width = 20f, height = 20f)) } ) { Box( modifier = Modifier .size(20.dp) .background(color = Color.Red) ) } } Result:
  7. Composable Canvas @Composable fun MyCanvas() { Box(modifier = Modifier .size(100.dp)

    .background(color = Color.White) .drawBehind { drawRect(color = Color.Blue, size = Size(width = 20f, height = 20f)) } ) { Box( modifier = Modifier .size(20.dp) .background(color = Color.Red) .align(Alignment.BottomEnd) ) } } Result:
  8. Android view Canvas vs Composable canvas val density = LocalDensity.current

    val horizontalPadding = with(density) { 20.dp.toPx() } fun dp2px(resource: Resources, dp: Int): Int { return TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_DIP, dp.toFloat(), resource.getDisplayMetrics()) .toInt() } private fun dpToPixel(dp: Float): Float { val metrics: DisplayMetrics = this.getResources().getDisplayMetrics() return dp * (metrics.densityDpi / 160f) }
  9. Android view Canvas vs Composable canvas class CustomView(context: Context?, attr:

    AttributeSet?) : View(context, attr) { private var width = 0 private var height = 0 fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { this.width = w this.height = h super.onSizeChanged(w, h, oldw, oldh) } fun onDraw(canvas: Canvas) { canvas.drawText("TEST", width / 2, height / 2, paint) } } @Composable fun MyCanvas() { Canvas(modifier = Modifier, onDraw = { val width = size.width val height = size.height }) }
  10. @Composable Canvas has no function drawText 😞 android.graphics.Canvas has function

    drawText 🙂 Canvas() { this.drawContext.canvas.nativeCanvas.drawText( “Text on canvas”, drawPadding + index.times(distance), size.height, textPaint ) } } val textPaint = Paint().apply { color = MaterialTheme.colors.onPrimary.toArgb() textAlign = Paint.Align.CENTER this.textSize = textSize typeface = Typeface.DEFAULT_BOLD }
  11. @Composable fun BarChartCanvas(list: List<Int>, barSelected: (Int) -> Unit) { Row(Modifier

    .fillMaxWidth() .padding(12.dp) .height(150.dp) .horizontalScroll(rememberScrollState()) ) { val density = LocalDensity.current val horizontalPadding = with(density) { 12.dp.toPx() } val distance = with(density) { 26.dp.toPx() } val calculatedWidth = with(density) { (distance.times(list.size) + horizontalPadding.times(2)).toDp() } } }
  12. @Composable fun BarChartCanvas(list: List<Int>, barSelected: (Int) -> Unit) { Row(Modifier

    .fillMaxWidth() .padding(12.dp) .height(150.dp) .horizontalScroll(rememberScrollState()) ) { val density = LocalDensity.current val horizontalPadding = with(density) { 12.dp.toPx() } val distance = with(density) { 26.dp.toPx() } val calculatedWidth = with(density) { (distance.times(list.size) + horizontalPadding.times(2)).toDp() } } }
  13. @Composable fun BarChartCanvas(list: List<Int>, barSelected: (Int) -> Unit) { Row(Modifier

    .fillMaxWidth() .padding(12.dp) .height(150.dp) .horizontalScroll(rememberScrollState()) ) { val density = LocalDensity.current val horizontalPadding = with(density) { 12.dp.toPx() } val distance = with(density) { 26.dp.toPx() } val calculatedWidth = with(density) { (distance.times(list.size) + horizontalPadding.times(2)).toDp() } } }
  14. @Composable fun BarChartCanvas(list: List<Int>, barSelected: (Int) -> Unit) { Row(Modifier

    .fillMaxWidth() .padding(12.dp) .height(150.dp) .horizontalScroll(rememberScrollState()) ) { val density = LocalDensity.current val horizontalPadding = with(density) { 12.dp.toPx() } val distance = with(density) { 26.dp.toPx() } val calculatedWidth = with(density) { (distance.times(list.size) + horizontalPadding.times(2)).toDp() } } }
  15. @Composable fun BarChartCanvas(list: List<Int>, barSelected: (Int) -> Unit) { Row(Modifier

    .fillMaxWidth() .padding(12.dp) .height(150.dp) .horizontalScroll(rememberScrollState()) ) { val density = LocalDensity.current val horizontalPadding = with(density) { 12.dp.toPx() } val distance = with(density) { 26.dp.toPx() } val calculatedWidth = with(density) { (distance.times(list.size) + horizontalPadding.times(2)).toDp() } } }
  16. @Composable fun BarChartCanvas(list: List<Int>, barSelected: (Int) -> Unit) { Row(Modifier)

    { . . . Canvas( modifier = Modifier .fillMaxHeight() .width(calculatedWidth)){ // Draw functions go here } }
  17. // Draw scope val lineDistance = size.height.minus(smallPadding.times(2)).div(4) repeat(5) { drawLine(

    color = Color.Gray, start = Offset(0f, smallPadding.plus(it.times(lineDistance))), end = Offset(size.width, smallPadding.plus(it.times(lineDistance))) ) }
  18. // Draw scope val lineDistance = size.height.minus(smallPadding.times(2)).div(4) repeat(5) { drawLine(

    color = Color.Gray, start = Offset(0f, smallPadding.plus(it.times(lineDistance))), end = Offset(size.width, smallPadding.plus(it.times(lineDistance))) ) }
  19. // Draw scope val lineDistance = size.height.minus(smallPadding.times(2)).div(4) repeat(5) { drawLine(

    color = Color.Gray, start = Offset(0f, smallPadding.plus(it.times(lineDistance))), end = Offset(size.width, smallPadding.plus(it.times(lineDistance))) ) }
  20. // Draw scope val lineDistance = size.height.minus(smallPadding.times(2)).div(4) repeat(5) { drawLine(

    color = Color.Gray, start = Offset(0f, smallPadding.plus(it.times(lineDistance))), end = Offset(size.width, smallPadding.plus(it.times(lineDistance))) ) }
  21. // Draw scope val lineDistance = size.height.minus(smallPadding.times(2)).div(4) repeat(5) { drawLine(

    color = Color.Gray, start = Offset(0f, smallPadding.plus(it.times(lineDistance))), end = Offset(size.width, smallPadding.plus(it.times(lineDistance))) ) }
  22. barAreas.forEachIndexed { index, item -> val barHeight = item.value.times(scale).toFloat() drawRoundRect(

    color = skyBlue400, topLeft = Offset( x = horizontalPadding + distance.times(index) - barWidth.div(2), y = size.height - barHeight - smallPadding ), size = Size(barWidth, barHeight), cornerRadius = CornerRadius(cornerRadius) ) }
  23. barAreas.forEachIndexed { index, item -> val barHeight = item.value.times(scale).toFloat() drawRoundRect(

    color = skyBlue400, topLeft = Offset( x = horizontalPadding + distance.times(index) - barWidth.div(2), y = size.height - barHeight - smallPadding ), size = Size(barWidth, barHeight), cornerRadius = CornerRadius(cornerRadius) ) } data class BarArea( val index: Int, val xStart: Float, val xEnd: Float, val value: Int )
  24. barAreas.forEachIndexed { index, item -> val barHeight = item.value.times(scale).toFloat() drawRoundRect(

    color = skyBlue400, topLeft = Offset( x = horizontalPadding + distance.times(index) - barWidth.div(2), y = size.height - barHeight - smallPadding ), size = Size(barWidth, barHeight), cornerRadius = CornerRadius(cornerRadius) ) } data class BarArea( val index: Int, val xStart: Float, val xEnd: Float, val value: Int ) val barAreas = list.mapIndexed { index, i -> BarArea( index = index, value = i, xStart = horizontalPadding + distance.times(index) - distance.div(2), xEnd = horizontalPadding + distance.times(index) + distance.div(2) ) }
  25. barAreas.forEachIndexed { index, item -> val barHeight = item.value.times(scale).toFloat() drawRoundRect(

    color = skyBlue400, topLeft = Offset( x = horizontalPadding + distance.times(index) - barWidth.div(2), y = size.height - barHeight - smallPadding ), size = Size(barWidth, barHeight), cornerRadius = CornerRadius(cornerRadius) ) }
  26. barAreas.forEachIndexed { index, item -> val barHeight = item.value.times(scale).toFloat() drawRoundRect(

    color = skyBlue400, topLeft = Offset( x = horizontalPadding + distance.times(index) - barWidth.div(2), y = size.height - barHeight - smallPadding ), size = Size(barWidth, barHeight), cornerRadius = CornerRadius(cornerRadius) ) }
  27. barAreas.forEachIndexed { index, item -> val barHeight = item.value.times(scale).toFloat() drawRoundRect(

    color = skyBlue400, topLeft = Offset( x = horizontalPadding + distance.times(index) - barWidth.div(2), y = size.height - barHeight - smallPadding ), size = Size(barWidth, barHeight), cornerRadius = CornerRadius(cornerRadius) ) }
  28. barAreas.forEachIndexed { index, item -> val barHeight = item.value.times(scale).toFloat() drawRoundRect(

    color = skyBlue400, topLeft = Offset( x = horizontalPadding + distance.times(index) - barWidth.div(2), y = size.height - barHeight - smallPadding ), size = Size(barWidth, barHeight), cornerRadius = CornerRadius(cornerRadius) ) }
  29. barAreas.forEachIndexed { index, item -> val barHeight = item.value.times(scale).toFloat() drawRoundRect(

    color = skyBlue400, topLeft = Offset( x = horizontalPadding + distance.times(index) - barWidth.div(2), y = size.height - barHeight - smallPadding ), size = Size(barWidth, barHeight), cornerRadius = CornerRadius(cornerRadius) ) }
  30. barAreas.forEachIndexed { index, item -> val barHeight = item.value.times(scale).toFloat() drawRoundRect(

    color = skyBlue400, topLeft = Offset( x = horizontalPadding + distance.times(index) - barWidth.div(2), y = size.height - barHeight - smallPadding ), size = Size(barWidth, barHeight), cornerRadius = CornerRadius(cornerRadius) ) }
  31. barAreas.forEachIndexed { index, item -> val barHeight = item.value.times(scale).toFloat() drawRoundRect(

    color = skyBlue400, topLeft = Offset( x = horizontalPadding + distance.times(index) - barWidth.div(2), y = size.height - barHeight - smallPadding ), size = Size(barWidth, barHeight), cornerRadius = CornerRadius(cornerRadius) ) }
  32. barAreas.forEachIndexed { index, item -> . . . this.drawIntoCanvas {

    canvas -> val textPositionY = chartAreaBottom - barHeight - smallPadding canvas.nativeCanvas.drawText( "${item.value}", horizontalPadding + distance.times(index), textPositionY, paint ) } }
  33. barAreas.forEachIndexed { index, item -> . . . this.drawIntoCanvas {

    canvas -> val textPositionY = chartAreaBottom - barHeight - smallPadding canvas.nativeCanvas.drawText( "${item.value}", horizontalPadding + distance.times(index), textPositionY, paint ) } }
  34. barAreas.forEachIndexed { index, item -> . . . this.drawIntoCanvas {

    canvas -> val textPositionY = chartAreaBottom - barHeight - smallPadding canvas.nativeCanvas.drawText( "${item.value}", horizontalPadding + distance.times(index), textPositionY, paint ) } }
  35. barAreas.forEachIndexed { index, item -> . . . this.drawIntoCanvas {

    canvas -> val textPositionY = chartAreaBottom - barHeight - smallPadding canvas.nativeCanvas.drawText( "${item.value}", horizontalPadding + distance.times(index), textPositionY, paint ) } } val textSize = with(density) { 10.sp.toPx() } val paint = Paint().apply { color = 0xffff47586B.toInt() textAlign = Paint.Align.CENTER this.textSize = textSize }
  36. fun Modifier.pointerInput( key1: Any?, block: suspend PointerInputScope.() -> Unit ):

    Modifier = composed() { val density = LocalDensity.current val viewConfiguration = LocalViewConfiguration.current remember(density) { SuspendingPointerInputFilter(viewConfiguration, density) }.apply { LaunchedEffect(this, key1) { block() } } } Pointer input
  37. @Composable fun Modifier.startGesture( onStart: (offsetX: Float) -> Unit ): Modifier

    { val interactionSource = remember { MutableInteractionSource() } return this.pointerInput(interactionSource) { forEachGesture { coroutineScope { awaitPointerEventScope { val touch = awaitFirstDown().also { it.consumeDownChange() } onStart(touch.position.x) } } } } } Pointer input
  38. @Composable fun Modifier.startGesture( onStart: (offsetX: Float) -> Unit ): Modifier

    { val interactionSource = remember { MutableInteractionSource() } return this.pointerInput(interactionSource) { forEachGesture { coroutineScope { awaitPointerEventScope { val touch = awaitFirstDown().also { it.consumeDownChange() } onStart(touch.position.x) } } } } } Pointer input
  39. @Composable fun Modifier.startGesture( onStart: (offsetX: Float) -> Unit ): Modifier

    { val interactionSource = remember { MutableInteractionSource() } return this.pointerInput(interactionSource) { forEachGesture { coroutineScope { awaitPointerEventScope { val touch = awaitFirstDown().also { it.consumeDownChange() } onStart(touch.position.x) } } } } } Pointer input
  40. @Composable fun Modifier.startGesture( onStart: (offsetX: Float) -> Unit ): Modifier

    { val interactionSource = remember { MutableInteractionSource() } return this.pointerInput(interactionSource) { forEachGesture { coroutineScope { awaitPointerEventScope { val touch = awaitFirstDown().also { it.consumeDownChange() } onStart(touch.position.x) } } } } } Pointer input
  41. @Composable fun Modifier.startGesture( onStart: (offsetX: Float) -> Unit ): Modifier

    { val interactionSource = remember { MutableInteractionSource() } return this.pointerInput(interactionSource) { forEachGesture { coroutineScope { awaitPointerEventScope { val touch = awaitFirstDown().also { it.consumeDownChange() } onStart(touch.position.x) } } } } } Pointer input
  42. @Composable fun Modifier.startGesture( onStart: (offsetX: Float) -> Unit ): Modifier

    { val interactionSource = remember { MutableInteractionSource() } return this.pointerInput(interactionSource) { forEachGesture { coroutineScope { awaitPointerEventScope { val touch = awaitFirstDown().also { it.consumeDownChange() } onStart(touch.position.x) } } } } } Pointer input
  43. @Composable fun Modifier.startGesture( onStart: (offsetX: Float) -> Unit ): Modifier

    { val interactionSource = remember { MutableInteractionSource() } return this.pointerInput(interactionSource) { forEachGesture { coroutineScope { awaitPointerEventScope { val touch = awaitFirstDown().also { it.consumeDownChange() } onStart(touch.position.x) } } } } } Pointer input
  44. @Composable fun Modifier.tapOrPress( onStart: (offsetX: Float) -> Unit, onCancel: (offsetX:

    Float) -> Unit, onCompleted: (offsetX: Float) -> Unit ): Modifier { val interactionSource = remember { MutableInteractionSource() } return this.pointerInput(interactionSource) { forEachGesture { coroutineScope { awaitPointerEventScope { val tap = awaitFirstDown().also { it.consumeDownChange() } onStart(tap.position.x) val up = waitForUpOrCancellation() if (up == null) { onCancel(tap.position.x) } else { up.consumeDownChange() onCompleted(tap.position.x) } } } } } } Pointer input
  45. @Composable fun Modifier.tapOrPress( onStart: (offsetX: Float) -> Unit, onCancel: (offsetX:

    Float) -> Unit, onCompleted: (offsetX: Float) -> Unit ): Modifier { val interactionSource = remember { MutableInteractionSource() } return this.pointerInput(interactionSource) { forEachGesture { coroutineScope { awaitPointerEventScope { val tap = awaitFirstDown().also { it.consumeDownChange() } onStart(tap.position.x) val up = waitForUpOrCancellation() if (up == null) { onCancel(tap.position.x) } else { up.consumeDownChange() onCompleted(tap.position.x) } } } } } } Pointer input
  46. @Composable fun Modifier.tapOrPress( onStart: (offsetX: Float) -> Unit, onCancel: (offsetX:

    Float) -> Unit, onCompleted: (offsetX: Float) -> Unit ): Modifier { val interactionSource = remember { MutableInteractionSource() } return this.pointerInput(interactionSource) { forEachGesture { coroutineScope { awaitPointerEventScope { val tap = awaitFirstDown().also { it.consumeDownChange() } onStart(tap.position.x) val up = waitForUpOrCancellation() if (up == null) { onCancel(tap.position.x) } else { up.consumeDownChange() onCompleted(tap.position.x) } } } } } } Pointer input
  47. @Composable fun Modifier.tapOrPress( onStart: (offsetX: Float) -> Unit, onCancel: (offsetX:

    Float) -> Unit, onCompleted: (offsetX: Float) -> Unit ): Modifier { val interactionSource = remember { MutableInteractionSource() } return this.pointerInput(interactionSource) { forEachGesture { coroutineScope { awaitPointerEventScope { val tap = awaitFirstDown().also { it.consumeDownChange() } onStart(tap.position.x) val up = waitForUpOrCancellation() if (up == null) { onCancel(tap.position.x) } else { up.consumeDownChange() onCompleted(tap.position.x) } } } } } } Pointer input
  48. @Composable fun Modifier.tapOrPress( onStart: (offsetX: Float) -> Unit, onCancel: (offsetX:

    Float) -> Unit, onCompleted: (offsetX: Float) -> Unit ): Modifier { val interactionSource = remember { MutableInteractionSource() } return this.pointerInput(interactionSource) { forEachGesture { coroutineScope { awaitPointerEventScope { val tap = awaitFirstDown().also { it.consumeDownChange() } onStart(tap.position.x) val up = waitForUpOrCancellation() if (up == null) { onCancel(tap.position.x) } else { up.consumeDownChange() onCompleted(tap.position.x) } } } } } } Pointer input
  49. Simple selection var selectedPosition by remember { mutableStateOf(0) } val

    selectedBar by remember(selectedPosition, barAreas) { derivedStateOf { barAreas.find { it.xStart < selectedPosition && selectedPosition < it.xEnd } } } Modifier.tapOrPress( onStart = { }, onCancel = { }, onCompleted = { selectedPosition = it } )
  50. Stateful Simple selection var selectedPosition by remember { mutableStateOf(0) }

    val selectedBar by remember(selectedPosition, barAreas) { derivedStateOf { barAreas.find { it.xStart < selectedPosition && selectedPosition < it.xEnd } } } Modifier.tapOrPress( onStart = { }, onCancel = { }, onCompleted = { selectedPosition = it } )
  51. Simple selection // in Draw scope if (selectedBar != null)

    { drawRoundRect( brush = Brush.verticalGradient( listOf( skyBlue400.copy(alpha = 0.3f), Color.Transparent ) ), topLeft = Offset( x = horizontalPaddingPx + distancePx.times(bar.index) - areaWidthPx.div(2), y = areaHeightPx.plus(topPaddingPx) - areaHeightPx ), size = Size(areaWidthPx, areaHeightPx), cornerRadius = CornerRadius(cornerRadiusPx) ) }
  52. Simple selection // in Draw scope if (selectedBar != null)

    { drawRoundRect( brush = Brush.verticalGradient( listOf( skyBlue400.copy(alpha = 0.3f), Color.Transparent ) ), topLeft = Offset( x = horizontalPaddingPx + distancePx.times(bar.index) - areaWidthPx.div(2), y = areaHeightPx.plus(topPaddingPx) - areaHeightPx ), size = Size(areaWidthPx, areaHeightPx), cornerRadius = CornerRadius(cornerRadiusPx) ) }
  53. Simple selection // in Draw scope if (selectedBar != null)

    { drawRoundRect( brush = Brush.verticalGradient( listOf( skyBlue400.copy(alpha = 0.3f), Color.Transparent ) ), topLeft = Offset( x = horizontalPaddingPx + distancePx.times(bar.index) - areaWidthPx.div(2), y = areaHeightPx.plus(topPaddingPx) - areaHeightPx ), size = Size(areaWidthPx, areaHeightPx), cornerRadius = CornerRadius(cornerRadiusPx) ) }
  54. Simple selection // in Draw scope if (selectedBar != null)

    { drawRoundRect( brush = Brush.verticalGradient( listOf( skyBlue400.copy(alpha = 0.3f), Color.Transparent ) ), topLeft = Offset( x = horizontalPaddingPx + distancePx.times(bar.index) - areaWidthPx.div(2), y = areaHeightPx.plus(topPaddingPx) - areaHeightPx ), size = Size(areaWidthPx, areaHeightPx), cornerRadius = CornerRadius(cornerRadiusPx) ) }
  55. Simple selection // in Draw scope if (selectedBar != null)

    { drawRoundRect( brush = Brush.verticalGradient( listOf( skyBlue400.copy(alpha = 0.3f), Color.Transparent ) ), topLeft = Offset( x = horizontalPaddingPx + distancePx.times(bar.index) - areaWidthPx.div(2), y = areaHeightPx.plus(topPaddingPx) - areaHeightPx ), size = Size(areaWidthPx, areaHeightPx), cornerRadius = CornerRadius(cornerRadiusPx) ) }
  56. Simple selection // in Draw scope if (selectedBar != null)

    { drawRoundRect( brush = Brush.verticalGradient( listOf( skyBlue400.copy(alpha = 0.3f), Color.Transparent ) ), topLeft = Offset( x = horizontalPaddingPx + distancePx.times(bar.index) - areaWidthPx.div(2), y = areaHeightPx.plus(topPaddingPx) - areaHeightPx ), size = Size(areaWidthPx, areaHeightPx), cornerRadius = CornerRadius(cornerRadiusPx) ) }
  57. Animatable val animatable = remember { Animatable(0f) } ... //Modifier.tapOrPress

    onCompleted = { scope.launch { selectedPosition = it animatable.animateTo(1f) } } ... // draw scope drawRoundRect( brush = brush, topLeft = Offset( x = horizontalPadding + distance.times(selectedBar!!.index) - selectionWidth.div(2), y = chartAreaHeight - chartAreaHeight.times(animatable.value)), // use of animatable value size = Size(selectionWidth, chartAreaHeight), cornerRadius = CornerRadius(cornerRadius) )
  58. Animatable val animatable = remember { Animatable(0f) } ... //Modifier.tapOrPress

    onCompleted = { scope.launch { selectedPosition = it animatable.animateTo(1f) } } ... // draw scope drawRoundRect( brush = brush, topLeft = Offset( x = horizontalPadding + distance.times(selectedBar!!.index) - selectionWidth.div(2), y = chartAreaHeight - chartAreaHeight.times(animatable.value)), // use of animatable value size = Size(selectionWidth, chartAreaHeight), cornerRadius = CornerRadius(cornerRadius) )
  59. gestureStarted() tempSelection = xPosition tempAnimatable.animateTo(1) var selectedPosition by remember {

    mutableStateOf(0f) } var tempPosition by remember { mutableStateOf(-Int.MAX_VALUE.toFloat()) } val animatable = remember { Animatable(1f) } val tempAnimatable = remember { Animatable(0f) }
  60. gestureStarted() tempSelection = xPosition tempAnimatable.animateTo(1) var selectedPosition by remember {

    mutableStateOf(0f) } var tempPosition by remember { mutableStateOf(-Int.MAX_VALUE.toFloat()) } val animatable = remember { Animatable(1f) } val tempAnimatable = remember { Animatable(0f) } gestureCanceled() tempSelection = -1 tempAnimatable.animateTo(0)
  61. gestureStarted() tempSelection = xPosition tempAnimatable.animateTo(1) var selectedPosition by remember {

    mutableStateOf(0f) } var tempPosition by remember { mutableStateOf(-Int.MAX_VALUE.toFloat()) } val animatable = remember { Animatable(1f) } val tempAnimatable = remember { Animatable(0f) } gestureCanceled() tempSelection = -1 tempAnimatable.animateTo(0) gestureCompleted() animatable = tempAnimatable + animateTo(1) tempAnimatable = 0 selection = tempSelection tempSelection = -1
  62. Testing @Composable fun BarChartCanvas(list: List<Int>, barSelected: (Int) -> Unit) Row(Modifier

    .fillMaxWidth() .padding(12.dp) .height(150.dp) .testTag("BarChart") .horizontalScroll(rememberScrollState()) )
  63. Testing @Composable fun BarChartCanvas(list: List<Int>, barSelected: (Int) -> Unit) Row(Modifier

    .fillMaxWidth() .padding(12.dp) .height(150.dp) .testTag("BarChart") .horizontalScroll(rememberScrollState()) )
  64. Testing @get:Rule val composeTestRule = createComposeRule() @Test fun checkCanvasExistence() {

    val list = mutableStateOf(listOf(1, 2, 3, 4, 5, 6, 7, 8)) composeTestRule.setContent { BarChartCanvas(list = list.value, barSelected = { }) } composeTestRule.onNodeWithTag(testTag = "BarChart").assertExists() }
  65. Testing @Test fun clickOnThirdElementOfList() { val list = mutableStateOf(listOf(1, 2,

    3, 4, 5, 6, 7, 8)) val selectedItem = mutableStateOf(list.value.first()) val distance = with(composeTestRule.density) { (12.dp + 20.dp.times(3)).toPx() } composeTestRule.setContent { BarChartCanvas(list = list.value, barSelected = { selectedItem.value = it }) } composeTestRule.onNodeWithTag(testTag = "BarChart") .performGesture { click(position = Offset(distance, 1f)) } assertEquals(3, selectedItem.value) }
  66. Testing @Test fun clickOnThirdElementOfList() { val list = mutableStateOf(listOf(1, 2,

    3, 4, 5, 6, 7, 8)) val selectedItem = mutableStateOf(list.value.first()) val distance = with(composeTestRule.density) { (12.dp + 20.dp.times(3)).toPx() } composeTestRule.setContent { BarChartCanvas(list = list.value, barSelected = { selectedItem.value = it }) } composeTestRule.onNodeWithTag(testTag = "BarChart") .performGesture { click(position = Offset(distance, 1f)) } assertEquals(3, selectedItem.value) }
  67. Testing @Test fun clickOnThirdElementOfList() { val list = mutableStateOf(listOf(1, 2,

    3, 4, 5, 6, 7, 8)) val selectedItem = mutableStateOf(list.value.first()) val distance = with(composeTestRule.density) { (12.dp + 20.dp.times(3)).toPx() } composeTestRule.setContent { BarChartCanvas(list = list.value, barSelected = { selectedItem.value = it }) } composeTestRule.onNodeWithTag(testTag = "BarChart") .performGesture { click(position = Offset(distance, 1f)) } assertEquals(3, selectedItem.value) }
  68. Testing @Test fun clickOnThirdElementOfList() { val list = mutableStateOf(listOf(1, 2,

    3, 4, 5, 6, 7, 8)) val selectedItem = mutableStateOf(list.value.first()) val distance = with(composeTestRule.density) { (12.dp + 20.dp.times(3)).toPx() } composeTestRule.setContent { BarChartCanvas(list = list.value, barSelected = { selectedItem.value = it }) } composeTestRule.onNodeWithTag(testTag = "BarChart") .performGesture { click(position = Offset(distance, 1f)) } assertEquals(3, selectedItem.value) }
  69. Testing @Test fun clickOnThirdElementOfList() { val list = mutableStateOf(listOf(1, 2,

    3, 4, 5, 6, 7, 8)) val selectedItem = mutableStateOf(list.value.first()) val distance = with(composeTestRule.density) { (12.dp + 20.dp.times(3)).toPx() } composeTestRule.setContent { BarChartCanvas(list = list.value, barSelected = { selectedItem.value = it }) } composeTestRule.onNodeWithTag(testTag = "BarChart") .performGesture { click(position = Offset(distance, 1f)) } assertEquals(3, selectedItem.value) }
  70. Testing @Test fun clickOnThirdElementOfList() { val list = mutableStateOf(listOf(1, 2,

    3, 4, 5, 6, 7, 8)) val selectedItem = mutableStateOf(list.value.first()) val distance = with(composeTestRule.density) { (12.dp + 20.dp.times(3)).toPx() } composeTestRule.setContent { BarChartCanvas(list = list.value, barSelected = { selectedItem.value = it }) } composeTestRule.onNodeWithTag(testTag = "BarChart") .performGesture { click(position = Offset(distance, 1f)) } assertEquals(3, selectedItem.value) }
  71. Testing @Test fun cancelSelectionOfThirdElement() { val list = mutableStateOf(listOf(1, 2,

    3, 4, 5, 6, 7, 8)) val selectedItem = mutableStateOf(list.value.first()) val distance = with(composeTestRule.density) { (12.dp + 20.dp.times(3)).toPx() } composeTestRule.setContent { BarChartCanvas(list = list.value, barSelected = { selectedItem.value = it }) } composeTestRule.onNodeWithTag(testTag = "BarChart") .performGesture { down(Offset(distance, 1f)) } .performGesture { moveBy(Offset(distance, 0f)) } .performGesture { up() } assertEquals(1, selectedItem.value) }
  72. Testing @Test fun cancelSelectionOfThirdElement() { val list = mutableStateOf(listOf(1, 2,

    3, 4, 5, 6, 7, 8)) val selectedItem = mutableStateOf(list.value.first()) val distance = with(composeTestRule.density) { (12.dp + 20.dp.times(3)).toPx() } composeTestRule.setContent { BarChartCanvas(list = list.value, barSelected = { selectedItem.value = it }) } composeTestRule.onNodeWithTag(testTag = "BarChart") .performGesture { down(Offset(distance, 1f)) } .performGesture { moveBy(Offset(distance, 0f)) } .performGesture { up() } assertEquals(1, selectedItem.value) }
  73. Testing @Test fun cancelSelectionOfThirdElement() { val list = mutableStateOf(listOf(1, 2,

    3, 4, 5, 6, 7, 8)) val selectedItem = mutableStateOf(list.value.first()) val distance = with(composeTestRule.density) { (12.dp + 20.dp.times(3)).toPx() } composeTestRule.setContent { BarChartCanvas(list = list.value, barSelected = { selectedItem.value = it }) } composeTestRule.onNodeWithTag(testTag = "BarChart") .performGesture { down(Offset(distance, 1f)) } .performGesture { moveBy(Offset(distance, 0f)) } .performGesture { up() } assertEquals(1, selectedItem.value) }
  74. Testing @Test fun cancelSelectionOfThirdElement() { val list = mutableStateOf(listOf(1, 2,

    3, 4, 5, 6, 7, 8)) val selectedItem = mutableStateOf(list.value.first()) val distance = with(composeTestRule.density) { (12.dp + 20.dp.times(3)).toPx() } composeTestRule.setContent { BarChartCanvas(list = list.value, barSelected = { selectedItem.value = it }) } composeTestRule.onNodeWithTag(testTag = "BarChart") .performGesture { down(Offset(distance, 1f)) } .performGesture { moveBy(Offset(distance, 0f)) } .performGesture { up() } assertEquals(1, selectedItem.value) }
  75. Testing @Test fun cancelSelectionOfThirdElement() { val list = mutableStateOf(listOf(1, 2,

    3, 4, 5, 6, 7, 8)) val selectedItem = mutableStateOf(list.value.first()) val distance = with(composeTestRule.density) { (12.dp + 20.dp.times(3)).toPx() } composeTestRule.setContent { BarChartCanvas(list = list.value, barSelected = { selectedItem.value = it }) } composeTestRule.onNodeWithTag(testTag = "BarChart") .performGesture { down(Offset(distance, 1f)) } .performGesture { moveBy(Offset(distance, 0f)) } .performGesture { up() } assertEquals(1, selectedItem.value) }
  76. Testing @Test fun cancelSelectionOfThirdElement() { val list = mutableStateOf(listOf(1, 2,

    3, 4, 5, 6, 7, 8)) val selectedItem = mutableStateOf(list.value.first()) val distance = with(composeTestRule.density) { (12.dp + 20.dp.times(3)).toPx() } composeTestRule.setContent { BarChartCanvas(list = list.value, barSelected = { selectedItem.value = it }) } composeTestRule.onNodeWithTag(testTag = "BarChart") .performGesture { down(Offset(distance, 1f)) } .performGesture { moveBy(Offset(distance, 0f)) } .performGesture { up() } assertEquals(1, selectedItem.value) }
  77. Testing @Test fun cancelSelectionOfThirdElement() { val list = mutableStateOf(listOf(1, 2,

    3, 4, 5, 6, 7, 8)) val selectedItem = mutableStateOf(list.value.first()) val distance = with(composeTestRule.density) { (12.dp + 20.dp.times(3)).toPx() } composeTestRule.setContent { BarChartCanvas(list = list.value, barSelected = { selectedItem.value = it }) } composeTestRule.onNodeWithTag(testTag = "BarChart") .performGesture { down(Offset(distance, 1f)) } .performGesture { moveBy(Offset(distance, 0f)) } .performGesture { up() } assertEquals(1, selectedItem.value) }
  78. Testing @Test fun cancelSelectionOfThirdElement() { val list = mutableStateOf(listOf(1, 2,

    3, 4, 5, 6, 7, 8)) val selectedItem = mutableStateOf(list.value.first()) val distance = with(composeTestRule.density) { (12.dp + 20.dp.times(3)).toPx() } composeTestRule.setContent { BarChartCanvas(list = list.value, barSelected = { selectedItem.value = it }) } composeTestRule.onNodeWithTag(testTag = "BarChart") .performGesture { down(Offset(distance, 1f)) } .performGesture { moveBy(Offset(distance, 0f)) } .performGesture { up() } assertEquals(1, selectedItem.value) }
  79. Final thoughts • Drawing using Composable Canvas is very similar

    to Android View Canvas • drawText is missing in Compose version • Testing is easy and very readable • Animation has good documentation