Blog

Jetpack Compose Modifiers: Unleashing Hidden Potential

On Wednesday, Nov 27, 2024
post image

As Seen In - jetc.dev Newsletter Issue #243

Jetpack Compose turns upside-down Android UI development with its modern, declarative slant. Amid its many powerful features, modifiers emerge as a crucial tool for developers. They allow the customization and extension of composable functions.

Modifiers are not just limited to pre-established functionalities; they can be stretched out and customized further. This flexibility is a game-changer for Android developers, allowing for unprecedented UI manipulation and behavior customization.

Building upon our previous exploration of basic Jetpack Compose modifiers, we’ll explore the advanced modifiers that elevate Jetpack Compose from a simple UI toolkit to a sophisticated development framework. These modifiers go beyond basic styling, offering developers powerful control and flexibility in creating dynamic, responsive interfaces.

Advanced Use Case

Let’s start with the following use case: We need to build a photo grid layout, displaying the thumbnail, allowing the selection of multiple photos after a long press, or showing the photo details by just tapping on the photo item.

advanced-use-case-demonstration

We need to define the desired behavior for photo interactions:

  • A single tap should open the photo details dialog.
  • A long press should modify the photo’s display, adding padding and a radio button for selection.

The critical problem is managing these interactions effectively. Since both actions rely on similar input methods (single press and long press) using a single modifier, they risk conflicting with each other. This means one action could potentially prevent or interfere with the other, creating a user experience challenge that requires a carefully designed solution.

The key is to implement a mechanism that can distinguish between these interaction types without mutual interference, ensuring both functionalities can coexist smoothly.

Understanding the Interaction

Our first objective is to implement a long press mechanism that toggles selection mode, enabling custom actions like deleting or sharing. We’ll achieve this by extending the pointerInput modifier to create a specialized interaction handler tailored to our specific use case.


fun Modifier.multiSelectGridHandler(
lazyGridState: LazyGridState,
haptics: HapticFeedback,
selectedIds: MutableState<Set<Long>>,
autoScrollSpeed: MutableState<Float>,
autoScrollThreshold: Float
) = pointerInput(Unit) {

    fun LazyGridState.gridItemKeyAtPosition(hitPoint: Offset): Long? =
        layoutInfo.visibleItemsInfo.find { itemInfo ->
            itemInfo
                .size
                .toIntRect()
                .contains(hitPoint.round() - itemInfo.offset)
        }?.key as? Long

    var initialKey: Long? = null
    var currentKey: Long? = null
    detectDragGesturesAfterLongPress(
        onDragStart = { offset ->
            lazyGridState.gridItemKeyAtPosition(offset)?.let { key ->
                if (!selectedIds.value.contains(key)) {
                    haptics.performHapticFeedback(HapticFeedbackType.LongPress)
                    initialKey = key
                    currentKey = key
                    selectedIds.value += key
                }
            }
        },
        onDragCancel = { initialKey = null; autoScrollSpeed.value = 0f },
        onDragEnd = { initialKey = null; autoScrollSpeed.value = 0f },
        onDrag = { change, _ ->
            if (initialKey != null) {
                val distFromBottom =
                    lazyGridState.layoutInfo.viewportSize.height - change.position.y
                val distFromTop = change.position.y

                autoScrollSpeed.value = when {
                    distFromBottom < autoScrollThreshold
                        -> autoScrollThreshold - distFromBottom
                    distFromTop < autoScrollThreshold
                        -> -(autoScrollThreshold - distFromTop)
                    else -> 0f
                }

                lazyGridState.gridItemKeyAtPosition(change.position)?.
                    let { key ->
                        if (currentKey != key) {
                            selectedIds.value = selectedIds.value
                                .minus(initialKey!!..currentKey!!)
                                .minus(currentKey!!..initialKey!!)
                                .plus(initialKey!!..key)
                                .plus(key..initialKey!!)
                            currentKey = key
                        }
                }
            }
        }
    )

}

This custom modifier is specifically designed for a LazyVerticalGrid layout. Consequently, it leverages the unique properties of LazyGridState and other grid-specific parameters to enable precise item selection and interaction within the grid’s context.

This extension function multiSelectGridHandler enhances the pointerInput modifier for a lazy grid with multi-select drag functionality. Key features include:

  1. Grid Item Detection:

    • gridItemKeyAtPosition finds the grid item at a specific touch point.
    • Uses item’s layout information to determine precise location.
  2. Long Press Selection:

    • Starts selection on long press.
    • Triggers haptic feedback.
    • Adds first selected item to selectedIds.
  3. Drag Selection:

    • Allows selecting multiple items by dragging.
    • Dynamically updates selectedIds set.
    • Supports range selection between initial and current items.
  4. Auto-Scrolling:

    • Calculates scroll speed based on proximity to grid edges.
    • Enables smooth scrolling during selection.
    • Adjusts autoScrollSpeed dynamically.

The function provides a sophisticated, custom interaction handler for grid-based selection scenarios.

Managing Selection Mode Interactions

Differentiating between selection and non-selection modes is crucial for implementing context-specific actions. The then() modifier provides an elegant solution for conditionally applying interaction behaviors. By using then(), we can dynamically adjust the modifier stack based on the current selection state, enabling precise control over user interactions. This approach allows us to:

  • Toggle selection-specific behaviors
  • Apply different interaction logic conditionally
  • Maintain clean, flexible UI interaction patterns

The implementation will leverage the then() modifier to switch between standard and selection-mode interactions seamlessly.

Let’s bridge the connection between the multiSelectGridHandler extension function and the then() modifier implementation.

Our custom multiSelectGridHandler extension function provides advanced multi-select drag capabilities for a LazyVerticalGrid. The then() modifier complements this by dynamically applying selection behavior based on the current interaction mode.

Implementation Highlight:


@Composable
fun PhotosGrid(
photos: List<Photo> = emptyList(),
selectedIds: MutableState<Set<Long>> = rememberSaveable { mutableStateOf(emptySet()) },
onPhotoClicked: (Photo) -> Unit = {}
) {
val inSelectionMode by remember {
derivedStateOf { selectedIds.value.isNotEmpty() }
}
val lazyGridState = rememberLazyGridState()
val autoScrollSpeed = remember { mutableFloatStateOf(0f) }

    LaunchedEffect(autoScrollSpeed.floatValue) {
        if (autoScrollSpeed.floatValue != 0f) {
            while (isActive) {
                lazyGridState.scrollBy(autoScrollSpeed.floatValue)
                delay(10)
            }
        }
    }

    LazyVerticalGrid(
        state = lazyGridState,
        columns = GridCells.Adaptive(minSize = 128.dp),
        verticalArrangement = Arrangement.spacedBy(3.dp),
        horizontalArrangement = Arrangement.spacedBy(3.dp),
        modifier = Modifier
            .photoGridDragHandler( // Extension function Implementation
                lazyGridState = lazyGridState,
                haptics = LocalHapticFeedback.current,
                selectedIds = selectedIds,
                autoScrollSpeed = autoScrollSpeed,
                autoScrollThreshold = with(
                    LocalDensity.current
                ) { 40.dp.toPx() }
            )
    ) {
        items(
            items = photos,
            key = { it.id },
        ) { photo ->

            val selected by remember {
                derivedStateOf { selectedIds.value.contains(photo.id) }
            }

            // Custom composable to display the photo item on the grid layout.
            ImageItem(
                photo = photo,
                inSelectionMode = inSelectionMode,
                selected = selected,
                modifier = Modifier
                    .semantics {
                        if (!inSelectionMode) {
                            onLongClick("Select") {
                                selectedIds.value += photo.id
                                true
                            }
                        }
                    }
                    .clickable(onClick = { onPhotoClicked(photo) })
                    .then(
                        if (inSelectionMode) {
                            Modifier
                                .toggleable(
                                    value = selected,
                                    interactionSource = remember {
                                        MutableInteractionSource()
                                    },
                                    indication = null,
                                    onValueChange = {
                                        if (it) {
                                            selectedIds.value += photo.id
                                        } else {
                                            selectedIds.value -= photo.id
                                        }
                                    }
                                )
                        } else Modifier
                    )
            )
        }
    }

}

Performance and Optimization Considerations

While advanced modifiers offer vast flexibility, developers must consider potential performance implications.

Custom modifier implementations like multiSelectGridHandler involve complex gesture detection and state management, which can introduce computational overhead. It’s crucial to profile and optimize these interactions, especially in grid layouts with large numbers of items. Techniques such as using remember and derivedStateOf help minimize unnecessary recompositions, while carefully designed gesture handlers can reduce the computational complexity of interaction detection.

Moreover, leveraging Compose’s built-in state management and composition model ensures that these advanced interaction patterns remain efficient and responsive, even in complex UI scenarios. The key is to balance rich interactivity and computational efficiency, using modifiers as a precise instrument for crafting sophisticated user experiences.

Key Insights

The power of Jetpack Compose’s modifiers extends far beyond simple styling. As demonstrated in our previous example, modifiers are a versatile tool that enables developers to create complex, interactive user interfaces with remarkable flexibility.

By leveraging advanced techniques like then() and custom extension functions, developers can craft nicety interaction patterns that respond intelligently to user gestures.

The ability to dynamically apply and combine modifiers allows for creating responsive, context-aware UI components that can seamlessly transition between different interaction modes. This approach not only enhances user experience but also promotes cleaner, and and more maintainable code by separating interaction logic and UI rendering

References

Share this post: