Circuit Integration¶
Metro includes built-in support for Circuit, a Compose-first architecture for building kotlin apps. This integration generates Presenter.Factory and Ui.Factory implementations from @CircuitInject declarations, plus SubPresenterFactory and SubUiFactory implementations from @SubCircuitInject declarations. It is similar to Circuit’s existing KSP code generator but runs entirely within Metro’s compiler plugin. Generated factories contribute into the corresponding set multibindings.
Setup¶
Enable Circuit codegen in your Gradle build:
metro {
enableCircuitCodegen.set(true)
}
This requires the relevant Circuit runtime libraries on your classpath. The circuit-runtime-presenter and circuit-runtime-ui artifacts are optional — you can use presenter-only or UI-only modules. To generate SubCircuit factories, add com.slack.circuit:circuitx-subcircuit:<version>. Enabling this option also adds the circuit-codegen-annotations artifact to your implementation classpath.
Use either Metro’s native code generator or Circuit’s KSP code generator for a declaration but NOT both. Running both for the same @CircuitInject or @SubCircuitInject declarations attempts to generate the factories twice.
This is only compatible with Kotlin 2.3.20+ as it requires support for generating top-level declarations in FIR.
This will likely eventually move to a separate artifact.
Usage¶
Class-based Presenters and UIs¶
Annotate your Presenter or Ui implementation with @CircuitInject:
@CircuitInject(HomeScreen::class, AppScope::class)
@Inject
class HomePresenter(
private val repository: UserRepository,
) : Presenter<HomeState> {
@Composable
override fun present(): HomeState {
// ...
}
}
Metro generates a Presenter.Factory (or Ui.Factory) that:
- Is annotated with
@Injectand@ContributesIntoSet(scope) - Has a constructor that accepts a
() -> HomePresenterfunction - Implements
create()with screen matching and delegation to the provider
Function-based Presenters and UIs¶
Annotate a top-level @Composable function:
@CircuitInject(HomeScreen::class, AppScope::class)
@Inject
@Composable
fun HomePresenter(
screen: HomeScreen, // Circuit-provided
navigator: Navigator, // Circuit-provided
repository: UserRepository, // Injected as () -> UserRepository
): HomeState {
// ...
}
Metro generates a factory class whose constructor accepts provider-wrapped parameters (() -> T) for injected dependencies. At create() time, providers are invoked once (outside the composition) and passed to the function body along with any Circuit-provided parameters.
UI functions return Unit and must have a Modifier parameter:
@CircuitInject(HomeScreen::class, AppScope::class)
@Inject
@Composable
fun HomeUi(
state: HomeState, // Circuit-provided
modifier: Modifier, // Circuit-provided
analytics: Analytics, // Injected
) {
// ...
}
Assisted Injection¶
For presenters/UIs that need assisted injection (e.g., receiving a Navigator as an assisted parameter):
@AssistedInject
class FavoritesPresenter(
@Assisted private val navigator: Navigator,
private val repository: FavoritesRepository,
) : Presenter<FavoritesState> {
@CircuitInject(FavoritesScreen::class, AppScope::class)
@AssistedFactory
fun interface Factory {
fun create(@Assisted navigator: Navigator): FavoritesPresenter
}
@Composable
override fun present(): FavoritesState { /* ... */ }
}
The generated Circuit factory automatically bridges Circuit’s Presenter.Factory.create(screen, navigator, context) to your @AssistedFactory’s create() method by matching parameters by name. In the example above, navigator from Circuit’s create() is passed through to Factory.create(navigator) automatically.
Important:
- The
@CircuitInjectannotation goes on the@AssistedFactoryinterface, not the class itself. - The
@AssistedFactorymust be nested inside the targetPresenter/Uiclass. - The assisted parameters on your factory’s
create()method must be circuit-provided parameters (e.g.,Navigator,Screen). Custom assisted parameters that aren’t circuit-provided types are not supported — the generated factory has no way to obtain them at runtime since only Circuit’screate()parameters are available.
SubCircuit¶
The same enableCircuitCodegen option supports SubCircuit’s @SubCircuitInject annotation. Generated factories are contributed into Set<SubPresenterFactory> or Set<SubUiFactory>.
SubPresenter and SubUi Classes¶
Direct class targets must be annotated with @Inject and implement SubPresenter or SubUi:
@SubCircuitInject(ProfileCardScreen::class, AppScope::class)
@Inject
class ProfileCardPresenter(
private val repository: UserRepository,
) : SubPresenter<ProfileCardEvent, ProfileCardState> {
@Composable
override fun present(
outerEventSink: (ProfileCardEvent) -> Unit,
): ProfileCardState {
// ...
}
}
Assisted presenters and UIs use a nested factory, with @SubCircuitInject on the factory interface:
@AssistedInject
class ProfileCardPresenter(
@Assisted private val screen: ProfileCardScreen,
private val repository: UserRepository,
) : SubPresenter<ProfileCardEvent, ProfileCardState> {
@SubCircuitInject(ProfileCardScreen::class, AppScope::class)
@AssistedFactory
fun interface Factory {
fun create(screen: ProfileCardScreen): ProfileCardPresenter
}
// ...
}
SubPresenterFactory.create() and SubUiFactory.create() receive only a SubScreen. Metro forwards a matching screen to an assisted factory and returns null for non-matching screens. Other assisted parameters are not supported. The @AssistedFactory must be nested inside its target class.
SubUi Functions¶
Top-level @SubCircuitInject functions generate SubUiFactory implementations. A Modifier parameter is required, the SubCircuitUiState parameter is optional, and other parameters are injected once per create() call:
@SubCircuitInject(ProfileCardScreen::class, AppScope::class)
@Composable
fun ProfileCardUi(
state: ProfileCardState,
analytics: Analytics,
modifier: Modifier = Modifier,
) {
// ...
}
SubPresenter functions are not supported.
Circuit-Provided Parameters¶
Some parameter types are provided by Circuit at runtime and should not be injected:
| Parameter Type | Available To |
|---|---|
Screen (and subtypes) |
Presenter, UI |
Navigator |
Presenter only |
CircuitUiState (and subtypes) |
UI only |
Modifier |
UI only |
All other parameter types are treated as injected dependencies and wrapped in () -> T on the generated factory’s constructor.
Parameters already wrapped in () -> T, Provider<T>, Lazy<T>, or function types are passed through as-is without additional wrapping.
CircuitContext is intentionally excluded from the circuit-provided set. It is a factory-level concept and should not be accepted by presenters or UIs.
Validation¶
The compiler plugin validates @CircuitInject and @SubCircuitInject usage for common usage errors.
Notes¶
- Top-level
@AssistedFactorywith@CircuitInjector@SubCircuitInjectis not supported — the factory must be nested inside the target class. This is enforced by the compiler. expectdeclarations with@CircuitInjector@SubCircuitInjectare skipped. Onlyactualdeclarations are processed. You must annotate theactualdeclaration (too). kotlinc requires this symmetry as well.