Encapsulate your @Composable functions

A convenient convention for complex composable components.

Encapsulate your @Composable functions

TL;DR

Encapsulate your composable functions and associated types with a surrounding object and use the operator fun invoke of that object as your composable function. This is a convenient convention for complex components.

Details

Complex Composable functions often require numerous associated types for their implementation: state holders, UI models, default values, etc. While it does not seem to be defined within the Jetpack Compose API Guidelines, the common convention, promoted by the Jetpack Compose library component implementations, is to preface each associated type with the name of the composable function. For example, consider the Button composable function and its related types from the compose.material module:

	
@Composable
fun Button(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
    elevation: ButtonElevation? = ButtonDefaults.elevation(),
    shape: Shape = MaterialTheme.shapes.small,
    border: BorderStroke? = null,
    colors: ButtonColors = ButtonDefaults.buttonColors(),
    contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
    content: @Composable RowScope.() -> Unit
) { ... }

@Stable
interface ButtonElevation { ... }

@Stable
interface ButtonColors { ... }

object ButtonDefaults { ... }
	

These types all begin with the word "Button" and are located within the androidx.compose.material package. This convention associates the related types by a prefix, but has several drawbacks:

  • No strict enforcement of the convention as it relies on knowledge of the convention and willingness to conform to it. This could easily lead to inconsistencies between different components within the same library, by error, such as typos, overlooking a type, or simple ignorance of the convention. Static analysis tools may help to alleviate these issues, but it would prove rather difficult since each composable function is very different and may require external types as well.
  • Polluting of the package scope. There might be many different composable components within a single package, for instance, the androidx.compose.material package has greater than twenty different composable components, each of which has numerous of their own associated types. This might be beneficial for listing all types within a package, but with such a large amount of types, it might be difficult to find the type you are looking for. Code completion from an IDE may help this issue, but as you will see, this will work well with my proposition too.

Convention Proposal

Associate composable component related types by an encapsulating object instead of a naming convention, and include the composable function itself within the encapsulating object by utilizing Kotlin's invoke operator function. For example, the Button composable function and its related types can be rewritten as follows:


object Button {

  @Composable
  operator fun invoke(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
    elevation: ButtonElevation? = ButtonDefaults.elevation(),
    shape: Shape = MaterialTheme.shapes.small,
    border: BorderStroke? = null,
    colors: ButtonColors = ButtonDefaults.buttonColors(),
    contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
    content: @Composable RowScope.() -> Unit
  ) { ... }
  
  @Stable
  interface Elevation { ... }
  
  @Stable
  interface Colors { ... }
  
  object Defaults { ... }
}

Now, instead of everything being scoped to the package with a naming convention prefix, everything is scoped to an object with a statically enforced prefix of that object's name. And since the encapsulating component is an object, it is a singleton, so different instances cannot be mistakenly passed to a composable function. This approach has the following benefits:

  • Encapsulation of all the related types.
  • Easy finding and usage of related types through IDE code completion.
  • Cleaner package namespaces.
  • Better static enforcement of the convention.

The composable function invocation looks almost identical to the previous approach, but with cleaner accessing of related types:


Button(
  onClick = { ... },
  colors = Button.Defaults.colors()
) { ... }

However, it should be noted that one possible downside of this approach is incorrectly requiring the encapsulating object type as a parameter to another composable function. For instance:


@Composable
fun MyComponent(
  button: Button // Incorrect, though not dangerous, per se
) { ... }

This shouldn't be an issue, since the type is a singleton so there is no benefit in passing an instance as a parameter, and the invoke function will have to be explicitly called for the component to be added to the UI tree.

Conclusion

Encapsulating your composable function and associated types with a surrounding object, along with the usage of Kotlin's invoke operator function, provide a convenient approach to implementing UI components.