Lock in $30 Savings on PRO—Offer Ends Soon! ⏳

Tap it! Shake it! Fling it! Sheep it! The Ges...

Tap it! Shake it! Fling it! Sheep it! The Gesture Animations Dance!

Compose Multiplatform에서 제스처와 센서를 활용하여 애니메이션을 구현해보자

KwakEuiJin

June 29, 2024
Tweet

More Decks by KwakEuiJin

Other Decks in Programming

Transcript

  1. Tap it! Shake it! Fling it! Sheep it! The Gesture

    Animations Dance! @KwakEuiJin KotlinConf’24 Korea
  2. 1. What: 무엇을 애니메이션화 할 것인가? 2. When: 언제 애니메이션을

    발생시킬 것인가? 3. How: 어떻게 애니메이션을 구현할 것인가?
  3. 1. What: 무엇을 애니메이션화 할 것인가? 2. When: 언제 애니메이션을

    발생시킬 것인가? 3. How: 어떻게 애니메이션을 구현할 것인가?
  4. 1. What: 무엇을 애니메이션화 할 것인가? 2. When: 언제 애니메이션을

    발생시킬 것인가? 3. How: 어떻게 애니메이션을 구현할 것인가?
  5. 애니메이션을 어떻게 만들 것 인가? 1. What: 무엇을 애니메이션화 할

    것인가? 2. When: 언제 애니메이션을 발생시킬 것인가? 3. How: 어떻게 애니메이션을 구현할 것인가? Animation을 만들기 위한 3원칙
  6. Tab To Scale! Step #1 1. What? 크기 2. When?

    컴포저블이 터치되었을 때 3. How? 크기 1.0 <-> 1.2 Screen_recording_20240625_191219.gif
  7. Tab To Scale! // 1. What? val scale = 1f

    // 2. When? ComposableKodee( modifier = Modifier .graphicsLayer { // Important: set scale in graphicsLayer(!) scaleX = scale scaleY = scale } .clickable( onClick = { . . . }) . . . // 3. How? onClick = { val newScale = if (scale == 1f) 1.2f else 1f scale = newScale }
  8. Tab To Scale! // 1. What? val scale = 1f

    // 2. When? ComposableKodee( modifier = Modifier .graphicsLayer { // Important: set scale in graphicsLayer(!) scaleX = scale scaleY = scale } .clickable( onClick = { . . . }) . . . // 3. How? onClick = { val newScale = if (scale == 1f) 1.2f else 1f scale = newScale }
  9. Tab To Scale! // 1. What? val scale = 1f

    // 2. When? ComposableKodee( modifier = Modifier .graphicsLayer { // Important: set scale in graphicsLayer(!) scaleX = scale scaleY = scale } .clickable( onClick = { . . . }) . . . // 3. How? onClick = { val newScale = if (scale == 1f) 1.2f else 1f scale = newScale } graphicsLayer?
  10. Tab To Scale! // 1. What? val scale = 1f

    // 2. When? ComposableKodee( modifier = Modifier .graphicsLayer { // Important: set scale in graphicsLayer(!) scaleX = scale scaleY = scale } .clickable( onClick = { . . . }) . . . // 3. How? onClick = { val newScale = if (scale == 1f) 1.2f else 1f scale = newScale }
  11. Tab To Scale! // 1. What? val scale = 1f

    // 2. When? ComposableKodee( modifier = Modifier .graphicsLayer { // Important: set scale in graphicsLayer(!) scaleX = scale scaleY = scale } .clickable( onClick = { . . . }) . . . // 3. How? onClick = { val newScale = if (scale == 1f) 1.2f else 1f scale = newScale } No Animations!
  12. Tab To Scale! // 1. What? val scale = 1f

    // 2. When? ComposableKodee( modifier = Modifier .graphicsLayer { // Important: set scale in graphicsLayer(!) scaleX = scale.value scaleY = scale.value } .clickable(onClick = { . . . }) // 3. How? onClick = { val newScale = if (scale == 1f) 1.2f else 1f scale = newScale }
  13. Tab To Scale! // 1. What? val scale = remember

    { Animatable(1f) } // 2. When? ComposableKodee( modifier = Modifier .graphicsLayer { // Important: set scale in graphicsLayer(!) scaleX = scale.value scaleY = scale.value } .clickable(onClick = { . . . }) // 3. How? onClick = { val newScale = if (scale == 1f) 1.2f else 1f scale = newScale }
  14. Tab To Scale! // 1. What? val scale = remember

    { Animatable(1f) } // 2. When? ComposableKodee( modifier = Modifier .graphicsLayer { // Important: set scale in graphicsLayer(!) scaleX = scale.value scaleY = scale.value } .clickable(onClick = { . . . }) // 3. How? onClick = { val newScale = if (scale == 1f) 1.2f else 1f scale = newScale }
  15. Tab To Scale! // 1. What? val scale = remember

    { Animatable(1f) } // 2. When? ComposableKodee( modifier = Modifier .graphicsLayer { // Important: set scale in graphicsLayer(!) scaleX = scale.value scaleY = scale.value } .clickable(onClick = { . . . }) // 3. How? onClick = { coroutineScope.launch { val newScale = if (scale.value == 1f) 1.2f else 1f scale.animateTo(newScale) } }
  16. Tab To Scale! // 1. What? val scale = remember

    { Animatable(1f) } // 2. When? ComposableKodee( modifier = Modifier .graphicsLayer { // Important: set scale in graphicsLayer(!) scaleX = scale.value scaleY = scale.value } .clickable(onClick = { . . . }) // 3. How? onClick = { coroutineScope.launch { val newScale = if (scale.value == 1f) 1.2f else 1f scale.animateTo(newScale) } }
  17. Tab To Scale! // 3. How? onClick = { coroutineScope.launch

    { val newScale = if (scale.value == 1f) 1.2f else 1f scale.animateTo(newScale) } }
  18. Tab To Scale! // 3. How? onClick = { coroutineScope.launch

    { val newScale = if (scale.value == 1f) 1.2f else 1f scale.animateTo( targetValue = newScale, animationSpec = // animation spec ) . . .
  19. Tab To Scale! // 3. How? onClick = { coroutineScope.launch

    { val newScale = if (scale.value == 1f) 1.2f else 1f scale.animateTo( targetValue = newScale, animationSpec = spring( dampingRatio = Spring.DampingRatioHighBouncy, stiffness = Spring.StiffnessHigh, ) ) . . .
  20. Tab To Scale! // 3. How? onClick = { coroutineScope.launch

    { val newScale = if (scale.value == 1f) 1.2f else 1f scale.animateTo( targetValue = newScale, animationSpec = spring( dampingRatio = Spring.DampingRatioHighBouncy, stiffness = Spring.StiffnessHigh, ) ) . . .
  21. Reposition (Drag) ! Step #1 1. What? • 위치(translation) 2.

    When? • 컴포저블을 드래그 했을 때 3. How? • 새로운 위치로 이동
  22. Reposition (Drag) ! // 1. What? val translation = remember

    { Animatable(Offset(0f, 0f), Offset.VectorConverter) } // 2. When? ComposableKodee( modifier = Modifier .graphicsLayer { translationX = translation.value.x translationY = translation.value.y } .draggable2D(state = draggableState) ... ) // 3. How? val draggableState = rememberDraggable2DState { delta -> coroutineScope.launch { translation.snapTo(translation.value.plus(delta)) } }
  23. Reposition (Drag) ! // 1. What? val translation = remember

    { Animatable(Offset(0f, 0f), Offset.VectorConverter) } // 2. When? ComposableKodee( modifier = Modifier .graphicsLayer { translationX = translation.value.x translationY = translation.value.y } .draggable2D(state = draggableState) ... ) // 3. How? val draggableState = rememberDraggable2DState { delta -> coroutineScope.launch { translation.snapTo(translation.value.plus(delta)) } }
  24. Reposition (Drag) ! // 1. What? val translation = remember

    { Animatable(Offset(0f, 0f), Offset.VectorConverter) } // 2. When? ComposableKodee( modifier = Modifier .graphicsLayer { translationX = translation.value.x translationY = translation.value.y } .draggable2D(state = draggableState) ... ) // 3. How? val draggableState = rememberDraggable2DState { delta -> coroutineScope.launch { translation.snapTo(translation.value.plus(delta)) } }
  25. Reposition (Drag) ! // 1. What? val translation = remember

    { Animatable(Offset(0f, 0f), Offset.VectorConverter) } // 2. When? ComposableKodee( modifier = Modifier .graphicsLayer { translationX = translation.value.x translationY = translation.value.y } .draggable2D(state = draggableState) ... ) // 3. How? val draggableState = rememberDraggable2DState { delta -> coroutineScope.launch { translation.snapTo(translation.value.plus(delta)) } }
  26. Reposition (Drag) ! // 1. What? val translation = remember

    { Animatable(Offset(0f, 0f), Offset.VectorConverter) } // 2. When? ComposableKodee( modifier = Modifier .graphicsLayer { translationX = translation.value.x translationY = translation.value.y } .draggable2D(state = draggableState) ... ) // 3. How? val draggableState = rememberDraggable2DState { delta -> coroutineScope.launch { translation.snapTo(translation.value.plus(delta)) } }
  27. Reposition (Drag) ! // 1. What? val translation = remember

    { Animatable(Offset(0f, 0f), Offset.VectorConverter) } // 2. When? ComposableKodee( modifier = Modifier .graphicsLayer { translationX = translation.value.x translationY = translation.value.y } .draggable2D(state = draggableState) ... ) // 3. How? val draggableState = rememberDraggable2DState { delta -> coroutineScope.launch { translation.snapTo(translation.value.plus(delta)) } }
  28. Fling and Back Step #1 Screen_recording_20240625_210341-ezgif.com- video-to-gif-converter.gif 1. What? •

    위치(translation) 2. When? • 컴포저블을 던지는 드래그 모션이 발생했을 때 3. How? • 컴포저블이 위치를 되돌리거나 멈추기
  29. Fling and Back // 1. What? val translation = remember

    { Animatable(Offset(0f, 0f), Offset.VectorConverter) } // 2. When? ComposableKodee( modifier = Modifier .graphicsLayer { translationX = translation.value.x translationY = translation.value.y } .draggable2D(state = draggableState) ... ) // 3. How? val draggableState = rememberDraggable2DState { delta -> coroutineScope.launch { translation.snapTo(translation.value.plus(delta)) } }
  30. Fling and Back // 2. When? ComposableKodee( modifier = Modifier

    .draggable2D( state = draggableState, onDragStopped = { velocity -> doFlingMove(velocity) } ) )
  31. Fling and Back // 3. How? val decay = rememberSplineBasedDecay<Offset>()

    fun doFlingMove(velocity: Velocity) { // 1. Calculate target offset based on velocity val velocityOffset = Offset(velocity.x / 2f, velocity.y / 2f) val targetOffset = decay.calculateTargetValue( typeConverter = Offset.VectorConverter, initialValue = translation.value, initialVelocity = velocityOffset, ) // 2. If the target offset is within bounds, animate to it if (isTargetInBounds(targetOffset, screenSize)) { coroutineScope.launch { translation.animateDecay(velocityOffset, decay) } }
  32. Fling and Back // 3. How? val decay = rememberSplineBasedDecay<Offset>()

    fun doFlingMove(velocity: Velocity) { // 1. Calculate target offset based on velocity val velocityOffset = Offset(velocity.x / 2f, velocity.y / 2f) val targetOffset = decay.calculateTargetValue( typeConverter = Offset.VectorConverter, initialValue = translation.value, initialVelocity = velocityOffset, ) // 2. If the target offset is within bounds, animate to it if (isTargetInBounds(targetOffset, screenSize)) { coroutineScope.launch { translation.animateDecay(velocityOffset, decay) } } Decay Animation Spec
  33. Fling and Back // 3. How? val decay = rememberSplineBasedDecay<Offset>()

    fun doFlingMove(velocity: Velocity) { // 1. Calculate target offset based on velocity val velocityOffset = Offset(velocity.x / 2f, velocity.y / 2f) val targetOffset = decay.calculateTargetValue( typeConverter = Offset.VectorConverter, initialValue = translation.value, initialVelocity = velocityOffset, ) // 2. If the target offset is within bounds, animate to it if (isTargetInBounds(targetOffset, screenSize)) { coroutineScope.launch { translation.animateDecay(velocityOffset, decay) } } Decay Animation Spec DBMDVMBUF5BSHFU7BMVF ৈӝࢲ ݥ୹ԅ
  34. Fling and Back // 3. How? // 2. If the

    target offset is within bounds, animate to it if (isTargetInBounds(targetOffset, screenSize)) { coroutineScope.launch { translation.animateDecay(velocityOffset, decay) } } else { coroutineScope.launch { val adjustedOffset = calculateBoundedOffset(targetOffset, screenSize) // Get farthest Offset translation.animateTo(adjustedOffset) // Animate to farthest point translation.animateTo(Offset(0f, 0f), ..) // Animate back to center } } }
  35. Fling and Back // 3. How? // 2. If the

    target offset is within bounds, animate to it if (isTargetInBounds(targetOffset, screenSize)) { coroutineScope.launch { translation.animateDecay(velocityOffset, decay) } } else { coroutineScope.launch { val adjustedOffset = calculateBoundedOffset(targetOffset, screenSize) // Get farthest Offset translation.animateTo(adjustedOffset) // Animate to farthest point translation.animateTo(Offset(0f, 0f), ..) // Animate back to center } } }
  36. Fling and Back // 3. How? // 2. If the

    target offset is within bounds, animate to it if (isTargetInBounds(targetOffset, screenSize)) { coroutineScope.launch { translation.animateDecay(velocityOffset, decay) } } else { coroutineScope.launch { val adjustedOffset = calculateBoundedOffset(targetOffset, screenSize) translation.animateTo(adjustedOffset) // Animate to farthest point translation.animateTo(Offset(0f, 0f), ..) // Animate back to center } } }
  37. Fling and Back // 3. How? // 2. If the

    target offset is within bounds, animate to it if (isTargetInBounds(targetOffset, screenSize)) { coroutineScope.launch { translation.animateDecay(velocityOffset, decay) } } else { coroutineScope.launch { val adjustedOffset = calculateBoundedOffset(targetOffset, screenSize) translation.animateTo(adjustedOffset) // Animate to farthest point translation.animateTo(Offset(0f, 0f), ..) // Animate back to center } } }
  38. Fling and Back // 3. How? // 2. If the

    target offset is within bounds, animate to it if (isTargetInBounds(targetOffset, screenSize)) { coroutineScope.launch { translation.animateDecay(velocityOffset, decay) } } else { // 3. If not, animate to farthest point within bounds and then animate back to center coroutineScope.launch { val adjustedOffset = calculateBoundedOffset(targetOffset, screenSize) translation.animateTo(adjustedOffset) // Animate to farthest point translation.animateTo(Offset(0f, 0f), ..) // Animate back to center } } }
  39. Fling and Back // 3. How? val decay = rememberSplineBasedDecay<Offset>()

    fun doFlingMove(velocity: Velocity) { // 1. Calculate target offset based on velocity val velocityOffset = Offset(velocity.x / 2f, velocity.y / 2f) val targetOffset = decay.calculateTargetValue( typeConverter = Offset.VectorConverter, initialValue = translation.value, initialVelocity = velocityOffset, ) // 2. If the target offset is within bounds, animate to it if (isTargetInBounds(targetOffset, screenSize)) { coroutineScope.launch { translation.animateDecay(velocityOffset, decay) } } else { // 3. If not, animate to farthest point within bounds and then animate back to center coroutineScope.launch { val adjustedOffset = calculateBoundedOffset(targetOffset, screenSize) translation.animateTo(adjustedOffset) // Animate to farthest point translation.animateTo(Offset(0f, 0f), ..) // Animate back to center } } }
  40. MultiPlatform Sensor Manager interface MultiplatformSensorManager { fun registerListener( sensorType: MultiplatformSensorType,

    onSensorChanged: (MultiplatformSensorEvent) -> Unit, ) fun unregisterAll() } @Composable expect fun rememberSensorManager(): MultiplatformSensorManager
  41. MultiPlatform Sensor Manager // Android @Composable actual fun rememberSensorManager(): MultiplatformSensorManager

    { val context = LocalContext.current return remember(context) { AndroidSensorManager(context) } } // iOS @Composable actual fun rememberSensorManager(): MultiplatformSensorManager { return remember { iOSSensorManager() } } @Composable expect fun rememberSensorManager(): MultiplatformSensorManager
  42. Multiplatform Sensor Manager // Android class AndroidSensorManager(private val context: Context)

    : MultiplatformSensorManager { private val sensorManager by lazy { context.getSystemService(Context.SENSOR_SERVICE) as SensorManager } override fun registerListener(. . .) { } override fun unregisterAll() { } }
  43. Multiplatform Sensor Manager // Android class AndroidSensorManager(private val context: Context)

    : MultiplatformSensorManager { private val sensorManager by lazy { context.getSystemService(Context.SENSOR_SERVICE) as SensorManager } override fun registerListener(...) { sensorManager.getDefaultSensor(sensorType.toSensorType())?.let { sensor -> val sensorEventListener = object : SensorEventListener {...} sensorManager.registerListener(sensorEventListener, sensor) } } override fun unregisterAll() { listeners.forEach { (_, listener) -> sensorManager.unregisterListener(listener) } } }
  44. Multiplatform Sensor Manager // Android class AndroidSensorManager(private val context: Context)

    : MultiplatformSensorManager { private val sensorManager by lazy { context.getSystemService(Context.SENSOR_SERVICE) as SensorManager } override fun registerListener(...) { sensorManager.getDefaultSensor(sensorType.toSensorType())?.let { sensor -> val sensorEventListener = object : SensorEventListener {...} sensorManager.registerListener(sensorEventListener, sensor) } } override fun unregisterAll() { listeners.forEach { (_, listener) -> sensorManager.unregisterListener(listener) } } }
  45. Multiplatform Sensor Manager // iOS class iOSSensorManager : MultiplatformSensorManager {

    override fun registerListener(. . .) { } override fun unregisterAll() { }
  46. Multiplatform Sensor Manager // iOS class iOSSensorManager : MultiplatformSensorManager {

    private val motionManager = CMMotionManager() private val activityManager = CMMotionActivityManager() private val pedometerManager = CMPedometer() override fun registerListener(. . .) { } override fun unregisterAll() { }
  47. Multiplatform Sensor Manager // iOS class iOSSensorManager : MultiplatformSensorManager {

    private val motionManager = CMMotionManager() . . . override fun registerListener(. . .) { when (sensorType) { MultiplatformSensorType.ACCELEROMETER -> startAccelerometerUpdates(onSensorChanged) MultiplatformSensorType.STEP_COUNTER -> startPedometerUpdates(onSensorChanged) MultiplatformSensorType.STEP_DETECTOR -> startStepDetection(onSensorChanged) MultiplatformSensorType.LIGHT -> startLightUpdates(onSensorChanged) } } override fun unregisterAll() { }
  48. Multiplatform Sensor Manager // iOS class iOSSensorManager : MultiplatformSensorManager {

    private val motionManager = CMMotionManager() . . . override fun registerListener(. . .) { when (sensorType) { MultiplatformSensorType.ACCELEROMETER -> startAccelerometerUpdates(onSensorChanged) . . . } } private fun startAccelerometerUpdates(onSensorChanged: (MultiplatformSensorEvent) -> Unit, ) { if (motionManager.isAccelerometerAvailable()) { motionManager.startAccelerometerUpdatesToQueue(. . .) { data, error -> . . . } } }
  49. Multiplatform Sensor Manager class iOSSensorManager : MultiplatformSensorManager { private val

    motionManager = CMMotionManager() // For position related sensors private val activityManager = CMMotionActivityManager() // Detect activity (walking, driving, ...) private val pedometerManager = CMPedometer() // Step counter ... override fun registerListener(...) { when (sensorType) { MultiplatformSensorType.ACCELEROMETER -> startAccelerometerUpdates(onSensorChanged) ... } } override fun unregisterAll() { motionManager.stopDeviceMotionUpdates() motionManager.stopDeviceMotionUpdates() motionManager.stopGyroUpdates() motionManager.stopMagnetometerUpdates() motionManager.stopAccelerometerUpdates() pedometerManager.stopPedometerUpdates() pedometerManager.stopPedometerEventUpdates() activityManager.stopActivityUpdates() ... } }
  50. Rotation Shift 1. What? 회전 시키기 2. When? 디바이스의 방향이

    변경 될 경우 3. How? 회전 Sensor의 value 0. Sensor Data이해하기 Orientation
  51. Orientation - Android - Roll • Y 축 회전 •

    -180°에서 180° - Azimuth • Z 축 회전 • 0°에서 360° - Pitch • X 축 회전 • -90°에서 90°
  52. Orientation ­ iOS Yaw • Z축 회전 • -180º에서 180º

    Pitch • X축 회전 • -180º에서 180º Roll • Y축 회전 • -180º에서 180º
  53. Orientation ­ Android && iOS - Yaw • Z축 회전

    • -180º에서 180º - Pitch • X축 회전 • -180º에서 180º - Roll • Y축 회전 • -180º에서 180º - Azimuth • Z 축 회전 • 0°에서 360° - Pitch • X 축 회전 • -90°에서 90° - Roll • Y 축 회전 • -180°에서 180°
  54. Orientation ­ Shared Code data class DeviceOrientation( val azimuth: Float,

    // Orientation[0] 0 to 360 val pitch: Float, // Orientation[1] -90 to 90 val roll: Float, // Orientation[2] -180 to 180 ) { val azimuthDegrees = azimuth.toDegrees() val pitchDegrees = pitch.toDegrees() val rollDegrees = roll.toDegrees() }
  55. Orientation ­ Shared Code data class DeviceOrientation() // Main interface

    MultiplatformSensorManager { fun registerListener( sensorType: MultiplatformSensorType, onSensorChanged: (MultiplatformSensorEvent) -> Unit ) fun observeOrientationChanges( onOrientationChanged: (DeviceOrientation) -> Unit ) fun unregisterAll() }
  56. Rotation Shift // 1. What? val rotationX = remember {

    Animatable(0f) } val rotationY = remember { Animatable(0f) } val rotationZ = remember { Animatable(0f) } // 2. When? ComposableKodee( modifier = Modifier .graphicsLayer { this.rotationX = rotationX.value this.rotationY = rotationY.value this.rotationZ = rotationZ.value } ... ) // 3. How?
  57. Rotation Shift // 3. How? val sensorManager: MultiplatformSensorManager = rememberSensorManager()

    sensorManager.observeOrientationChanges { orientation -> coroutineScope.launch { rotationX.snapTo() } coroutineScope.launch { rotationY.snapTo() } coroutineScope.launch { rotationZ.snapTo() } }
  58. Rotation Shift // 3. How? val sensorManager: MultiplatformSensorManager = rememberSensorManager()

    sensorManager.observeOrientationChanges { orientation -> coroutineScope.launch { rotationX.snapTo() } coroutineScope.launch { rotationY.snapTo() } coroutineScope.launch { rotationZ.snapTo() } }
  59. Rotation Shift // 3. How? val sensorManager: MultiplatformSensorManager = rememberSensorManager()

    sensorManager.observeOrientationChanges { orientation -> coroutineScope.launch { rotationX.snapTo() } coroutineScope.launch { rotationY.snapTo() } coroutineScope.launch { rotationZ.snapTo() } }
  60. Rotation Shift // 3. How? val sensorManager: MultiplatformSensorManager = rememberSensorManager()

    sensorManager.observeOrientationChanges { orientation -> coroutineScope.launch { rotationX.snapTo() } coroutineScope.launch { rotationY.snapTo() } coroutineScope.launch { rotationZ.snapTo() } }
  61. Rotation Shift // 1. What? val rotationX = remember {

    Animatable(0f) } val rotationY = remember { Animatable(0f) } val rotationZ = remember { Animatable(0f) } // 2. When? ComposableKodee( modifier = Modifier .graphicsLayer { this.rotationX = rotationX.value this.rotationY = rotationY.value this.rotationZ = rotationZ.value } ... ) // 3. How? sensorManager.observeOrientationChanges { orientation -> coroutineScope.launch { rotation.snapTo(orientation.degrees) } ... }
  62. Rotation Shift // 1. What? val rotationX = remember {

    Animatable(0f) } val rotationY = remember { Animatable(0f) } val rotationZ = remember { Animatable(0f) } // 2. When? ComposableKodee( modifier = Modifier .graphicsLayer { this.rotationX = rotationX.value this.rotationY = rotationY.value this.rotationZ = rotationZ.value } ... ) // 3. How? sensorManager.observeOrientationChanges { orientation -> coroutineScope.launch { rotation.snapTo(orientation.degrees) } ... }
  63. 더 많은 것을 배우고 싶다면? • Play around with the

    code! https://github.com/nicole-terc/sheepit-sensors- multiplatform • Rebecca Franks - Practical magic with animations in Jetpack Compose https://www.youtube.com/watch?v=HNSKJIQtb4c • Compose Animation Documentation https://developer.android.com/develop/ui/compose/animat ion/introduction • Android Sensor Documentation https://developer.android.com/develop/sensors-and- location/sensors/sensors_overview • iOS Sensor Documentation https://developer.apple.com/documentation/coremotion https://developer.apple.com/documentation/sensorkit https://github.com/nicole-terc/sheepit-sensors-multiplatform