Annotation reference¶
This page is the reference for the navigation codegen annotations. They live in the codegen-annotations artifact under the package
io.github.thomaskioko.codegen.annotations. All have SOURCE retention.
There are seven:
@NavDestinationtargets presenter classes in the shared Kotlin Multiplatform layer. The processor generates a Metro@GraphExtensionplus a navigation binding for each annotated presenter.@ScreenUi,@SheetUi, and@TabUitarget@Composablefunctions in Androiduimodules. The processor generates the Metro binding that adds the composable to the consumer'sSet<ScreenContent>orSet<SheetContent>multibinding.@TabUiis for tab pager pages, where the active child is aTabChildrather than aScreenDestination.@ChildPresentertargets a presenter class that another presenter creates, instead of one you navigate to through a route. Tab pager pages are the typical case. The processor generates a<Presenter>ChildGraphgraph extension plus a factory contributed to the parent host's scope.@AppRoottargets the application's@AssistedInjectroot presenter implementation. The processor generates the activity-scope binding container that wires the nested@AssistedFactoryto the presenter's bound interface.@AppRootUitargets the host@Composablethat wraps every other screen. The processor generates anAppRootProviderinterface plus a Composable extension. The activity then calls the host once (graph.AppRootContent()) instead of forwarding each dependency by hand.
For each annotation, this page covers what it marks, what the processor emits, the validation rules, and the common pitfalls. The terms used throughout (graph extension, multibinding, binding, slot, scope) are defined in the glossary in architecture/index.md.
@NavDestination¶
Marks a presenter class as a navigation destination. One annotation, three kinds.
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
public annotation class NavDestination(
val route: KClass<*>,
val parentScope: KClass<*>,
val kind: DestinationKind,
)
public enum class DestinationKind {
SCREEN,
OVERLAY,
TAB_ROOT,
}
Use cases¶
Use @NavDestination on a presenter when the navigator needs to push it onto the back stack (SCREEN), show it as a modal overlay (OVERLAY), or render it as a
top level tab (TAB_ROOT). Without it, you write the Metro graph extension, the NavDestination factory, and the route binding by hand for every presenter.
Parameters¶
The route parameter points at the feature's route class.
- For
SCREENandOVERLAYit implements the consumer project'sNavRouteinterface.OVERLAYroutes also implement the consumer's overlay marker interface (for exampleOverlayRoutein Tv Maniac). The navigator checks that marker at runtime to decide whether to push the route onto the stack or show it as an overlay. - For
TAB_ROOTit implements the consumer'sNavRootinterface and is typically adata object.
The parentScope parameter names the parent dependency injection scope whose factory provides a ComponentContext. Typically ActivityScope::class.
The kind parameter chooses one of three destination roles. See DestinationKind.
Example¶
@Inject
@NavDestination(
route = ShowsRoute::class,
parentScope = ActivityScope::class,
kind = DestinationKind.SCREEN,
)
public class ShowsPresenter(
componentContext: ComponentContext,
) : ComponentContext by componentContext
Generated code¶
For a presenter com.example.feature.presenter.FooPresenter annotated @NavDestination(route = FooRoute::class, parentScope = ActivityScope::class, kind = SCREEN):
FooScreenGraph.ktis a Metro@GraphExtension(FooRoute::class)interface scoped to the route. The route class itself is the scope marker, so we don't generate a separate scope class. The reasoning is in architecture/generators.md.FooNavDestinationBinding.ktis a Metro@ContributesTo(parentScope)interface with a companion that contributes:@IntoSet NavDestination<*>: the matchingNavDestination.Screen(orOverlay, orTabRoot) instance.@IntoSet NavRouteBinding<*>forSCREENorOVERLAY, or@IntoSet NavRootBinding<*>forTAB_ROOT.@IntoSet NavRootforTAB_ROOTonly: the route singleton itself, contributed toSet<NavRoot>so consumers do not have to keep a parallel binding next to each tab.
See examples.md for concrete output for each kind.
Behavior by kind¶
| Kind | Injection | Contributes |
|---|---|---|
SCREEN |
@Inject (no runtime parameters) or @AssistedInject (parameterized) |
NavDestination.Screen plus NavRouteBinding<*> |
OVERLAY |
Same as SCREEN |
NavDestination.Overlay plus NavRouteBinding<*> |
TAB_ROOT |
@Inject only (no @AssistedInject) |
NavDestination.TabRoot plus NavRootBinding<*> plus the NavRoot singleton into Set<NavRoot> |
For SCREEN and OVERLAY, the processor detects whether your presenter takes a runtime parameter from the route. A plain @Inject constructor gives a graph that
exposes the presenter directly. An @AssistedInject constructor with a nested @AssistedFactory gives a graph that exposes the factory. The generated binding then
casts the route, reads the property whose type matches the presenter's single @Assisted parameter, and calls factory.create(param).
Validation¶
The processor reports a compile error if any of the following hold:
- The annotated symbol is not a class.
kindis notSCREEN,OVERLAY, orTAB_ROOT.- The presenter is parameterized but does not have exactly one
@Assistedconstructor parameter. kindisTAB_ROOTand the presenter declares a nested@AssistedFactory. Tab roots must use plain@Injectbecause the route is adata objectand carries no payload.
Pitfalls¶
- Forgetting
@Injector@AssistedInject.@NavDestinationonly tells the processor which graph and binding to generate. The presenter still needs Metro's injection annotation so Metro creates instances of it. - Routing a tab through a
data class.TAB_ROOTrequires adata object. Adata classroute implies a payload, but the tab multibinding is keyed by route type, not by instance. - Mismatched route property type for parameterized presenters. The
@Assistedparameter type must match a property on the route class. The processor reads that property at navigation time and passes it through the assisted factory.
@ScreenUi¶
Marks a @Composable function as the Android renderer for a screen presenter.
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.SOURCE)
public annotation class ScreenUi(
val presenter: KClass<*>,
val parentScope: KClass<*>,
)
Use cases¶
Pair @ScreenUi with @NavDestination(kind = SCREEN) on the matching presenter. The presenter lives in the shared Kotlin Multiplatform layer. The composable lives
in an Android only ui module.
The annotation exists because the navigation host can't call the composable directly. At runtime the host holds an active RootChild, and it doesn't know the
concrete presenter type inside. So it walks a Set<ScreenContent> multibinding, finds the entry whose predicate matches the active child, and renders that entry's
content lambda. @ScreenUi generates that entry. It pairs the (it as? ScreenDestination<*>)?.presenter is FooPresenter predicate with a content lambda that casts
the child and calls the annotated composable.
Parameters¶
The presenter parameter names the presenter type this screen renders. The processor uses it in two places. It builds the matches predicate, which checks that the
active RootChild is a ScreenDestination<*> wrapping that type. It also casts the presenter before passing it to the composable.
The parentScope parameter names the dependency injection scope the generated binding is contributed to. Typically ActivityScope::class.
Example¶
@Composable
@ScreenUi(presenter = ShowsPresenter::class, parentScope = ActivityScope::class)
public fun ShowsScreen(
presenter: ShowsPresenter,
modifier: Modifier = Modifier,
) {
// ... compose UI here
}
Generated code¶
For a composable com.example.feature.ui.FooScreen annotated @ScreenUi(presenter = FooPresenter::class, parentScope = ActivityScope::class), the processor emits one
file into com.example.feature.ui.di:
FooScreenUiBinding.kt: a@BindingContainer @ContributesTo(ActivityScope::class) objectthat@Provides @IntoSeta singleScreenContent. Thematcheslambda tests(it as? ScreenDestination<*>)?.presenter is FooPresenter. Thecontentlambda casts the child and invokesFooScreen(presenter = ..., modifier = modifier).
We use a @BindingContainer object on purpose. The full reasoning is in
architecture/generators.md. The short version: in an
Android only ui module, an interface + companion object form silently produces an empty multibinding unless the right Metro flag is set.
Composable signature¶
The annotated function must match the signature @Composable fun <Name>(presenter: <PresenterType>, modifier: Modifier = Modifier). The processor doesn't check the
parameter names or the Modifier default while parsing. But the generated code calls the composable with presenter = ..., modifier = modifier. So a function with
parameters in a different order or with different names fails to compile after generation, not during processing.
Pitfalls¶
- Annotating a composable in a transitive
implementationdependency. The generated binding lives in the same module as the composable. If the app only gets that module transitively, the binding never reaches the app's compile classpath. Add the module as a directimplementationdependency. - Returning a value from the composable.
ScreenContent.contentis(RootChild, Modifier) -> Unit. The generated wrapper expects the composable to returnUnit.
@SheetUi¶
Marks a @Composable function as the Android renderer for a modal overlay presenter. Parallel to @ScreenUi, but contributes a SheetContent instead of a ScreenContent.
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.SOURCE)
public annotation class SheetUi(
val presenter: KClass<*>,
val parentScope: KClass<*>,
)
Use cases¶
Pair @SheetUi with @NavDestination(kind = OVERLAY) on the matching presenter. The presenter lives in the shared Kotlin Multiplatform layer. The composable lives
in an Android only ui module.
The annotation exists for the same reason as @ScreenUi. The consumer's overlay slot can't call the composable directly. The slot host walks a Set<SheetContent>
multibinding, finds the entry whose predicate matches the active overlay child, and renders that entry's content lambda. @SheetUi generates that entry. It pairs
the (it as? SheetDestination<*>)?.presenter is FooPresenter predicate with a content lambda that casts the child and calls the annotated composable. This set is
separate from Set<ScreenContent> because the overlay slot and the navigation stack are independent registries.
Parameters¶
The parameters have the same meaning as on @ScreenUi.
Example¶
@Composable
@SheetUi(presenter = EpisodeSheetPresenter::class, parentScope = ActivityScope::class)
public fun EpisodeSheet(
presenter: EpisodeSheetPresenter,
modifier: Modifier = Modifier,
) {
// ... compose your ModalBottomSheet here
}
Generated code¶
For EpisodeSheet annotated @SheetUi(presenter = EpisodeSheetPresenter::class, parentScope = ActivityScope::class), the processor emits EpisodeSheetUiBinding.kt. It
has the same structure as a @ScreenUi binding, with two differences: the return type is SheetContent instead of ScreenContent, and the content lambda doesn't
forward a modifier.
Composable signature¶
The signature requirement matches @ScreenUi: @Composable fun <Name>(presenter: <PresenterType>, modifier: Modifier = Modifier). The generated code forwards only
presenter. The modifier parameter is there so you can still call the composable from preview code.
The generated wrapper doesn't pass a modifier because SheetContent.content is typed as (SheetChild) -> Unit. A modal overlay decides its own layout (usually inside
a ModalBottomSheet) in the composable body, not at the call site.
@TabUi¶
Marks a @Composable function as the Android renderer for one tab pager page. Parallel to @ScreenUi, but the predicate matches a TabChild<*> instead of a
ScreenDestination<*>.
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.SOURCE)
public annotation class TabUi(
val presenter: KClass<*>,
val parentScope: KClass<*>,
)
Use cases¶
Pair @TabUi with a tab pager presenter that exposes its children as TabChild<*> instances, not ScreenDestination<*> instances. Tv Maniac's home shell works this
way. The home pager hosts a fixed set of tab pages (Discover, Library, Progress, Profile). Each is backed by a tab presenter wrapped in a TabChild, and the pager
renders them through a Set<ScreenContent> multibinding.
The annotation exists because the predicate @ScreenUi generates ((it as? ScreenDestination<*>)?.presenter is FooPresenter) doesn't match a TabChild<*>. @TabUi
generates the same ScreenContent multibinding entry, but casts the child to TabChild<*>. The host sees one set. Each predicate in it knows how to recognise its own
child type.
Parameters¶
The parameters have the same meaning as on @ScreenUi.
Example¶
@Composable
@TabUi(presenter = DiscoverShowsPresenter::class, parentScope = ActivityScope::class)
public fun DiscoverScreen(
presenter: DiscoverShowsPresenter,
modifier: Modifier = Modifier,
) {
// ... compose UI here
}
Generated code¶
For DiscoverScreen annotated @TabUi(presenter = DiscoverShowsPresenter::class, parentScope = ActivityScope::class), the processor emits one file,
DiscoverScreenUiBinding.kt. It has the same structure as a @ScreenUi binding, with two differences: the cast inside the matches predicate
((it as? TabChild<*>)?.presenter is DiscoverShowsPresenter) and the cast inside the content lambda ((child as TabChild<*>).presenter as DiscoverShowsPresenter).
Composable signature¶
The signature requirement matches @ScreenUi: @Composable fun <Name>(presenter: <PresenterType>, modifier: Modifier = Modifier). The generator forwards both presenter
and modifier to the composable.
@ChildPresenter¶
Marks a presenter class as a child presenter that a parent host presenter creates and owns, such as a tab pager's pages. The processor generates a
<Presenter>ChildGraph graph extension plus a factory contributed to the parent host's scope.
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
public annotation class ChildPresenter(
val scope: KClass<*>,
val parentScope: KClass<*>,
)
Use cases¶
Use @ChildPresenter when another presenter constructs this one, instead of the navigator reaching it through a route. Tab pager pages, sub-screens inside an
expanding card, and similar sub-components fit. Routed destinations use @NavDestination instead.
Without the annotation, a parent presenter with child presenters needs a hand-written @GraphExtension interface that lists each child as a property. It also needs a
nested @ContributesTo(parentScope) @GraphExtension.Factory for the parent to consume. With the annotation, the processor emits one graph extension for each
annotated child, and the parent takes one factory per child.
Parameters¶
The scope parameter is the graph scope, used as the marker on the generated @GraphExtension. Several @ChildPresenter classes may share a scope. Each still gets
its own graph extension.
The parentScope parameter names the parent dependency injection scope that hosts the generated factory. Usually this is the route class of the parent host (for
example ProgressRoot::class).
Example¶
@Inject
@ChildPresenter(
scope = ProgressChildScope::class,
parentScope = ProgressRoot::class,
)
public class UpNextPresenter(
componentContext: ComponentContext,
// ... deps
) : ComponentContext by componentContext
Generated code¶
For a presenter com.example.feature.upnext.UpNextPresenter annotated @ChildPresenter(scope = ProgressChildScope::class, parentScope = ProgressRoot::class), the
processor emits one file into com.example.feature.upnext.di:
UpNextChildGraph.kt: a@GraphExtension(ProgressChildScope::class) interface UpNextChildGraphexposingval upNextPresenter: UpNextPresenterand a nested@ContributesTo(ProgressRoot::class) @GraphExtension.Factory interface Factorywhose single functioncreateUpNextGraph(@Provides componentContext: ComponentContext)returns the graph.
Each presenter gets its own factory function name (create<BaseName>Graph), so two child graphs contributed to the same parent scope don't collide. The base name is
the presenter's simple name without the Presenter suffix.
Parent presenter wiring¶
The parent presenter takes one factory parameter for each child:
@Inject
public class ProgressPresenter(
componentContext: ComponentContext,
upNextGraphFactory: UpNextChildGraph.Factory,
calendarGraphFactory: CalendarChildGraph.Factory,
) : ComponentContext by componentContext {
public val upNextPresenter: UpNextPresenter =
upNextGraphFactory.createUpNextGraph(childContext(key = "UpNext")).upNextPresenter
public val calendarPresenter: CalendarPresenter =
calendarGraphFactory.createCalendarGraph(childContext(key = "Calendar")).calendarPresenter
}
Reusable components¶
parentScope decides which hosts can embed the child. Point it at a parent route (as in the example
above) and the child belongs to that one host. Point it at a shared ancestor scope and every host
below that scope can embed it. That is how a component can live in its own module and be reused.
Metro graph extensions resolve bindings from any ancestor scope. The scope chain runs
AppScope -> ActivityScope -> {tab roots} -> {child scopes}. Each level is a @GraphExtension whose
factory @ContributesTo the level above. So a factory contributed to ActivityScope is visible to
every tab root, stack screen, and child below it.
To make a component reusable, give it its own scope and set parentScope to ActivityScope:
@Inject
@ChildPresenter(
scope = FeaturedShowsComponentScope::class,
parentScope = ActivityScope::class,
)
public class FeaturedShowsPresenter(
componentContext: ComponentContext,
// ... deps available at ActivityScope or AppScope
) : ComponentContext by componentContext
Any host below ActivityScope then embeds it the same way a parent embeds a pager child:
@Inject
public class DiscoverPresenter(
componentContext: ComponentContext,
featuredGraphFactory: FeaturedShowsChildGraph.Factory,
) : ComponentContext by componentContext {
public val featuredPresenter: FeaturedShowsPresenter =
featuredGraphFactory.createFeaturedShowsGraph(childContext(key = "Featured")).featuredShowsPresenter
}
Nesting one embeddable component inside another just works. The outer component's graph descends
from ActivityScope, so it can inject the inner component's ActivityScope-contributed factory.
There is one constraint. An embeddable component may inject only bindings reachable at ActivityScope
or AppScope. If it depends on a binding scoped to one tab root, it compiles in that root and fails
in every other host. You get an ordinary Metro missing-binding error at the embedding site, not a
silent failure. The generator doesn't care about scopes, so you need no extra annotation or generator
change. The parentScope you choose is the whole difference.
Validation¶
The processor reports a compile error if the annotated symbol is not a class.
Pitfalls¶
- Forgetting
@Inject.@ChildPresenteronly tells the processor which graph to generate. The presenter still needs Metro's injection annotation so Metro creates instances of it. - Reusing a factory function name. Two child graphs contributed to the same
parentScopecan't expose factory functions with the same name. The generator derives the name from the presenter's simple name. So two child presenters in the same parent scope need different simple names.
@AppRoot¶
Marks an @AssistedInject presenter implementation as the application's root host. The processor emits the @BindingContainer that connects the nested
@AssistedFactory to the bound interface at the parent scope.
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
public annotation class AppRoot(
val parentScope: KClass<*>,
)
Use cases¶
Use @AppRoot on the root presenter implementation in the activity scope. The activity creates the root presenter once and hands it to the host composable. It is
not a destination on the navigation stack. Without the annotation, you write a @BindingContainer @ContributesTo(parentScope) object by hand. It takes the assisted
factory and a ComponentContext and returns the bound interface. With the annotation, the processor emits that file.
@AppRoot differs from @NavDestination in two ways. First, the root has no route, so the annotation has no route parameter. Second, the root is bound to its
public interface at the parent scope (usually ActivityScope) instead of exposed through a @GraphExtension. So the generated file is a binding container, not a
graph plus a destination binding.
Parameters¶
The parentScope parameter names the dependency injection scope hosting the generated binding. Typically ActivityScope::class in the consumer project.
Example¶
@AppRoot(parentScope = ActivityScope::class)
@AssistedInject
public class DefaultRootPresenter(
@Assisted componentContext: ComponentContext,
private val navigator: Navigator,
// ... more deps
) : RootPresenter, ComponentContext by componentContext {
@AssistedFactory
public fun interface Factory {
public fun create(componentContext: ComponentContext): DefaultRootPresenter
}
}
Generated code¶
For an implementation com.example.app.presenter.DefaultRootPresenter annotated @AppRoot(parentScope = ActivityScope::class) and implementing RootPresenter, the
processor emits one file into com.example.app.presenter.di:
RootPresenterBindingContainer.kt: a@BindingContainer @ContributesTo(parentScope) objectwhose@Provides @SingleIn(parentScope)function takes aComponentContextand the nestedFactory, and returns the bound interface (RootPresenterin this case). The function body invokes the factory's single function with the suppliedComponentContext.
This replaces the binding container you would otherwise write by hand and keep in sync with the factory function name and the bound interface name.
Bound interface inference¶
The processor reads the implementation's supertypes in declaration order and picks the first non-marker interface as the bound type. It skips Decompose's
ComponentContext (usually used as a delegate via ComponentContext by componentContext) and Kotlin's implicit Any. An implementation that extends more than one
non-marker interface is rejected at compile time.
Validation¶
The processor reports a compile error if any of the following hold:
- The annotated symbol is not a class.
- The class does not carry
@AssistedInject. - The class does not declare a nested
@AssistedFactoryinterface. - The nested factory does not declare exactly one function.
- The class extends zero or more than one non-marker interface.
Pitfalls¶
- Forgetting the nested factory.
@AppRootrequires@AssistedInjectplus a nested@AssistedFactory. The factory's single function must take aComponentContextand return the implementation type, the same way you would call the assisted factory by hand. - Multiple non-marker supertypes. The bound type is inferred from the supertype list. If the implementation extends two interfaces (for example a presenter contract and an extra interface), the processor can't pick one and reports a compile error. Remove the second interface so the bound type is clear, or merge the two contracts into one.
@AppRootUi¶
Marks the host @Composable function as the application's root UI. The processor generates a provider interface with one property for each non-modifier parameter of
the composable. It also generates a Composable extension that calls the composable with the receiver's properties.
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.SOURCE)
public annotation class AppRootUi(
val presenter: KClass<*>,
val parentScope: KClass<*>,
)
Use cases¶
Pair @AppRootUi with @AppRoot on the matching presenter implementation. The composable is the activity's top level Compose entry point. It receives the root
presenter plus any multibinding sets the host needs (Set<ScreenContent>, Set<SheetContent>, and so on). It renders every screen the navigation system pushes
beneath it.
The composable is not part of the Set<ScreenContent> multibinding the navigation system walks. It is the host that provides that set to its descendants, so
@ScreenUi doesn't apply. @AppRootUi lets the codegen emit a provider interface built from the composable's parameter list. The activity graph extends that
interface, and the activity renders the host with one call.
Parameters¶
The presenter parameter names the root presenter type. The processor reads the composable's parameters in declaration order and skips a final modifier: Modifier
parameter. The first remaining parameter type must equal presenter. A mismatch is a compile error.
The parentScope parameter names the dependency injection scope hosting the generated artifacts. Typically ActivityScope::class.
Example¶
@AppRootUi(presenter = RootPresenter::class, parentScope = ActivityScope::class)
@Composable
public fun RootScreen(
rootPresenter: RootPresenter,
screenContents: Set<ScreenContent>,
sheetContents: Set<SheetContent>,
modifier: Modifier = Modifier,
) {
// ... compose UI here, including the navigation host
}
Generated code¶
For a composable com.example.app.ui.RootScreen annotated @AppRootUi(presenter = RootPresenter::class, parentScope = ActivityScope::class), the processor emits one
file into com.example.app.ui.di:
RootScreenAppRootUiBinding.ktcontains two declarations:- An
AppRootProviderinterface declaring onevalfor each non-modifier parameter on the annotated composable. - A
@Composable AppRootProvider.AppRootContent(modifier: Modifier)extension that invokes the composable using the receiver's properties.
Make your activity-scope @DependencyGraph extend AppRootProvider, then call graph.AppRootContent() from the activity. The call site shrinks from one argument per
dependency to one extension call.
Composable signature¶
The annotated function must:
- Be
@Composable. - Declare at least one non-modifier parameter.
- Declare its first non-modifier parameter as the
presentertype.
The processor reads the parameter list in order and skips any parameter named modifier whose type is androidx.compose.ui.Modifier. Every other parameter becomes a
property on the generated AppRootProvider interface.
Validation¶
The processor reports a compile error if any of the following hold:
- The annotated symbol is not a function.
- The function lives in the default (empty) package.
- The function has no non-modifier parameters.
- The first non-modifier parameter type does not equal
presenter. - More than one
@AppRootUiis declared in the same compilation round.
Pitfalls¶
- Activity graph does not extend
AppRootProvider. The generated extension is onAppRootProvider, not on your graph type. Declare: AppRootProvideron your@DependencyGraphinterface so the extension resolves at the call site. - Adding a parameter without updating the graph. Each non-modifier parameter becomes a
valon the generated interface. If the activity graph already exposes a property with the same name and type, the new field is covered. Otherwise you add the property, usually as a Metro multibinding or injection point.
DestinationKind¶
DestinationKind is the enum passed to @NavDestination(kind = ...). It picks the role the destination plays at runtime.
SCREEN: a screen pushed onto the navigation stack. Tapping the system back button pops it off and returns to the previous destination.OVERLAY: a modal overlay (sheet, dialog, or menu) that appears on top of the current screen without affecting the back stack. Dismissing the overlay returns to the screen that was visible underneath.TAB_ROOT: the destination shown when the user selects a top level tab. Each tab anchors its own back stack and persists across tab switches.
The SCREEN and OVERLAY outputs have the same structure. They differ only in the NavDestination subclass the binding contributes. The navigator checks that
subclass at runtime to decide between pushing onto the stack and showing an overlay.
Requirements¶
The generated code references fully qualified names that your project must provide. They are hardcoded in the processor's util/External.kt. Below they are grouped by
the role each plays.
The destination types pull from com.thomaskioko.tvmaniac.navigation:
BaseRoute: sealed parent of every routable target.NavRoute: supertype for routes that map to navigation stack screens or overlays.NavRoot: supertype for routes that map to top level tab anchors.NavDestination: sealed factory family with nestedScreen,Overlay,TabRootsubclasses. Codegen emits one of these for each annotated presenter.NavRouteBinding: Metro multibinding entry forNavRoutepolymorphic serialization.NavRootBinding: Metro multibinding entry forNavRootpolymorphic serialization.RootChild: Decompose child marker; the return type of everyNavDestinationfactory lambda.ScreenDestination: generic root wrapper used byScreenandOverlayfactories.SheetChild,SheetDestination: used when the slot host renders overlays. The consumer usually rewraps theRootChildfrom anOverlayfactory into aSheetDestinationat the slot host.
Tabs additionally pull TabChild from com.thomaskioko.tvmaniac.home.nav. TabChild is the generic tab wrapper used by TabRoot factory lambdas.
com.thomaskioko.tvmaniac.core.base.ActivityScope is the default parent scope referenced by the generated binding contributions.
The Android UI renderer types live under com.thomaskioko.tvmaniac.navigation.ui. ScreenContent carries a matches predicate and a @Composable (RootChild, Modifier)
-> Unit content lambda. SheetContent does the same for SheetChild. The bindings generated for @ScreenUi and @SheetUi also reference
androidx.compose.ui.Modifier, because the screen variant forwards a modifier into the composable.
@AppRoot references one more Metro primitive, dev.zacsweers.metro.SingleIn, to scope the generated provider to the parent scope. @AppRootUi emits a top level
@Composable extension, so it references androidx.compose.runtime.Composable and androidx.compose.ui.Modifier directly. These three names are also hardcoded
constants in util/External.kt, with matching test stubs.
The processor is opinionated about these names. A project other than Tv Maniac would need to change util/External.kt to match its own navigation primitives. See
architecture/consumer-contract.md for why these are constants and what a fork would change.
Migration¶
Earlier releases shipped @NavScreen, @TabScreen, and @NavSheet as separate annotations. The single @NavDestination(kind = ...) API replaces them:
@NavScreenbecomes@NavDestination(kind = DestinationKind.SCREEN).@TabScreenbecomes@NavDestination(kind = DestinationKind.TAB_ROOT).@NavSheetbecomes@NavDestination(kind = DestinationKind.OVERLAY).
See the CHANGELOG for the version that introduced the change.