SubCircuit¶
SubCircuit is a lightweight framework for rendering nested presenter/UI pairs that delegate events to an outer component rather than handling navigation themselves.
dependencies {
implementation("com.slack.circuit:circuitx-subcircuit:<version>")
// For code generation
ksp("com.slack.circuit:circuitx-subcircuit-codegen:<version>")
// For testing
testImplementation("com.slack.circuit:circuitx-subcircuit-test:<version>")
}
Experimental
SubCircuit is annotated with @ExperimentalSubCircuitApi and may change without notice.
When to Use SubCircuit¶
Use SubCircuit when you need nested, reusable UI components that:
- Don’t need direct navigation access
- Delegate cross-cutting concerns (navigation, dialogs) to a parent
- Don’t require
Parcelablescreen serialization - Compose into larger Circuit screens as building blocks
Common use cases include list items that trigger navigation, embedded widgets reused across features, and renderable UI blocks that emit events to their container.
SubCircuit vs Circuit¶
| Aspect | SubCircuit | Circuit |
|---|---|---|
| Navigation | Delegated via outerEventSink |
Direct via Navigator |
| Screen serialization | None required | Parcelable |
| Use case | Nested/embedded components | Top-level screens |
| DI wiring | @SubCircuitInject |
@CircuitInject |
Architecture¶
┌─────────────────────────────────────────────────┐
│ Outer Circuit/Composable │
│ ┌───────────────────────────────────────────┐ │
│ │ outerEventSink: (OuterEvent) -> Unit │ │
│ └─────────────────────▲─────────────────────┘ │
│ │ │
│ │ │
│ ┌─────────────────────┴───────────────────┐ │
│ │ SubCircuitContent │ │
│ │ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ SubPresenter│────▶│ SubUi │ │ │
│ │ │ │ │ │ │ │
│ │ │ present() │ │ Content() │ │ │
│ │ └─────────────┘ └─────────────┘ │ │
│ └─────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
Core Types¶
| Type | Description |
|---|---|
SubScreen<OuterEvent> |
Marker interface for screens (no Parcelable requirement) |
SubPresenter<OuterEvent, State> |
Presenter that receives outerEventSink |
SubUi<State> |
UI component that renders state |
SubCircuit |
Registry for presenter and UI factories |
SubCircuitContent |
Composable that renders a SubScreen |
SubCircuitOuterEvent |
Marker for events delegated to the outer component |
SubCircuitUiState |
Marker for UI state types |
@SubCircuitInject |
Annotation for code generation |
Usage¶
1. Define Outer Events¶
Events that need to be handled by the parent component:
sealed interface ProfileCardEvent : SubCircuitOuterEvent {
data class NavigateToProfile(val userId: String) : ProfileCardEvent
data class NavigateToChat(val userId: String) : ProfileCardEvent
}
2. Define UI State¶
data class ProfileCardState(
val name: String,
val avatarUrl: String?,
val eventSink: (ProfileCardUiEvent) -> Unit
) : SubCircuitUiState
sealed interface ProfileCardUiEvent {
data object Clicked : ProfileCardUiEvent
}
3. Create the SubScreen¶
data class ProfileCardScreen(
val userId: String
) : SubScreen<ProfileCardEvent>
4. Implement the SubPresenter¶
class ProfileCardPresenter(
private val screen: ProfileCardScreen,
private val userRepository: UserRepository
) : SubPresenter<ProfileCardEvent, ProfileCardState> {
@Composable
override fun present(outerEventSink: (ProfileCardEvent) -> Unit): ProfileCardState {
val user by produceState<User?>(null, screen.userId) {
userRepository.getUser(screen.userId).collect { value = it }
}
return ProfileCardState(
name = user?.name ?: "Loading...",
avatarUrl = user?.avatarUrl,
eventSink = { event ->
when (event) {
ProfileCardUiEvent.Clicked ->
outerEventSink(ProfileCardEvent.NavigateToProfile(screen.userId))
}
}
)
}
}
5. Implement the UI¶
@SubCircuitInject(ProfileCardScreen::class, AppScope::class)
@Composable
fun ProfileCardUi(state: ProfileCardState, modifier: Modifier = Modifier) {
Row(
modifier = modifier.clickable { state.eventSink(ProfileCardUiEvent.Clicked) },
verticalAlignment = Alignment.CenterVertically
) {
AsyncImage(model = state.avatarUrl, modifier = Modifier.size(48.dp))
Spacer(Modifier.width(12.dp))
Text(text = state.name)
}
}
6. Use SubCircuitContent¶
Render the SubCircuit from a parent composable, handling delegated events:
@Composable
fun TeamMembersUi(state: TeamMembersState, modifier: Modifier = Modifier) {
LazyColumn(modifier = modifier) {
items(state.members) { member ->
SubCircuitContent(
screen = ProfileCardScreen(userId = member.id),
outerEventSink = { event ->
when (event) {
is ProfileCardEvent.NavigateToProfile ->
state.eventSink(TeamMembersUiEvent.NavigateToProfile(event.userId))
is ProfileCardEvent.NavigateToChat ->
state.eventSink(TeamMembersUiEvent.NavigateToChat(event.userId))
}
}
)
}
}
}
Code Generation¶
SubCircuit uses KSP to generate factory classes that wire presenters and UIs into the DI graph.
Presenter Factories¶
Annotate your @AssistedFactory interface with @SubCircuitInject:
class ProfileCardPresenter @AssistedInject constructor(
@Assisted val screen: ProfileCardScreen,
private val userRepository: UserRepository
) : SubPresenter<ProfileCardEvent, ProfileCardState> {
@Composable
override fun present(outerEventSink: (ProfileCardEvent) -> Unit): ProfileCardState {
// ...
}
@SubCircuitInject(ProfileCardScreen::class, AppScope::class)
@AssistedFactory
interface Factory {
fun create(screen: ProfileCardScreen): ProfileCardPresenter
}
}
This generates a SubPresenterFactory implementation contributed to the DI graph via multibinding.
UI Factories¶
Annotate @Composable UI functions directly:
@SubCircuitInject(ProfileCardScreen::class, AppScope::class)
@Composable
fun ProfileCardUi(state: ProfileCardState, modifier: Modifier = Modifier) {
// ...
}
Requirements:
- Function must be
@Composable - First parameter must implement
SubCircuitUiState - Second parameter should be
modifier: Modifier
DI Modes¶
The code generator supports two DI frameworks:
Generates @ContributesMultibinding(Scope::class) + @Inject.
Set subcircuit.codegen.mode=metro as a KSP argument. Generates @ContributesIntoSet(Scope::class) + @Inject.
Wiring¶
The generated factories are collected via multibinding and assembled into a SubCircuit instance:
@Provides
fun provideSubCircuit(
presenterFactories: Set<@JvmSuppressWildcards SubPresenterFactory>,
uiFactories: Set<@JvmSuppressWildcards SubUiFactory>
): SubCircuit =
SubCircuit.builder()
.addPresenterFactories(presenterFactories)
.addUiFactories(uiFactories)
.build()
Provide SubCircuit to the composition tree via LocalSubCircuit:
CompositionLocalProvider(LocalSubCircuit provides subCircuit) {
// SubCircuitContent can now resolve screens
}
Testing¶
The circuitx-subcircuit-test artifact provides a .test {} extension for SubPresenters, built on Molecule and Turbine.
@Test
fun presenterEmitsNavigationEvent() = runTest {
val presenter = ProfileCardPresenter(
screen = ProfileCardScreen("123"),
userRepository = FakeUserRepository()
)
presenter.test {
val state = awaitItem()
assertEquals("Test User", state.name)
state.eventSink(ProfileCardUiEvent.Clicked)
assertEquals(
ProfileCardEvent.NavigateToProfile("123"),
outerEvents.awaitEvent()
)
}
}
Test API¶
The test {} block provides a SubCircuitReceiveTurbine with:
awaitItem()— Await the next distinct state emission (deduplicates unchanged states)outerEvents.awaitEvent()— Await the next outer eventouterEvents.assertNoEvents()— Assert no outer events were emittedawaitUnchanged()— Assert the next emission is identical to the previous one
You can also provide a custom TestOuterEventSink or use subPresenterTestOf for more control over the composition:
@Test
fun customTestSetup() = runTest {
val customSink = TestOuterEventSink<ProfileCardEvent>()
subPresenterTestOf(
presentFunction = { myPresenter.present(customSink::invoke) },
outerEventSink = customSink
) {
val state = awaitItem()
// assertions...
}
}