What if a layout could break away from the traditional linear structure and present content in a more dynamic, circular form? Circular layouts offer a visually engaging way to display elements, especially when creating interactive menus or dashboards.
This article builds on the foundational work found in the JetpackLayouts repository. Inspired by the possibilities presented in this library, some enhancements were made to extend its capabilities. These enhancements are now part of a pull request and will be demonstrated here, showcasing how circular layouts can be further refined and utilized in Jetpack Compose.
Creating intuitive and visually appealing user interfaces often requires moving beyond the standard layouts provided by a framework. In Jetpack Compose, layouts are central to how UI components are organized and displayed on the screen. However, the true power of layouts in Compose comes from understanding how to control the measurement and placement of these components, giving developers the flexibility to create custom and complex UI designs like circular presentations.
Overview of Jetpack Compose Layouts
Layouts in Jetpack Compose serve as containers that define how their child elements are measured and placed. Unlike the traditional Android View system, where layouts are pre-defined and somewhat rigid, Compose allows developers to determine their layouts using composables. This approach offers a level of customization that can significantly enhance the user experience.
At the core of this system is the Layout
composable, which allows developers to define how a group of composables should be arranged. The Layout
function accepts a measurement block and a placement block, giving complete control over how children are sized and positioned. This is where the concept of measurement policies comes into play.
The Role of Measurement Policies
Measurement policies dictate how a layout measures its children and how it decides on its size. This process is crucial because it determines how the UI will look and how it will behave on different screen sizes and orientations.
In Jetpack Compose, measurement policies are defined in the measurement block of the Layout
composable. This block is responsible for:
- Measuring the Children: Each child’s composability is measured according to the constraints provided by the parent. The measurement block returns a
Placeable
object, which holds the measured width and height of the child. - Determining the Size of the Layout: The parent layout decides its size based on the measurements of its children. It can size itself according to the largest child, the smallest child, or a combination of all children.
- Placing the Children: After the children are measured and the size of the layout is determined, the placement block positions the children within the layout.
The flexibility of measurement policies allows developers to create non-standard layouts, such as arranging items in a circle. By customizing the measurement block, developers can precisely control how elements are laid out in relation to each other, which is essential for creating a circular layout.
Circular Layout Inspiration
The inspiration for creating a circular layout often comes from the need to present content uniquely and engagingly. Circular layouts can be handy when items are equidistant from a central point, such as in a menu or a dashboard. This type of layout provides a natural flow, guiding the user’s attention around the screen in a manner that feels intuitive and balanced.
By leveraging the power of measurement policies, a circular layout can be created to place elements evenly along a circular path. This involves calculating the appropriate angles and distances from the center, ensuring each item is correctly positioned within the layout’s boundaries.
A custom measurement policy is required to create a circular layout that arranges elements evenly around a central point. This section explores the key components of such a policy and demonstrates how it enables the flexible and precise placement of composables within a circular layout.
Creating the Circular Measurement Policy
The circularMeasurePolicy
function defines the core logic for measuring and placing composables in a circular arrangement. Below is a breakdown of how this policy works and the important parameters that drive its behavior:
internal fun circularMeasurePolicy(
overrideRadius: (() -> Dp)?,
startAngle: () -> Float,
spanAngle: Float,
clockwise: CircularPlacementDirection
) = MultiContentMeasurePolicy { (centerMeasurables: List<Measurable>,
contentMeasurables: List<Measurable>),
constraints: Constraints ->
Parameters Explained
- overrideRadius: An optional lambda that allows overriding the default radius of the circle. This can be useful when adjusting the radius based on specific criteria dynamically.
- startAngle: A lambda that provides the starting angle in degrees for placing the first child composable. This allows customization of where the first item in the circular layout will appear.
- spanAngle: The angle span in degrees over which the children are distributed. This determines how spread out the items are across the circular layout.
- clockwise: Defines the direction in which the children are placed. It allows for flexibility in designing layouts that follow either a clockwise or counterclockwise direction.
Measurement and Placement Logic
require(centerMeasurables.size == 1) { "Center composable can have only one child" }// Measure children with modified constraints
val modifiedConstraints = constraints.copy(
minWidth = 0,
minHeight = 0,
)
val centerPlaceable: Placeable = centerMeasurables.first().measure(modifiedConstraints)
val contentPlaceables: List<Placeable> = contentMeasurables.map { it.measure(modifiedConstraints) }
// Ensure all measured composables are circular
require(centerPlaceable.isCircle()) { "Center composable must be a circle" }
require(contentPlaceables.all { it.isCircle() }) { "Content composables must be circles" }
// Calculate the overall radius and layout size
val overallRadius = overrideRadius?.invoke()?.roundToPx() ?: (centerPlaceable.height / 2)
val maxExtraRadius = contentPlaceables.mapNotNull { placeable ->
(placeable.parentData as? CircularParentData)?.extraRadius?.roundToPx()
}.maxOrNull() ?: 0
val totalRadius = overallRadius + maxExtraRadius
val biggestChildSize = contentPlaceables.maxOfOrNull { it.height } ?: 0
val centerSize = centerPlaceable.height
val layoutSize = max(centerSize, 2 * totalRadius + biggestChildSize)
layout(layoutSize, layoutSize) {
// Place the center and content children in the circular layout
val middle = layoutSize / 2
var angle = startAngle()
// Adjust the angle increment based on the clockwise direction
val angleIncrement = if (contentPlaceables.size > 1) {
spanAngle / (contentPlaceables.size - 1)
} else {
0f
} * if (clockwise == CircularPlacementDirection.Clockwise) 1 else -1
contentPlaceables.forEachIndexed { index, placeable ->
val finalAngle = (placeable.parentData as? CircularParentData)?.exactAngle ?: angle
val angleRadian = finalAngle.toRadians()
val radius =
overallRadius + ((placeable.parentData as? CircularParentData)?.extraRadius?.roundToPx()
?: 0)
placeable.place(
x = (middle + radius * sin(angleRadian) - placeable.height / 2).toInt(),
y = (middle - radius * cos(angleRadian) - placeable.height / 2).toInt(),
)
if (index < contentPlaceables.size - 1) {
angle += angleIncrement
}
}
centerPlaceable.place(middle - centerSize / 2, middle - centerSize / 2)
}
}
After defining the measurement policy, the next step is to wrap this logic in a composable function that developers can easily use within their Jetpack Compose applications. This function, Circular
, provides a clear interface for arranging children in a circular layout around a central element.
The Circular
Composable Function
The Circular
function is designed to be flexible and customizable, allowing developers to create circular layouts with varying configurations easily. Here’s how this function is structured and how it integrates with the circularMeasurePolicy
:
@Composable
fun Circular(
modifier: Modifier = Modifier,
overrideRadius: (() -> Dp)? = null,
startAngle: () -> Float = { 0.0f },
span: CircularSpan = CircularSpan.WholeCircle,
clockwise: CircularPlacementDirection = CircularPlacementDirection.Clockwise,
center: @Composable () -> Unit,
content: @Composable CircularScope.() -> Unit,
) {
Layout(
measurePolicy = circularMeasurePolicy(
overrideRadius,
startAngle,
spanAngle,
clockwise
),
contents = listOf(center, { CircularScopeInstance.content() }),
modifier = modifier,
)
}
Key Parameters Explained
- modifier: A standard Jetpack Compose
Modifier
that allows for additional customization of the circular layout’s appearance and behavior. - overrideRadius: Similar to the parameter in the measurement policy, this lambda allows overriding the default radius of the circle. If
null
, the radius is calculated based on the size of the central composable, providing a flexible default behavior. - startAngle: This lambda provides the starting angle in degrees for placing the first child composable. By default, it starts at 0 degrees, but it can be adjusted to rotate the entire layout as needed.
- span: Defines the angle span in degrees over which the children are distributed around the circle. The default is 360 degrees, which places the children evenly around the entire circle. This can be modified to create semi-circular or quarter-circle layouts.
- clockwise: Specifies the direction in which the children are placed. The default is clockwise, but for different design requirements, it can be changed to counterclockwise.
- center: A composable function representing the central element of the circular layout. This is typically the focal point of the layout, such as an icon or button that the other elements surround.
- content: A composable function representing the elements to be placed around the center. This content is defined within a custom
CircularScope
, which provides additional tools for fine-tuning the placement of each element.
Integrating with the Measurement Policy
The Circular
function uses the circularMeasurePolicy
as its measurement policy, ensuring that all the composables are arranged according to the logic defined in the previous section. The Layout
composable is responsible for measuring and placing the elements according to this policy.
This setup allows for a high degree of customization while maintaining a simple and clean interface for developers. The combination of the modifier
and customizable parameters makes integrating the Circular composable into any Jetpack Compose project easy, regardless of the specific design requirements.
Using Circular
in Practice
The Circular
composable can be used in various UI scenarios where a circular arrangement of elements is desired. For example, it could be used to create a circular navigation menu, a radial chart, or a set of buttons arranged around a central action button.
By adjusting the startAngle
, spanAngle
, and clockwise
parameters, developers can create layouts that range from simple circular arrangements to more complex patterns, all while maintaining control over the placement and appearance of each element.
@Preview
@Composable
fun CircularPreview() {
AppTheme {
Column {
Circular(
overrideRadius = { 100.dp },
span = CircularSpan.Angle(180f),
startAngle = { 90f },
center = {
FloatingActionButton({},
) {
Icon(Icons.Filled.Menu, contentDescription = "Menu")
}
},
clockwise = CircularPlacementDirection.CounterClockwise
) {
MakeIcons()
}Circular(
overrideRadius = { 100.dp },
span = CircularSpan.WholeCircle,
startAngle = { 0f },
center = {
FloatingActionButton({},
) {
Icon(Icons.Filled.Menu, contentDescription = "Menu")
}
},
clockwise = CircularPlacementDirection.CounterClockwise
) {
MakeIcons()
}
}
}
}
Building on the concept of a circular layout, a dynamic and interactive menu can be created using the CircularMenu
composable. This menu expands and contracts, providing a visually appealing and functional UI element. The scenario envisioned for this section involves an “exploding” menu, where menu items radiate outwards from a central point when expanded and retract back when collapsed.
Introducing the CircularMenu
Composable
The CircularMenu
composable is designed to take advantage of the circular measurement policy’s flexibility while incorporating animations to create a dynamic user experience. Here’s the core function that drives this menu:
@Composable
fun CircularMenu(
modifier: Modifier = Modifier,
expanded: Boolean,
menuItemCount: Int,
span: CircularSpan = CircularSpan.Angle(90f),
startAngle: Float = 0f,
radius: Float = 100f,
clockwise: CircularPlacementDirection = CircularPlacementDirection.Clockwise,
animation: CircularAnimation = CircularAnimation.StaggerAnimation(),
centerContent: @Composable () -> Unit,
content: @Composable CircularScope.() -> Unit,
) {
val animatedRadiusValues = remember {
List(menuItemCount) { Animatable(0f) }
}LaunchedEffect(expanded) {
when (animation) {
is CircularAnimation.ExpandAnimation -> {
animatedRadiusValues.forEach { animatable ->
launch {
animatable.animateTo(
if (expanded) radius else 0f,
animationSpec = animation.animationSpec
)
}
}
}
is CircularAnimation.StaggerAnimation -> {
animatedRadiusValues.forEachIndexed { index, animatable ->
launch {
delay(index * animation.betweenAnimation.inWholeMilliseconds)
animatable.animateTo(
if (expanded) radius else 0f,
animationSpec = animation.animationSpec
)
}
}
}
}
}
Layout(
measurePolicy = circularMeasurePolicy(
overrideRadius = { index -> animatedRadiusValues[index].value.dp },
startAngle = { startAngle },
span = span,
clockwise = clockwise,
),
contents = listOf(centerContent, { CircularScopeInstance.content() }),
modifier = modifier,
)
}
private fun circularMeasurePolicy(
overrideRadius: (Int) -> Dp,
startAngle: () -> Float,
span: CircularSpan,
clockwise: CircularPlacementDirection,
) = MultiContentMeasurePolicy { (centerMeasurables: List<Measurable>,
contentMeasurables: List<Measurable>),
constraints: Constraints ->
require(centerMeasurables.size == 1) { "Center composable can have only one child" }
// Measure children
val modifiedConstraints = constraints.copy(
minWidth = 0,
minHeight = 0,
)
val centerPlaceable: Placeable = centerMeasurables.first().measure(modifiedConstraints)
val contentPlaceables: List<Placeable> = contentMeasurables.map { it.measure(modifiedConstraints) }
require(centerPlaceable.isCircle()) { "Center composable must be circle" }
require(contentPlaceables.all { it.isCircle() }) { "Content composables must be circle" }
// Calculate layout size based on the span and radius
val maxRadius = contentPlaceables.mapIndexed { index, _ -> overrideRadius(index).toPx() }.maxOrNull() ?: 0f
val effectiveRadius = maxRadius + centerPlaceable.width / 2
val (width, height) = when (span) {
is CircularSpan.WholeCircle -> {
2 * effectiveRadius to 2 * effectiveRadius
}
is CircularSpan.Angle -> {
val angleRadian = (span.angle / 2).toRadians()
(2 * effectiveRadius * sin(angleRadian)).toInt() to effectiveRadius.toInt()
}
}
val layoutWidth = max(centerPlaceable.width, width.toInt())
val layoutHeight = max(centerPlaceable.height, height.toInt())
layout(layoutWidth, layoutHeight) {
// Determine the center position based on the span and direction
val middleX = when (span) {
is CircularSpan.WholeCircle -> layoutWidth / 2
is CircularSpan.Angle -> if (clockwise == CircularPlacementDirection.Clockwise) {
(layoutWidth - effectiveRadius).toInt()
} else {
effectiveRadius.toInt()
}
}
val middleY = layoutHeight / 2
var angle = startAngle()
// Adjust the angle increment based on the circular span
val angleIncrement = when (span) {
is CircularSpan.WholeCircle -> {
if (contentPlaceables.size > 1) {
360f / contentPlaceables.size
} else {
0f
}
}
is CircularSpan.Angle -> {
if (contentPlaceables.size > 1) {
span.angle / (contentPlaceables.size - 1)
} else {
0f
}
}
} * if (clockwise == CircularPlacementDirection.Clockwise) 1 else -1
contentPlaceables.forEachIndexed { index, placeable ->
val finalAngle = (placeable.parentData as? CircularParentData)?.exactAngle ?: angle
val angleRadian = finalAngle.toRadians()
val radiusPx = overrideRadius(index).toPx()
placeable.place(
x = (middleX + radiusPx * sin(angleRadian) - placeable.width / 2).toInt(),
y = (middleY - radiusPx * cos(angleRadian) - placeable.height / 2).toInt(),
)
if (index < contentPlaceables.size - 1) {
angle += angleIncrement
}
}
centerPlaceable.place(middleX - centerPlaceable.width / 2, middleY - centerPlaceable.height / 2)
}
}
Key Parameters and Features
- expanded: This boolean controls whether the menu is expanded or collapsed. When
true
, the menu items “explode” outward from the center; whenfalse
, they retract towards the center. - menuItemCount: Specifies the number of items in the menu. This allows the menu to dynamically adjust based on the number of elements it needs to display.
- span: Defines the angular spread of the menu items. A span of 90 degrees creates a quarter-circle arrangement, while 360 degrees would form a full circle.
- radius: Specifies the maximum distance from the center that menu items can move when fully expanded. This controls the “explosion” radius of the menu.
- animation: Determines how the menu items animate between the expanded and collapsed states. The default is a staggered animation, where each item expands or contracts with a slight delay relative to the previous one, creating a cascading effect.
Placing the Menu Items
Once the animations are set, the Layout
composable leverages the same circular measurement policy discussed earlier, but with a twist: it dynamically adjusts the placement of each menu item based on the current animated radius value:
Layout(
measurePolicy = circularMeasurePolicy(
overrideRadius = { index -> animatedRadiusValues[index].value.dp },
startAngle = { startAngle },
span = span,
clockwise = clockwise,
),
contents = listOf(centerContent, { CircularScopeInstance.content() }),
modifier = modifier,
)
To provide a practical example of how the CircularMenu
can be integrated into a typical Android UI, this section demonstrates its usage within a Scaffold
layout. The Scaffold
is a powerful component in Jetpack Compose that offers a structured way to organize app bars, floating action buttons, and other UI elements. By placing the CircularMenu
on the bottom app bar, a highly functional and visually appealing interface can be achieved.
Example: Embedding the Circular Menu in a Scaffold
Here’s how the CircularMenu
can be embedded in a Scaffold
with a bottom app bar:
Scaffold(
floatingActionButtonPosition = FabPosition.EndOverlay,
floatingActionButton = {
var expanded by remember { mutableStateOf(false) }CircularMenu(
centerContent = {
FloatingActionButton(
onClick = { expanded = !expanded },
) {
Icon(Icons.Filled.Menu, contentDescription = "Menu")
}
},
radius = 150f,
expanded = expanded,
span = CircularSpan.WholeCircle,
clockwise = CircularPlacementDirection.CounterClockwise,
menuItemCount = 7,
content = {
for (i in 0..6) {
FloatingActionButton({}) {
when (i) {
0 -> Icon(Icons.Filled.Home, contentDescription = "Home")
1 -> Icon(Icons.Filled.Save, contentDescription = "Save")
2 -> Icon(Icons.Filled.Backup, contentDescription = "Backup")
3 -> Icon(Icons.Filled.Commit, contentDescription = "Commit")
4 -> Icon(Icons.Filled.Map, contentDescription = "Map")
5 -> Icon(Icons.Filled.CardTravel, contentDescription = "Travel")
6 -> Icon(Icons.Filled.TableBar, contentDescription = "Bar")
}
}
}
}
)
},
bottomBar = {
BottomAppBar {
IconButton(onClick = { /* Handle click */ }) {
Icon(Icons.Filled.Star, contentDescription = "Favorite")
}
}
}
) {
Box(modifier = Modifier
.fillMaxSize()
.padding(it), contentAlignment = Alignment.Center
) {
Circular(
overrideRadius = { 100.dp },
span = CircularSpan.Angle(180f),
startAngle = { 90f },
center = {
FloatingActionButton(
onClick = { /* Handle click */ },
) {
Icon(Icons.Filled.Menu, contentDescription = "Menu")
}
},
clockwise = CircularPlacementDirection.CounterClockwise
) {
for (i in 0..5) {
FilledIconButton(onClick = { /* Handle click */ }) {
when (i) {
0 -> Icon(Icons.Filled.Home, contentDescription = "Home")
1 -> Icon(Icons.Filled.Save, contentDescription = "Save")
2 -> Icon(Icons.Filled.Backup, contentDescription = "Backup")
3 -> Icon(Icons.Filled.Commit, contentDescription = "Commit")
4 -> Icon(Icons.Filled.Tag, contentDescription = "Tag")
5 -> Icon(Icons.Filled.Cake, contentDescription = "Cake")
}
}
}
}
}
}
Practical Application and Use Case
This setup is ideal for applications that require quick access to multiple actions from a single point. For instance, a media app might use this layout to easily access playback controls, bookmarks, or sharing options. The circular arrangement ensures that all options are readily accessible without overwhelming the user, and the ability to expand or collapse the menu keeps the UI clean and uncluttered.
Creating custom layouts in Jetpack Compose offers a unique opportunity to break away from traditional UI patterns and explore more dynamic and interactive designs. The journey from understanding the basics of layouts and measurement policies to building complex components like the Circular
layout and the CircularMenu
highlights the versatility and power of Jetpack Compose.
By implementing a circular measurement policy, developers can arrange elements in a visually appealing manner that enhances user experience and adds a layer of creativity to their applications. The CircularMenu
composable takes this concept further, integrating smooth animations and dynamic interactions, making it an ideal solution for menus, dashboards, and other interactive UI elements.
The example of embedding the CircularMenu
in a Scaffold
with a bottom app bar demonstrates how these custom layouts can be seamlessly integrated into real-world applications, providing both functionality and aesthetic appeal. The video demonstration serves as a visual guide, helping to bring the concepts to life and showing how these components can be utilized effectively.
As you explore and experiment with these concepts in your projects, the possibilities for creating unique and engaging user interfaces are vast. Whether you’re building a complex app or a simple feature, Jetpack Compose provides the tools to push the boundaries of what’s possible in Android UI design.