Skip to content

compose

2 posts with the tag “compose”

Negative Padding in Compose

Skip’s open-source SkipUI library implements the SwiftUI API for Android. To do so, SkipUI leverages Compose, Android’s own modern, declarative UI framework.

The parallels between SwiftUI and Compose are striking, especially when it comes to layout. SwiftUI uses HStack, VStack, and ZStack for basic layout, with modifiers like offset and padding to shift or pad the resulting placement. Compose uses Row, Column, and Box for basic layout, and it too has offset and padding modifiers. Compose has one odd omission, however: it doesn’t support negative padding! Supplying a negative value will throw an IllegalArgumentException.

SkipUI must support whatever SwiftUI supports, so internally we’ve replaced the standard Compose padding modifier with our own custom layout that works for both positive and negative values. We present that layout below, in case you find it useful in your own Compose work.

Notes:

  1. SkipUI’s implementation is tied to SwiftUI internals, so this is an untested and simplified port of the actual code.
  2. Important: If you plan to use this in pure Compose code, expose it as a custom modifier for a more fluent API.
@Composable fun PaddingLayout(modifier: Modifier, top: Dp = 0.dp, leading: Dp = 0.dp, bottom: Dp = 0.dp, trailing: Dp = 0.dp, content: @Composable () -> Unit) {
val density = LocalDensity.current
val topPx = with(density) { top.roundToPx() }
val bottomPx = with(density) { bottom.roundToPx() }
val leadingPx = with(density) { leading.roundToPx() }
val trailingPx = with(density) { trailing.roundToPx() }
Layout(modifier = modifier, content = {
// Compose content
content()
}) { measurables, constraints ->
if (measurables.isEmpty()) {
return layout(width: 0, height: 0) {}
}
// Subtract the padding from the available size the content can use
val updatedConstraints = constraints.copy(
minWidth = constraint(constraints.minWidth, subtracting = leadingPx + trailingPx),
minHeight = constraint(constraints.minHeight, subtracting = topPx + bottomPx),
maxWidth = constraint(constraints.maxWidth, subtracting = leadingPx + trailingPx),
maxHeight = constraint(constraints.maxHeight, subtracting = topPx + bottomPx)
)
val contentPlaceables = measurables.map { it.measure(updatedConstraints) }
// Layout within the padded size
layout(width = contentPlaceables[0].width + leadingPx + trailingPx, height = contentPlaceables[0].height + topPx + bottomPx) {
// Offset the content position by the leading and top padding
for (contentPlaceable in contentPlaceables) {
contentPlaceable.placeRelative(x = leadingPx, y = topPx)
}
}
}
}
// Subtract the given amount from a size constraint value, honoring Int.MAX_VALUE and preventing illegal negative constraints.
private fun constraint(value: Int, subtracting: Int): Int {
if (value == Int.MAX_VALUE) {
return value
}
return max(0, value - subtracting)
}

We hope that you find this useful! If you have questions or suggestions for improvements, please reach out to us on Mastodon @skiptools@mas.to, via chat skiptools.slack.com, or in our discussion forums.

Nested Dropdown Menus in Compose

Skip’s open-source SkipUI library implements the SwiftUI API for Android. To do so, SkipUI leverages Compose, Android’s own modern, declarative UI framework.

While implementing SwiftUI’s Menu, we discovered that Compose doesn’t build in support for nested dropdown menus. Googling revealed that we weren’t the only devs wondering how to present a sub-menu from a Compose menu item, but the answers we found didn’t meet our needs. The code below represents our own simple, general solution to nested dropdown menus in Compose.

Note: SkipUI’s implementation is tied to SwiftUI internals, so this is an untested and simplified port of the actual code.

// Simple menu model. You could expand this for icons, section
// headings, dividers, etc
class MenuModel(title: String, val items: List<MenuItem>): MenuItem(title, {})
open class MenuItem(val title: String, val action: () -> Unit)
// Display a menu model, whose items can include nested menu models
@Composable fun DropdownMenu(menu: MenuModel) {
val isMenuExpanded = remember { mutableStateOf(false) }
val nestedMenu = remember { mutableStateOf<MenuModel?>(null) }
val coroutineScope = rememberCoroutineScope()
// Action to replace the currently-displayed menu with the
// given one on item selection. The hardcoded delays are
// unfortunate but improve the user experience
val replaceMenu: (MenuModel?) -> Unit = { menu ->
coroutineScope.launch {
// Allow selection animation before dismiss
delay(200)
isMenuExpanded.value = false
// Prevent flash of primary menu on nested dismiss
delay(100)
nestedMenu.value = null
if (menu != null) {
nestedMenu.value = menu
isMenuExpanded.value = true
}
}
}
DropdownMenu(expanded = isMenuExpanded.value, onDismissRequest = {
isMenuExpanded.value = false
coroutineScope.launch {
// Prevent flash of primary menu on nested dismiss
delay(100)
nestedMenu.value = null
}
}) {
for (item in menu.items) {
DropdownMenuItem(text = { Text(item.title) }, onClick = {
item.action()
replaceMenu(item as? MenuModel)
})
}
}
}

You can see this in action in the Skip Showcase app’s menu playground:

Nested dropdown menus in the Skip Showcase app

We hope that you find this useful! If you have questions or suggestions for improvements, please reach out to us on Mastodon @skiptools@mas.to, via chat skiptools.slack.com, or in our discussion forums.