Fix: BottomSheet Detent Update Crash In Compose
This article addresses a critical issue encountered when updating detents in a BottomSheet implementation using Compose. Specifically, the application crashes when the list of detents is updated with a list of the same length, but containing different detent values. Let’s dive into the details, understand the root cause, and explore a potential solution.
Understanding the Issue
The core problem lies in how the ObjectFloatMap handles detent updates. When a new list of detents is provided, the equals method in ObjectFloatMap is triggered. If any of the detents present in the current list are missing from the new list, a java.util.NoSuchElementException is thrown. This occurs because the get operator is used, which expects all keys to be present. A safer approach would be to use getOrNull, which gracefully handles missing keys.
The crash is triggered when you attempt to update the detents of a BottomSheetState with a new list that has the same size as the previous list but contains a detent that wasn't in the original list. This can happen even if the overall number of detents remains the same. Let's look at an example.
Consider a scenario where you initially have two detents: Header and Middle. If you update this list to Header and Tall, and if the internal logic of the BottomSheet attempts to access the value associated with Middle after the update, but it is no longer present, the application will crash. This is because the system is trying to find a detent that no longer exists in the updated list.
The ObjectFloatMap is used internally to manage the state and calculations related to the detents. When the detents are updated, this map needs to be updated accordingly. However, the current implementation doesn't handle the case where detents are removed from the list, leading to the NoSuchElementException.
To avoid this issue, it's crucial to ensure that the detent update process is more robust. Instead of directly accessing the map with the get operator, which assumes the key exists, a safer approach is to use getOrNull. This method returns null if the key is not found, allowing the code to handle the missing detent gracefully without crashing the application.
Affected Platforms
This bug has been confirmed to reproduce on Android. While not explicitly tested, it’s plausible that similar behavior could occur on other platforms that share the same underlying Compose Unstyled code.
Reproducing the Bug
To reproduce this issue, you can use the following code snippet:
object NavigationSheetDetents {
val Header = SheetDetent(identifier = "Header") { _, _ ->
150.dp
}
val Tall =
SheetDetent(identifier = "Tall") { _, _ ->
1000.dp
}
val Middle =
SheetDetent(identifier = "Middle") { containerHeight, sheetHeight ->
500.dp
}
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
TestUpdateDestinationTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Greeting(
name = "Test App Update was successful. Yay!",
modifier = Modifier.padding(innerPadding)
)
val sheetState = rememberBottomSheetState(
initialDetent = NavigationSheetDetents.Header,
detents = listOf(NavigationSheetDetents.Header, NavigationSheetDetents.Middle)
)
BottomSheet(
state = sheetState,
modifier = Modifier
.shadow(4.dp, RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp))
.clip(RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp))
.background(Color.White)
.widthIn(max = 640.dp)
.fillMaxWidth()
.imePadding(),
) {
Column (
modifier = Modifier.fillMaxWidth().height(1200.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
DragIndication(
modifier = Modifier
.padding(top = 22.dp)
.background(Color.Black.copy(0.4f), RoundedCornerShape(100))
.width(32.dp)
.height(4.dp)
)
Text("Current state: ${sheetState.currentDetent}")
val coroutineScope = rememberCoroutineScope()
Button(onClick = {
coroutineScope.launch {
sheetState.detents = listOf(NavigationSheetDetents.Header, NavigationSheetDetents.Tall)
}
}) {
Text("Crash it")
}
}
}
}
}
}
}
}
In this example, the BottomSheet is initialized with two detents: Header and Middle. When the "Crash it" button is pressed, the detents are updated to Header and Tall. This update causes the application to crash because the Middle detent is no longer present in the new list, and the ObjectFloatMap attempts to access it.
Key Steps to Reproduce:
- Initialize a
BottomSheetStatewith a list of detents (e.g.,HeaderandMiddle). - Update the
sheetState.detentswith a new list of the same size, but containing different detents (e.g.,HeaderandTall). - Observe the application crash with a
java.util.NoSuchElementException.
Technical Details
The issue arises in the ObjectFloatMap equals method, specifically due to the use of the throwing get operator. This operator assumes that all keys are present in the map, which is not always the case when detents are updated. Instead, using a getOrNull type method would provide a safer way to handle missing keys and prevent the crash.
Version Information
- Compose Unstyled version: 1.48.3
- Compose BOM version: 2024.09.00
Proposed Solution
To resolve this issue, the equals method in ObjectFloatMap should be modified to use getOrNull instead of get. This would allow the code to handle cases where a detent is no longer present in the map, preventing the NoSuchElementException from being thrown.
Here’s a conceptual example of how the fix might look (note: this is a simplification and the actual implementation might vary):
//Original code (simplified)
fun equals(other: ObjectFloatMap): Boolean {
for ((key, value) in this) {
if (other[key] != value) { // This line causes the crash
return false
}
}
return true
}
//Fixed code (simplified)
fun equals(other: ObjectFloatMap): Boolean {
for ((key, value) in this) {
if (other.getOrNull(key) != value) { // Using getOrNull
return false
}
}
return true
}
By using getOrNull, the code checks if the key exists in the other map before attempting to access its value. If the key is not found, getOrNull returns null, which can be handled without causing a crash.
Practical Advice and Best Practices
When working with BottomSheet detents in Compose, consider the following best practices to avoid potential issues:
- Ensure Detent Consistency: When updating detents, ensure that the new list contains all the necessary detents or handles the absence of detents gracefully.
- Use Safe Access: Employ methods like
getOrNullwhen accessing map values to avoid unexpected exceptions. - Thorough Testing: Always test detent updates thoroughly, especially when dealing with dynamic or conditional detent lists.
- Defensive Programming: Implement defensive programming techniques to handle potential edge cases and prevent crashes.
By following these guidelines, you can create more robust and reliable BottomSheet implementations in your Compose applications.
Conclusion
Updating detents in a BottomSheet requires careful handling to avoid crashes. The issue described in this article highlights the importance of using safe access methods like getOrNull when working with maps and ensuring that detent updates are handled gracefully. By understanding the root cause and implementing the proposed solution, you can prevent unexpected crashes and create a smoother user experience. Always remember to test your detent updates thoroughly and follow best practices for robust and reliable Compose applications.
For further information on Compose and BottomSheet implementations, refer to the official Android Developers Documentation.