Last active
June 28, 2024 21:14
-
-
Save surajsau/b178e2646a1240f883774811e15bbb6a to your computer and use it in GitHub Desktop.
Parallax effect with Jetpack Compose
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@Composable | |
fun ParallaxScreen(modifier: Modifier = Modifier) { | |
val context = LocalContext.current | |
val scope = rememberCoroutineScope() | |
var data by remember { mutableStateOf<SensorData?>(null) } | |
DisposableEffect(Unit) { | |
val dataManager = SensorDataManager(context) | |
dataManager.init() | |
val job = scope.launch { | |
dataManager.data | |
.receiveAsFlow() | |
.onEach { data = it } | |
.collect() | |
} | |
onDispose { | |
dataManager.cancel() | |
job.cancel() | |
} | |
} | |
Box(modifier = modifier) { | |
ParallaxView( | |
modifier = Modifier | |
.fillMaxWidth() | |
.align(Alignment.Center), | |
multiPlier = 20, | |
data = data | |
) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// For changing offset values, it is always preferred to use .offset { } instead of .offset() | |
// as offset {..} is implemented to avoid recomposition during the offset changes | |
@Composable | |
fun ParallaxView( | |
modifier: Modifier = Modifier, | |
depthMultiplier: Int = 20, | |
data: SensorData? | |
) { | |
val roll by derivedStateOf { (data?.roll ?: 0f) * depthMultiplier } | |
val pitch by derivedStateOf { (data?.pitch ?: 0f) * depthMultiplier } | |
Box(modifier = modifier) { | |
// Glow Shadow | |
// Has quicker offset change and in opposite direction to the Image Card | |
Image( | |
painter = painterResource(id = drawable.beach), | |
modifier = Modifier | |
.offset { | |
IntOffset( | |
x = -(roll * 1.5).dp.roundToPx(), | |
y = (pitch * 2).dp.roundToPx() | |
) | |
} | |
.width(256.dp) | |
.height(356.dp) | |
.align(Alignment.Center) | |
.blur(radius = 24.dp, edgeTreatment = BlurredEdgeTreatment.Unbounded), | |
contentDescription = null, | |
contentScale = ContentScale.FillHeight, | |
) | |
// Edge (used to give depth to card when tilted) | |
// Has slightly slower offset change than Image Card | |
Box( | |
modifier = Modifier | |
.offset { | |
IntOffset( | |
x = (roll * 0.9).dp.roundToPx(), | |
y = -(pitch * 0.9).dp.roundToPx() | |
) | |
} | |
.width(300.dp) | |
.height(400.dp) | |
.align(Alignment.Center) | |
.background( | |
color = Color.White.copy(alpha = 0.3f), | |
shape = RoundedCornerShape(16.dp) | |
), | |
) | |
// Image Card | |
// The image inside has a parallax shift in the opposite direction | |
Image( | |
painter = painterResource(id = drawable.beach), | |
modifier = Modifier | |
.offset { | |
IntOffset( | |
x = roll.dp.roundToPx(), | |
y = -pitch.dp.roundToPx() | |
) | |
} | |
.width(300.dp) | |
.height(400.dp) | |
.align(Alignment.Center) | |
.clip(RoundedCornerShape(16.dp)), | |
contentDescription = null, | |
contentScale = ContentScale.FillHeight, | |
alignment = BiasAlignment( | |
horizontalBias = (roll * 0.005).toFloat(), | |
verticalBias = 0f, | |
) | |
) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class SensorDataManager (context: Context): SensorEventListener { | |
private val sensorManager by lazy { | |
context.getSystemService(Context.SENSOR_SERVICE) as SensorManager | |
} | |
fun init() { | |
Log.d("SensorDataManager", "init") | |
val accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_GRAVITY) | |
val magnetometer = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD) | |
sensorManager.registerListener(this, accelerometer, SensorManager.SENSOR_DELAY_UI) | |
sensorManager.registerListener(this, magnetometer, SensorManager.SENSOR_DELAY_UI) | |
} | |
private var gravity: FloatArray? = null | |
private var geomagnetic: FloatArray? = null | |
val data: Channel<SensorData> = Channel(Channel.UNLIMITED) | |
override fun onSensorChanged(event: SensorEvent?) { | |
if (event?.sensor?.type == Sensor.TYPE_GRAVITY) | |
gravity = event.values | |
if (event?.sensor?.type == Sensor.TYPE_MAGNETIC_FIELD) | |
geomagnetic = event.values | |
if (gravity != null && geomagnetic != null) { | |
var r = FloatArray(9) | |
var i = FloatArray(9) | |
if (SensorManager.getRotationMatrix(r, i, gravity, geomagnetic)) { | |
var orientation = FloatArray(3) | |
SensorManager.getOrientation(r, orientation) | |
data.trySend( | |
SensorData( | |
roll = orientation[2], | |
pitch = orientation[1] | |
) | |
) | |
} | |
} | |
} | |
fun cancel() { | |
Log.d("SensorDataManager", "cancel") | |
sensorManager.unregisterListener(this) | |
} | |
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {} | |
} | |
data class SensorData( | |
val roll: Float, | |
val pitch: Float | |
) |
@thebehera Can you give an example set of values where it'd happen? Is there a way to avoid this? Maybe using Sensor.TYPE_ROTATION_VECTOR
along with SensorManager.getQuaternionFromVector
, and some matching logic?
@LouisCAD check out an article I wrote which should cover your questions. https://medium.com/@rahulbehera/sensor-based-parallax-animations-for-android-and-ios-5363f38e37ec
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
SensorManager.getOrientation
can run into gimbal lock issues, so there must be some kind of limit to this before it fails