Custom Compose Component
The Compose Remote Layout library comes with built-in components like Column, Row, Text, and Button. However, your application often requires specialized UI elements that aren't part of the standard set. Custom Nodes allow you to extend the library with your own reusable components.
Understanding Custom Nodes
Custom Nodes are a way to register your own Composable functions that can be referenced in JSON layouts. This creates a powerful extension mechanism that allows you to:
- Create application-specific components that match your design system
- Encapsulate complex UI logic in reusable components
- Integrate third-party libraries with Compose Remote Layout
- Maintain consistency across your application
How Custom Nodes Work
The CustomNodes
object is a registry for all your custom components. It maintains a map of
component type names to Composable functions:
object CustomNodes {
private val nodes = mutableMapOf<String, @Composable (NodeParam) -> Unit>()
fun register(
type: String,
node: @Composable (NodeParam) -> Unit,
) {
nodes[type.lowercase()] = node
}
fun get(type: String): (@Composable (NodeParam) -> Unit)? = nodes[type.lowercase()]
fun exists(type: String): Boolean = nodes.containsKey(type.lowercase())
fun clear() {
nodes.clear()
}
}
When the DynamicLayout encounters a component type that doesn't match any built-in component, it checks if a custom node is registered with that type. If found, it calls the corresponding Composable function.
Registering Custom Nodes
To register a custom node, call the CustomNodes.register()
function, typically during application
initialization:
// In your Application class or composition root
fun registerCustomComponents() {
// Register a custom profile card component
CustomNodes.register("profile_card") { param ->
// Implementation of your custom component
Card(
modifier = param.modifier,
elevation = 4.dp
) {
Row(modifier = Modifier.padding(16.dp)) {
// Avatar
Box(
modifier = Modifier
.size(50.dp)
.background(Color.Gray, CircleShape),
contentAlignment = Alignment.Center
) {
Text(
text = param.data["initials"] ?: "?",
color = Color.White,
fontWeight = FontWeight.Bold
)
}
// User info
Column(
modifier = Modifier
.padding(start = 16.dp)
.align(Alignment.CenterVertically)
) {
Text(
text = param.data["name"] ?: "Unknown",
fontWeight = FontWeight.Bold
)
Text(
text = param.data["role"] ?: "",
fontSize = 14.sp,
color = Color.Gray
)
}
}
}
}
// Register other custom components...
}
The NodeParam Object
Your custom component implementation receives a NodeParam
object containing all the information
needed to render the component:
data class NodeParam(
val data: Map<String, String>, // Properties from JSON
val modifier: Modifier, // Combined modifiers
val children: List<ComponentWrapper>?, // Child components (if any)
val path: String, // Component path in the tree
val parentScrollable: Boolean, // If parent is scrollable
val onClickHandler: (String) -> Unit, // Click event handler
val bindsValue: BindsValue // Value bindings
)
Let's explore each parameter:
Data
The data
map contains all properties defined in the JSON for your custom component:
{
"profile_card": {
"name": "John Doe",
"role": "Software Engineer",
"initials": "JD",
"level": "Senior"
}
}
Access these properties in your implementation:
val name = param.data["name"] ?: "Unknown"
val role = param.data["role"] ?: ""
val initials = param.data["initials"] ?: "?"
val level = param.data["level"] ?: ""
Always provide defaults for optional properties to make your components robust.
Modifier
The modifier
parameter contains all modifiers applied to your component, including base modifiers
like width, height, padding, etc.
// Use the provided modifier directly
Card(
modifier = param.modifier,
elevation = 4.dp
) {
// Component content
}
// Or combine with additional modifiers
Box(
modifier = param.modifier
.padding(8.dp) // Add extra padding
.clip(RoundedCornerShape(8.dp)), // Add corner clipping
contentAlignment = Alignment.Center
) {
// Component content
}
Children
The children
parameter contains any child components defined in the JSON:
{
"expandable_section": {
"title": "Section Title",
"expanded": "true",
"children": [
{
"text": {
"content": "Child content 1"
}
},
{
"text": {
"content": "Child content 2"
}
}
]
}
}
Render these children in your implementation:
CustomNodes.register("expandable_section") { param ->
val title = param.data["title"] ?: "Section"
val isExpanded = param.data["expanded"]?.toBoolean() ?: false
var expanded by remember { mutableStateOf(isExpanded) }
Column(modifier = param.modifier) {
// Header with toggle
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded }
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = title,
fontWeight = FontWeight.Bold
)
Icon(
imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = if (expanded) "Collapse" else "Expand"
)
}
// Content (children)
AnimatedVisibility(visible = expanded) {
Column(modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)) {
// Render each child
param.children?.forEach { childWrapper ->
DynamicLayout(
component = childWrapper.component,
path = "${param.path}-child-${childWrapper.hashCode()}",
parentScrollable = param.parentScrollable,
onClickHandler = param.onClickHandler,
bindValue = param.bindsValue
)
}
}
}
}
}
Path
The path
parameter provides the component's position in the layout tree, which is useful for
maintaining state and managing children:
// Use the path for uniqueness in keys or IDs
val stateKey = "${param.path}-expanded"
var expanded by remember(stateKey) { mutableStateOf(isExpanded) }
// Pass path to child components
DynamicLayout(
component = childComponent,
path = "${param.path}-child-$index",
// Other parameters...
)
ParentScrollable
The parentScrollable
flag indicates if the parent component is scrollable, which helps avoid
nested scrolling issues:
// Only make this component scrollable if the parent isn't
val scrollModifier = if (!param.parentScrollable && needsScrolling) {
Modifier.verticalScroll(rememberScrollState())
} else {
Modifier
}
Column(
modifier = param.modifier.then(scrollModifier)
) {
// Component content
}
OnClickHandler
The onClickHandler
function allows your custom component to trigger click events that are handled
by the parent:
CustomNodes.register("action_button") { param ->
val actionId = param.data["action_id"] ?: "default_action"
val label = param.data["label"] ?: "Action"
Button(
onClick = {
// Forward the action to the parent handler
param.onClickHandler("custom_action:$actionId")
},
modifier = param.modifier
) {
Text(label)
}
}
BindsValue
The bindsValue
parameter provides access to the current bound values, allowing your component to
access dynamic data:
CustomNodes.register("user_greeting") { param ->
// Access a value from bindings
val username = param.bindsValue.getValue<String>(
LayoutComponent.Custom(
type = "user_greeting",
data = param.data,
modifier = null,
children = null
),
"username"
) ?: "Guest"
Text(
text = "Welcome, $username!",
style = MaterialTheme.typography.h5,
modifier = param.modifier
)
}
Using Custom Nodes in JSON
Once registered, you can use your custom nodes in JSON layouts:
{
"column": {
"children": [
{
"profile_card": {
"name": "John Doe",
"role": "Software Engineer",
"initials": "JD",
"level": "Senior",
"modifier": {
"base": {
"fillMaxWidth": true,
"padding": {
"all": 8
},
"clickId": "view_profile:john.doe"
}
}
}
},
{
"expandable_section": {
"title": "Recent Activity",
"expanded": "true",
"children": [
{
"text": {
"content": "Updated profile information"
}
},
{
"text": {
"content": "Completed project milestone"
}
}
]
}
}
]
}
}
Real-World Examples
Let's explore some practical custom components you might create:
Rating Component
CustomNodes.register("star_rating") { param ->
val rating = param.data["rating"]?.toFloatOrNull() ?: 0f
val maxRating = param.data["max"]?.toIntOrNull() ?: 5
val size = param.data["size"]?.toIntOrNull() ?: 24
val color = param.data["color"]?.let { ColorParser.parseColor(it) } ?: Color.Gold
Row(modifier = param.modifier) {
for (i in 1..maxRating) {
val filled = i <= rating
val halfFilled = !filled && i - 0.5f <= rating
Icon(
imageVector = when {
filled -> Icons.Filled.Star
halfFilled -> Icons.Filled.StarHalf
else -> Icons.Filled.StarBorder
},
contentDescription = null,
tint = color,
modifier = Modifier.size(size.dp)
)
}
}
}
Usage:
Input Field
CustomNodes.register("input_field") { param ->
val field = remember {
TextFieldState(
initialValue = param.data["value"] ?: "",
label = param.data["label"] ?: "",
placeholder = param.data["placeholder"] ?: "",
isPassword = param.data["password"]?.toBoolean() ?: false,
onChange = { newValue ->
// Report changes to parent
val fieldId = param.data["id"] ?: "field"
param.onClickHandler("field_change:$fieldId:$newValue")
}
)
}
// Get any error message from bindings
val errorKey = param.data["error_key"] ?: "${param.data["id"]}_error"
val error = param.bindsValue.getValue<String>(
LayoutComponent.Custom(
type = "input_field",
data = param.data,
modifier = null,
children = null
),
errorKey
) ?: ""
Column(modifier = param.modifier) {
if (field.label.isNotEmpty()) {
Text(
text = field.label,
style = MaterialTheme.typography.caption,
modifier = Modifier.padding(bottom = 4.dp)
)
}
OutlinedTextField(
value = field.value,
onValueChange = {
field.value = it
field.onChange(it)
},
placeholder = {
if (field.placeholder.isNotEmpty()) {
Text(field.placeholder)
}
},
visualTransformation = if (field.isPassword) {
PasswordVisualTransformation()
} else {
VisualTransformation.None
},
isError = error.isNotEmpty(),
modifier = Modifier.fillMaxWidth()
)
if (error.isNotEmpty()) {
Text(
text = error,
color = MaterialTheme.colors.error,
style = MaterialTheme.typography.caption,
modifier = Modifier.padding(top = 4.dp, start = 4.dp)
)
}
}
}
// Helper class for field state
private class TextFieldState(
initialValue: String,
val label: String,
val placeholder: String,
val isPassword: Boolean,
val onChange: (String) -> Unit
) {
var value by mutableStateOf(initialValue)
}
Usage:
{
"input_field": {
"id": "email",
"label": "Email Address",
"placeholder": "Enter your email",
"value": "{email_value}",
"error_key": "email_error"
}
}
Chart Component
CustomNodes.register("chart") { param ->
val chartType = param.data["type"] ?: "bar"
val title = param.data["title"] ?: ""
val dataKey = param.data["data_key"] ?: "chart_data"
val height = param.data["height"]?.toIntOrNull() ?: 200
// Get data from bindings
val chartData = param.bindsValue.getValue<String>(
LayoutComponent.Custom(
type = "chart",
data = param.data,
modifier = null,
children = null
),
dataKey
) ?: "[]"
// Parse data
val data = try {
Json.decodeFromString<List<ChartDataPoint>>(chartData)
} catch (e: Exception) {
emptyList()
}
Column(modifier = param.modifier) {
if (title.isNotEmpty()) {
Text(
text = title,
style = MaterialTheme.typography.h6,
modifier = Modifier.padding(bottom = 8.dp)
)
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(height.dp)
.border(1.dp, Color.LightGray)
) {
when (chartType.lowercase()) {
"bar" -> BarChart(data)
"line" -> LineChart(data)
"pie" -> PieChart(data)
else -> {
Text(
text = "Unsupported chart type: $chartType",
modifier = Modifier.align(Alignment.Center)
)
}
}
}
}
}
// Chart implementation components
@Composable
private fun BarChart(data: List<ChartDataPoint>) {
// Your bar chart implementation
}
@Composable
private fun LineChart(data: List<ChartDataPoint>) {
// Your line chart implementation
}
@Composable
private fun PieChart(data: List<ChartDataPoint>) {
// Your pie chart implementation
}
// Data class for chart data
@Serializable
private data class ChartDataPoint(
val label: String,
val value: Float,
val color: String? = null
)
Usage:
Best Practices
1. Use Descriptive Type Names
Choose clear, descriptive names for your custom components:
// Good
CustomNodes.register("profile_card") { /* ... */ }
CustomNodes.register("input_field") { /* ... */ }
CustomNodes.register("product_carousel") { /* ... */ }
// Avoid
CustomNodes.register("card1") { /* ... */ }
CustomNodes.register("custom_input") { /* ... */ }
CustomNodes.register("carousel") { /* ... */ }
2. Always Provide Defaults
Make your components robust by providing defaults for all properties:
val title = param.data["title"] ?: "Untitled"
val count = param.data["count"]?.toIntOrNull() ?: 0
val isEnabled = param.data["enabled"]?.toBoolean() ?: true
val color = param.data["color"]?.let { ColorParser.parseColor(it) } ?: Color.Black
3. Handle Type Conversions Safely
Always use safe conversions for non-string types:
// Safe integer conversion
val size = param.data["size"]?.toIntOrNull() ?: 16
// Safe boolean conversion
val isVisible = param.data["visible"]?.toBoolean() ?: true
// Safe float conversion
val opacity = param.data["opacity"]?.toFloatOrNull() ?: 1.0f
// Safe color parsing
val color = param.data["color"]?.let {
try {
ColorParser.parseColor(it)
} catch (e: Exception) {
Color.Black // Fallback color
}
} ?: Color.Black
4. Document Your Components
Document your custom components for other developers:
/**
* Rating Component
*
* Displays a star rating with customizable properties.
*
* Properties:
* - rating: The rating value (0-5, supports half stars)
* - max: Maximum number of stars (default: 5)
* - size: Size of each star in dp (default: 24)
* - color: Star color in hex format (default: gold)
*
* Example:
* ```json
* {
* "star_rating": {
* "rating": "4.5",
* "max": "5",
* "size": "32",
* "color": "#FFD700"
* }
* }
* ```
*/
CustomNodes.register("star_rating") { param ->
// Implementation...
}
5. Keep Components Focused
Each custom component should have a single responsibility:
// Good: Focused component
CustomNodes.register("price_display") { param ->
val price = param.data["price"]?.toDoubleOrNull() ?: 0.0
val currency = param.data["currency"] ?: "$"
// Render price with currency
}
// Avoid: Component doing too much
CustomNodes.register("product_card") { param ->
// Handles image, title, price, description, rating, actions, etc.
// Too many responsibilities in one component
}
6. Reuse Built-in Components
Build on top of built-in components when possible:
CustomNodes.register("section_header") { param ->
val title = param.data["title"] ?: ""
val subtitle = param.data["subtitle"]
Column(modifier = param.modifier) {
Text(
text = title,
style = MaterialTheme.typography.h6
)
if (subtitle != null) {
Text(
text = subtitle,
style = MaterialTheme.typography.subtitle1,
color = Color.Gray
)
}
Divider(modifier = Modifier.padding(vertical = 8.dp))
}
}
7. Register Early in App Lifecycle
Register all your custom components during app initialization:
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
// Register all custom components
registerCustomComponents()
}
private fun registerCustomComponents() {
// UI components
CustomNodes.register("profile_card") { /* ... */ }
CustomNodes.register("section_header") { /* ... */ }
// Form components
CustomNodes.register("input_field") { /* ... */ }
CustomNodes.register("dropdown") { /* ... */ }
// Visualization components
CustomNodes.register("chart") { /* ... */ }
CustomNodes.register("progress_indicator") { /* ... */ }
// Special components
CustomNodes.register("map_view") { /* ... */ }
CustomNodes.register("image_gallery") { /* ... */ }
}
}
Troubleshooting
Component Not Appearing
If your custom component doesn't appear:
- Verify the component type is registered correctly (check case sensitivity)
- Ensure the registration happens before the layout is rendered
- Check for exceptions in your component implementation
- Verify the JSON structure matches what your component expects
Data Not Being Passed Correctly
If properties aren't being passed correctly:
- Check the JSON property names match what your code is looking for
- Ensure you're handling type conversions safely
- Verify your defaults are working as expected
Children Not Rendering
If child components aren't rendering:
- Check that you're correctly passing the children to DynamicLayout
- Ensure you're using the correct path for child components
- Verify the parent-child relationship in your JSON
Next Steps
Now that you understand custom nodes, you can:
- Design a component system for your application
- Create reusable UI elements that match your design language
- Integrate third-party libraries with Compose Remote Layout