In this guide, we will create an Image Spirit Level View in Jetpack Compose using the accelerometer sensor. This feature simulates the behavior of a spirit level (a tool used to measure the tilt of a surface) and provides visual feedback for device orientation through dynamic animations.
Let’s break the code step by step for better understanding.
Step 1: Setting Up the Composable
The SpiritLevelView
composable is the main entry point where the spirit level UI and logic are defined.
@Composable
fun SpiritLevelView() {
val context = LocalContext.current
val sensorManager = context.getSystemService(SENSOR_SERVICE) as SensorManager
val accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
var xTilt by remember { mutableFloatStateOf(0f) }
var yTilt by remember { mutableFloatStateOf(0f) }
}
Explanation:
- SensorManager:
- Access the device’s
SensorManager
to interact with hardware sensors. - Retrieve the accelerometer sensor, which provides real-time tilt information.
- Access the device’s
- Mutable States:
xTilt
andyTilt
are used to store the tilt values on the horizontal and vertical axes, respectively. These values are dynamically updated as the device moves.
Step 2: Handling Sensor Events
We use a SensorEventListener
to capture accelerometer data and update the tilt values.
val sensorEventListener = object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent?) {
event?.let {
xTilt = -event.values[0]
yTilt = -event.values[1]
}
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
}
Explanation:
onSensorChanged
:- Called whenever the sensor detects a change in tilt.
- The accelerometer provides values in the
event.values
array:event.values[0]
: Horizontal tilt (X-axis).event.values[1]
: Vertical tilt (Y-axis).
- The tilts are reversed (
-event.values
) to align with the physical directions.
Step 3: Registering and Unregistering the Listener
To ensure efficient resource management, the listener is registered when the composable is created and unregistered when it’s disposed.
LaunchedEffect(Unit) {
sensorManager.registerListener(
sensorEventListener,
accelerometer,
SensorManager.SENSOR_DELAY_GAME
)
}
DisposableEffect(Unit) {
onDispose {
sensorManager.unregisterListener(sensorEventListener)
}
}
Explanation:
LaunchedEffect
:- Registers the accelerometer listener when the composable is first launched.
- Uses
SENSOR_DELAY_GAME
for a faster response to sensor data, ideal for real-time animations.
DisposableEffect
:- Ensures the listener is unregistered when the composable is removed from the UI, preventing memory leaks.
Step 4: Designing the Layout
The layout consists of two spirit levels: one vertical and one horizontal. Each level is implemented using the SpiritLevelBar
composable.
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.White),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxSize()
.padding(32.dp)
) {
SpiritLevelBar(
modifier = Modifier
.weight(1f)
.width(50.dp),
tilt = yTilt,
isVertical = true
)
Spacer(modifier = Modifier.height(32.dp))
SpiritLevelBar(
modifier = Modifier
.fillMaxWidth()
.height(50.dp),
tilt = xTilt,
isVertical = false
)
}
}
Explanation:
- Outer
Box
:- Centers the entire UI within the screen.
- Uses
Column
to stack the vertical and horizontal bars with spacing in between.
SpiritLevelBar
:- Handles the tilt visualization for each bar.
- Accepts parameters like
tilt
(tilt value) andisVertical
(orientation).
Step 5: Creating the Spirit Level Bar
The SpiritLevelBar
composable is responsible for:
- Drawing the rectangular spirit level bar.
- Animating a ball inside the bar to indicate the tilt.
@Composable
fun SpiritLevelBar(modifier: Modifier, tilt: Float, isVertical: Boolean) {
val ballSize = 40.dp
val maxTilt = 4.5f // Maximum tilt angle.
val offsetAnim = remember { Animatable(0f) }
BoxWithConstraints(
modifier = modifier
.background(Color.LightGray.copy(0.4f), RoundedCornerShape(20.dp))
.padding(2.dp)
) {
val barSize = if (isVertical) maxHeight.value else maxWidth.value
val maxOffset = barSize / 2f - ballSize.value / 2f
val targetOffset = (tilt / maxTilt).coerceIn(-1f, 1f) * maxOffset
LaunchedEffect(targetOffset) {
offsetAnim.animateTo(
targetValue = targetOffset,
animationSpec = tween(durationMillis = 100)
)
}
Canvas(
modifier = Modifier
.align(Alignment.Center)
.size(ballSize)
.offset(
x = if (!isVertical) offsetAnim.value.dp else 0.dp,
y = if (isVertical) -offsetAnim.value.dp else 0.dp
)
) {
val ballColor = if ((tilt * 10).toInt() == 0) Color.Green else Color.Red
drawCircle(color = ballColor)
}
}
Text(
text = "${(tilt * 10).toInt()}°",
color = Color.Green,
fontSize = 25.sp
)
}
Explanation:
- Ball Animation:
- Uses
Animatable
to animate the ball’s position smoothly. - The offset is calculated based on the tilt and constrained within the bar size.
- Uses
- Canvas:
- Draws the ball inside the spirit level bar.
- The ball’s color changes to green when the tilt is zero (balanced).
- Tilt Angle:
- Displays the current tilt angle (in degrees) near the bar for user feedback.
Final Output
- A vertical spirit level indicates the tilt along the Y-axis.
- A horizontal spirit level indicates the tilt along the X-axis.
- Both bars are smooth, responsive, and visually appealing.
Enhancements to Try
- Add sound or vibration feedback when the device is level.
- Include visual alerts for extreme tilt angles.
- Allow users to calibrate the spirit level manually.
With this setup, you have a fully functional and interactive spirit level in Jetpack Compose!
You can find the full source code for this image cropper here on GitHub Gist.