android-jetpack-composeandroid-animationandroid-motionlayout

Making a streched box (or circle) with Compose and MotionLayout


I am trying to get a good understanding of how to use MotionLayout in Jetpack Compose.

At this state, I believe to have a basic understanding of how the MotionLayout works, by having a MotionScene (defined in a .json5 file) set to the MotionLayout and then apply a Modifier.layoutId to all the Composables, which should have an effect in the animation.

What I am trying to do, is to have a circle, which should stretch out on the X-axis, left side first (for maybe 2-300 ms) then have the right side follow along awards the left (for 2-300 ms), so that it will be a full circle once again - Just at a different position.

An example of what I wish to have, is an animation similar to the three images here. Example of how the animation should work

Sadly I haven't been able to do that yet. In the Transitions part of the MotionScene, I have played around with scaleX, which made the circle misshaped and stretched out to both sides at the same time. And translationX which ofc, just moves the entire circle instead of just a part of it.

My current implementation looks like this:

Composable

@OptIn(ExperimentalMotionApi::class)
@Preview
@Composable
fun AnimationScreen() {
    val context = LocalContext.current
    val motionScene = remember {
        context.resources.openRawResource(R.raw.motion_scene).readBytes().decodeToString()
    }
    var progress by remember { mutableStateOf(0f) }

    Column {
        MotionLayout(
            motionScene = MotionScene(content = motionScene),
            progress = progress,
            modifier = Modifier
                .fillMaxWidth()
        ) {
            Row(
                modifier = Modifier
                    .height(200.dp)
                    .fillMaxWidth()
                    .layoutId("row_container")
            ) { }
            Box(
                modifier = Modifier
                    .size(100.dp)
                    .clip(
                        RoundedCornerShape(
                            topEnd = 25.dp,
                            bottomEnd = 25.dp,
                            topStart = 25.dp,
                            bottomStart = 25.dp
                        )
                    )
                    .background(Color.Red)
                    .layoutId("circle")
            )
        }

        Spacer(Modifier.height(32.dp))

        Slider(
            value = progress,
            onValueChange = {
                progress = it
            }
        )
    }
}

motion_scene.json5

{
  ConstraintSets: {
    start: {
      circle: {
        width: 40,
        height: 40,
        start: ['logo_pic', 'start', 0],
        end: ['logo_pic', 'end', 0],
        top: ['logo_pic', 'top', 0],
        bottom: ['logo_pic', 'bottom', 0]
      },
    },
    end: {
      circle: {
        width: 40,
        height: 40,
        start: ['logo_pic', 'start', 0],
        end: ['logo_pic', 'end', 0],
        top: ['logo_pic', 'top', 0],
        bottom: ['logo_pic', 'bottom', 0],
      },
    },
  },
  Transitions: {
    default: {
      from: 'start',
      to: 'end',
      pathMotionArc: 'startHorizontal',
      KeyFrames: {
        KeyAttributes: [
          {
            target: ['circle'],
            frames: [0, 50, 80, 100],
            scaleX: [1, 1, 2, 2],

            //translationX: [0, 0, 0, -150]
          },
        ],
        KeyPosition: [
          {
            target: ['circle'],
            percentWidth: 20
          }
        ]
      }
    }
  }
}

Hopefully it all comes down to my just being new to this framework, and someone just says, "Easy! You just need to...". Any suggestions on, how I would make this work?


Solution

  • There is not a simple way to do this with motionlayout Because the start and end are the same size. But you can add a middle and play some trick with progress. It was coded this way to avoid a few bugs in the latest release.

    demo of motion

    public const val motionSceneStr = """
    {
      ConstraintSets: {
        start: {
          circle: {
            width: 40,
            height: 40,
            start: ['parent', 'start', 0],
            top: ['parent', 'top', 0],
            bottom: ['parent', 'bottom', 0]
          },
        },
        
       middle: {
          circle: {
            width: 'spread',
            height: 40,
            start: ['parent', 'start', 0],
            end: ['parent', 'end', 0],
            top: ['parent', 'top', 0],
            bottom: ['parent', 'bottom', 0],
          },
        },
       
        end: {
          circle: {
            width: 40,
            height: 40,
            end: ['parent', 'end', 0],
            top: ['parent', 'top', 0],
            bottom: ['parent', 'bottom', 0],
          },
        },
      },
      Transitions: {
          part2: {   from: 'middle',   to: 'end'  }
          part1: {   from: 'middle',       to: 'start'   }
      }
    }
    """;
    
    
    @OptIn(ExperimentalMotionApi::class)
    @Preview
    @Composable
    fun AnimationScreen() {
        val context = LocalContext.current
        val motionScene = remember {motionSceneStr }
        var progress by remember { mutableStateOf(0f) }
    
        Column {
            MotionLayout(
                motionScene = MotionScene(content = motionScene),
                progress = if (progress<0.5) 1-progress*2  else progress*2-1,
                transitionName = if (progress<0.5f) "part1" else "part2",
                modifier = Modifier
                    .fillMaxWidth()
            ) {
                Row(
                    modifier = Modifier
                        .height(200.dp)
                        .fillMaxWidth()
                        .layoutId("row_container")
                ) { }
                Box(
                    modifier = Modifier
                        .size(100.dp)
                        .clip(
                            RoundedCornerShape(
                                topEnd = 25.dp,
                                bottomEnd = 25.dp,
                                topStart = 25.dp,
                                bottomStart = 25.dp
                            )
                        )
                        .background(Color.Red)
                        .layoutId("circle")
                )
            }
    
            Spacer(Modifier.height(32.dp))
    
            Slider(
                value = progress,
                onValueChange = {
                    progress = it
                }
            )
        }
    }