
Statechart-based navigation using DOT schemas, code generation, runtime statechart execution, history and deep-history resume, parallel-region back dispatch, and Compose integration for rendering active nodes.
Way is a navigation library built around statechart-like node graphs.
Way's model is a Harel statechart and maps directly onto the W3C SCXML standard: flows are compound (OR) states, parallel nodes are parallel (AND) states, and screens are atomic states. See Relationship to statecharts / SCXML for the full concept mapping.
A graph is defined in .dot files, and the ru.kode.way Gradle plugin generates:
The runtime (:way) executes transitions, keeps navigation state, and supports extension points.
The Compose integration (:way-compose) renders active nodes with NodeHost.
:way - Runtime library structured as a Kotlin Multiplatform project (common + JVM). Current release targets JVM/Android only. iOS and other native targets are planned for a future release.
:way-compose - Android Compose integration for NavigationService.:way-gradle-plugin - Gradle plugin (id("ru.kode.way")) that generates code from .dot schemas.:sample - JVM sample (KMP multi-target support is not yet released).:sample-compose:* - Android sample split into feature modules.
:sample-compose:main-parallel:* - tab-bar example using ParallelFlowNode (see Compose Integration).:sample-compose:app:routing - google/huawei product flavors with a per-flavor .dot extension (see Flavor-Specific Routing).8.0+ (plugin checks this at apply time).2.3.20.Notes:
gradle.properties.dependencies {
implementation("ru.kode:way:<version>")
implementation("ru.kode:way-compose:<version>") // optional, for Compose host
}plugins {
id("ru.kode.way") version "<version>"
}For Android modules that also use KSP, apply KSP plugin as usual:
plugins {
id("com.google.devtools.ksp")
}Place schema files in a sibling way directory next to your Kotlin sources:
src/main/kotlin -> src/main/way
src/commonMain/kotlin -> src/commonMain/way
src/commonTest/kotlin -> src/commonTest/way (for KMP test generation)Example:
digraph App {
package = "ru.kode.way.sample.compose.app.routing"
app [type=flow]
login [type=schema, resultType = "ru.kode.way.sample.compose.login.routing.LoginFlowResult"]
main [type=schema, resultType = "ru.kode.way.sample.compose.main.routing.MainFlowResult"]
app -> login
app -> main
}package - output package for generated code.schemaFileName - override generated schema file/class base name.targetsFileName - override generated targets file/class base name.mode = "extend" - the file adds nodes and edges to the schema with the same graph id instead of declaring one
(see Flavor-Specific Routing).type
flow - local flow node.schema - imported child schema flow.parallelFlow - parallel flow node (all sub-regions active at once).history / deepHistory - a history pseudostate: a childless leaf declared under a plain flow or a parallel (parentFlow -> histNodeId). Generates a typed HistoryTarget accessor and is codegen-time-only (it never becomes a runtime Schema.NodeType).resultType - result type for Finish(...) from a flow.parameterName / parameterType - typed payload for that node target.All nodes without type are treated as screen nodes.
The plugin registers:
generateWayClassesgenerateTestWayClasses (for commonTest in KMP)Generated sources are written under:
build/generated/way/code/<sourceSet>/...Typical generated types (from graph id App):
AppSchemaAppTargets (+ Target.Companion.app accessor)AppNodeBuilder and nested AppNodeBuilder.Factory
AppChildFinishRequest (sealed interface with nested child events, when child flows exist)<ParallelName>NodeBuilder also emits named val <child>RegionId: RegionId properties for each sub-region, so callers never need to hardcode RegionId(Path(...)) strings.For main -> details(eSimId) -> packageDetails(packageId) two builders are generated:
Target.myESimFlow.packageDetails(packageId) // short: only the node's own parameter
Target.myESimFlow.packageDetails(eSimId, packageId) // full: every parameterized node on the pathdetails's own transitions. A param-less node gets a property (Target.myESimFlow.info).FlowNode.initial, AbsoluteTarget or a jump into a branch which is not alive.If a short target is sent after its ancestor was left (e.g. a late tap after Back), the event is dropped, see
Dropped events. An ancestor parameter whose name clashes with another one on the path is prefixed
with the node id (detailsId).
An imported schema reachable from several parents (app -> a -> perm, app -> b -> perm, perm [type = schema]) gets
one target per parent path, named <node>Via<Parent>:
Target.app.permViaA(reason) // short
Target.app.permViaB(bId, reason) // full: payloads of the `b` path onlyWith a single parent the plain name (perm) is kept. The schema is built with the payloads of the path it was entered
through, and its finish request reaches that path's parent. Targets inside the imported schema need no parent in their
name: they are resolved relative to the alive instance of that schema. All parents must be in the same region, and
none of them can be a parallel node.
commonMain (and commonTest when present).ksp<Variant>Kotlin tasks depend on generation and receive generated roots.main.Android product flavors can each get their own .dot file for the same schema — e.g. a step only
some flavors need. Priority of the source sets, low to high:
main < flavor < build type < flavor+buildType variant-specific.
A flavor file either extends the schema or replaces it.
Extend. A file with the mode = "extend" graph attribute holds only what the flavor adds. Its nodes and
edges are added to the schema with the same graph id, and the result is validated as a whole:
src/main/way/app-flow.dot # app -> main
src/google/way/app-flow.dot # extension: app -> login (only google has a login step)
digraph App {
mode = "extend"
login [type=schema, resultType = "ru.kode.way.sample.compose.login.routing.LoginFlowResult"]
app -> login
}digraph App), so the file may have any name. With the name of
the base file it sits next to it in the flavor's source set, as above.package, schemaFileName, targetsFileName. Attributes it gives to an untyped node of the schema (a
screen's parameter) do overwrite the schema's ones, as a repeated declaration in one file does.Replace. A file without the attribute at src/<flavor>/way/<name>.dot fully replaces the file at the
same relative path in a lower-priority source set (together with the extensions at this path), exactly like
Android resource merging. Use it when a flavor has to drop something.
The plugin registers one generate<Variant>WayClasses task per Android variant
(generateGoogleDebugWayClasses, generateHuaweiDebugWayClasses, ...), each writing to
build/generated/way/code/<variant>/. A flavor without files of its own simply reuses
main's file as its own input — there is no generated/way/code/main/, since main is a
contributing source set, never a buildable variant on its own.
The gotcha: hand-written code can't straddle flavors. Whichever way a flavor changes a schema, its generated
code differs, while ordinary Kotlin/Java source sets (src/main/kotlin, src/google/kotlin,
src/huawei/kotlin) use plain Gradle union — every flavor's compilation always includes
main/kotlin. So if one flavor's schema has a node another flavor's doesn't, the
flavor missing it won't generate the matching class member (e.g. AppChildFinishRequest.Login),
and hand-written code in src/main/kotlin referencing it fails with Unresolved reference for
that flavor.
Rule: anything touching a symbol that isn't in every flavor's generated output must live in
that flavor's own Kotlin source set (src/google/kotlin, src/huawei/kotlin, ...), not
src/main/kotlin. Code that stays in main may only reference the intersection of what every
flavor generates. The node factory is such code: the generated Factory interface has a create… method
for every node, so a flavor with an added node needs its own factory class (the shared part can stay in main
as an abstract class).
Worked example: sample-compose/app/routing (google / huawei flavors) —
src/google/way/app-flow.dot extends src/main/way/app-flow.dot with a Google-only login
step, and the hand-written AppFlow.kt / AppFlowNode.kt / di/AppFlowComponent.kt are
duplicated per flavor under src/google/kotlin / src/huawei/kotlin accordingly.
src/test/kotlin/.../AppFlowNodeTest.kt is a shared test that only touches the symbols common to
both flavors, compiled once per flavor to prove both generate usable code.
Core runtime types in :way:
FlowNode<R>, ScreenNode, ParallelFlowNode<R>
NavigationService<R>FlowTransition / ScreenTransition (a ParallelFlowNode returns FlowTransition<R>)Target (FlowTarget, ScreenTarget, AbsoluteTarget, HistoryTarget)NavigationService drops an event, instead of applying its transition, when:
DropReason.MissingPayload(path): the target needs the parameterized node at path, which is not alive and has no
payload (a short target sent after its flow was left).DropReason.StaleSource(path): the event was sent through an EventSink of a node which has left navigation, or
was recreated, by the time the event is dispatched.A dropped event changes nothing: state is rolled back, no node lifecycle callbacks and no transition listeners run,
and the remaining enqueued events are still processed. A drop is all-or-nothing: a multi-target NavigateTo or an
event broadcast to parallel regions is dropped entirely when any of its targets misses a payload. The event is resolved
before onPreTransition, so neither onPreTransition nor onPostTransition is called for it (except for a payload
missed by a hand-rolled Schema which does not report the node as parameterized: that one is only caught when the node
builder throws MissingPayloadException, after onPreTransition). Every ServiceExtensionPoint.onEventDropped is
called:
service.addServiceExtensionPoint(object : ServiceExtensionPoint<Unit> {
override fun onPreTransition(service: NavigationService<Unit>, event: Event, state: NavigationState) = Unit
override fun onPostTransition(service: NavigationService<Unit>, event: Event, state: NavigationState) = Unit
override fun onEventDropped(service: NavigationService<Unit>, event: Event, reason: DropReason) {
log.w("dropped $event: $reason")
}
})With service.strictEventDropping = true, send (on the service or any sink) throws
EventDroppedException(event, reason) after the rollback for a DropReason.MissingPayload (a bug in the app's
targets). A DropReason.StaleSource is an expected race (a tap on a leaving screen) and is never thrown. Events still enqueued after the thrown one stay queued for the next
send. The default is false, so apps which construct NavigationService directly are lenient unless they
enable it (recommended for debug builds and tests). NodeHost(nodeBuilder, ...) sets it from the app's
FLAG_DEBUGGABLE. A missing payload during start() is always rethrown, without calling onEventDropped.
NavigateTo a node which is already alive with a different argument rebuilds it: the node and its alive
descendants get onExit, are built again with the new argument, get onEntry and a new generation, so sinks
obtained from the old instances become stale. An equal argument keeps the node as it is.HistoryTarget into a parameterized node which is not alive rebuilds it with the argument it had when it was left:
the history record keeps the payloads of the nodes it left and is replaced on every exit, so it holds at most one
configuration. A node which is still alive keeps its current argument.service.eventSink(path) returns an EventSink bound to the node instance alive at path. Its events:
Ignore through the node to its ancestors within the region, and (except Event.Back) also reach
every parallel enclosing the node and bubble up through each parallel's ancestors, like service.send (same region
order, same merge);service.send would ask it, so a screen in region A can send an event only a node in sibling
region B handles. An event handled in scope is not sent again, and the nodes already asked in scope are not asked
again on the fallback, so each node gets the event at most once. A node which does its work and answers Ignore
does not stop the fallback, and neither does a parallel answering Stay;Event.Back stays in the node's region (never reaching enclosing parallels or sibling regions): a parallel's sink
routes it through that parallel's DispatchBackTo, a sink of a node inside a region keeps it in that region;DropReason.StaleSource.service.nodeGeneration(path) returns the generation of the node instance alive at path (null if none); it changes
whenever the node is recreated, so it is a stable cache key for a sink (NodeHost keys LocalEventSink on it). A
scheduler set with setEnqueuedEventsScheduler receives the plain event sent through a sink; the sink's source is
kept when the scheduled event is sent back.
BaseScreenNode, BaseFlowNode and ParallelFlowNode expose their own sink as eventSink, attached right before
every onEntry (usable in onEntry and entry hooks; reading it before the first entry throws). It is not cleared on
exit: an event sent through it after the node left is dropped with DropReason.StaleSource. Every entry attaches a new
sink, so if the node builder returns the same instance on re-entry, a later read of eventSink yields the new entry's
live sink. For async work (and presenters) capture it first, val sink = eventSink in onEntry or before launching
the work: the captured sink goes stale when that entry ends. Events a node sends from onEntry of a transition which is
then rolled back are discarded without onEventDropped, since that entry never happened. A node re-entered by the
rollback keeps (and can use again) the sink of its original entry.
Events go into an EventSink; take the nearest one:
LocalEventSink.current (the rendered node's sink, or the service outside any node);eventSink (BaseScreenNode / BaseFlowNode / ParallelFlowNode), or service.eventSink(path) for a
custom node;NavigationService itself, which is an EventSink:
service.send(event) is the root sink (whole tree, never stale), equivalent to the root node's sink. A node's sink
falls back to the same whole-tree resolution for non-Back events nobody handles in its scope.Same threading rules as NavigationService (one thread; re-entrant sends are queued).
Way is a Harel statechart engine. Its runtime model corresponds directly to the W3C SCXML standard — in particular Appendix B, "Algorithm for SCXML Interpretation". If you know SCXML (or UML state machines), the concepts map one-to-one:
| Way concept | SCXML / statechart concept |
|---|---|
FlowNode<R> |
Compound (OR) state — <state> with children; exactly one child active at a time |
ParallelFlowNode<R> |
Parallel (AND) state — <parallel>; all regions active simultaneously |
ScreenNode |
Atomic state — a leaf <state> with no children |
FlowNode.initial |
The default/initial transition of a compound state (<initial>) |
| Region (sub-region of a parallel node) | Orthogonal region of a <parallel> state |
| Set of alive paths across all regions | The active configuration (the set of currently active states) |
| Navigating to a deep target auto-enters every intermediate state | Entry-set computation (computeEntrySet = addAncestorStatesToEnter + addDescendantStatesToEnter), scoped by the LCCA |
History node (type="history" / "deepHistory") → HistoryTarget
|
History pseudostate (<history>) — restore a state's most-recently-active configuration (see History) |
Entering a deep target automatically enters every state on the way in: each
intermediate compound state contributes its default/initial child, and each
intermediate parallel state contributes all of its regions. This is exactly
SCXML's entry-set computation, bounded by the LCCA (Least Common Compound
Ancestor) of the transition. Way implements these standard building blocks in
StatechartAlgorithm.kt: findLCCA, getTransitionDomain, computeExitSet,
getProperAncestors, and entryAncestors (the schema-static "ancestor fill"
half of computeEntrySet).
In a true statechart all parallel regions are active at the same time —
there is no notion of a "focused", "selected", or "current" region in the
control-flow sense. Which region is visible is a presentation concern,
orthogonal to the active-state set, and Way keeps it entirely on the app
side: the library stores no "current region". Your ParallelFlowNode
subclass holds its own field (e.g. var currentTab: RegionId) and uses it for
both rendering (in Content()) and Back routing — see
Parallel back dispatch.
"Back" is likewise not a statechart concept: it is layered on as a separate navigation facet, independent of the active-state configuration.
A history pseudostate restores the most-recently-active configuration of a flow or parallel
instead of its default initial. It is the SCXML <history> state, exposed as HistoryTarget.
When to reach for it — the one thing nothing else does: restore all regions of a non-root parallel that was fully torn down and later re-entered. The per-region back-stack and Back only resume a region while it is still alive; once a parallel is fully exited its alive chains are gone, so only recorded history can bring every region's leaf back.
Declare a childless history node under the flow/parallel whose configuration you want to remember:
digraph App {
package = "com.example"
appFlow [type=flow]
homeScreen [type=screen]
settingsParallel [type=parallelFlow]
settingsHist [type="deepHistory"] // history pseudostate for the parallel
appFlow -> homeScreen
appFlow -> settingsParallel
settingsParallel -> settingsHist
settingsParallel -> profileTab -> profileScreen -> editProfileScreen
settingsParallel -> devicesTab -> devicesScreen -> deviceDetailScreen
}The plugin generates a typed accessor in the enclosing flow's Targets class, whose path points at the
remembered flow/parallel (segment ids elided for readability):
// AppTargets
val settingsHist: HistoryTarget =
HistoryTarget(Path([appFlow, settingsParallel]), deep = true)Use it in a transition like any other target:
// appFlow's FlowNode
override fun transition(event: Event) = when (event) {
is OpenSettings -> NavigateTo(Target.appFlow.settingsHist) // resume Settings where the user left off
else -> Ignore
}Suppose the user drills profileTab → editProfile and devicesTab → deviceDetail, then backs all
the way out to Home — settingsParallel is fully destroyed. Re-opening:
NavigateTo(Target.appFlow.settingsParallel) (a plain FlowTarget) resets both tabs to their
defaults (profileScreen, devicesScreen).NavigateTo(Target.appFlow.settingsHist) (deep history) restores both tabs at once —
editProfileScreen and deviceDetailScreen. On the very first visit, with nothing recorded yet, it
falls back to the default initial, exactly like a FlowTarget.Shallow vs deep (type="history" vs type="deepHistory") differ only when the remembered child has
deeper state of its own:
onboarding -> wizard -> step1 // step1 is the default
wizard -> step2If the user was on wizard/step2 when onboarding was torn down, then re-entering via history:
type="deepHistory" restores wizard/step2 — the exact leaf you were on.type="history" (shallow) restores wizard but re-enters it at its default step1 — it remembers
which child was active, not how deep you had gone.They coincide when the child is a plain screen (nothing deeper to forget). For a parallel, deep
history restores every region's exact leaf, whereas shallow re-enters every region at its default (close
to a plain FlowTarget) — so for "resume the tabs exactly where I left off", reach for deepHistory.
When you don't need it. A root parallel (e.g. a bottom-nav bar that is never torn down) already keeps every tab alive, so the back-stack resumes each tab for free — no history required. Flows you re-compose explicitly (see Backing out of a cross-tab jump) don't need it either. Reach for history only for the genuine tear-down-and-return case above.
Parallel regions are all active at once, so Back must target one of them. Since "which region the user is looking at" is presentation state the library doesn't store, the parallel node decides in its ordinary transition(Event.Back):
DispatchBackTo(regionId) to route the structural back-pop into that region (supply the region from your own field);Stay to swallow Back, or Finish(...) / NavigateTo(...) to redirect;Ignore (the default) and Back goes to the deepest active region.// Tab bar: Back goes within the selected tab. The app owns "current tab".
class MainTabsNode : ParallelFlowNode<Unit>(), ComposableNode {
var currentTab: RegionId by mutableStateOf(homeRegionId); private set
override fun transition(event: Event) = when (event) {
is TabSelected -> { currentTab = event.regionId; Stay } // update own field
is Event.Back -> DispatchBackTo(currentTab) // route Back via own field
is LogoutRequested -> Finish(Unit)
else -> Ignore
}
}Pass a RegionId from NavigationState.regions keys or a generated <Schema>.<child>RegionId constant. A DispatchBackTo naming a region that is no longer alive is not an error — Back soft-falls-back to the deepest active region, so the back button never crashes. Back to a region which is not started yet (see initialRegions below) is ignored.
Transitions:
NavigateTo(targets)Finish(result)EnqueueEvent(event)NavigateAndEnqueue(navigate, events) — a NavigateTo plus follow-up events (build with navigateTo thenEnqueue event)DispatchBackTo(regionId) — from a ParallelFlowNode, route a Back-press into regionId (see Parallel back dispatch)StayIgnore (bubble to parent flow)A common tab-bar pattern: from tab A the user deep-jumps into a screen owned by tab B, then presses
Back expecting to both pop it in region B and return to tab A. Because presentation is
app-side, you drive it from the parallel node — track currentTab yourself, cross regions with
explicit AbsoluteTargets, and compose two hops:
var currentTab: RegionId by mutableStateOf(tabARegionId); private set
override fun transition(event: Event): FlowTransition<Unit> = when (event) {
// Hop 1: pop the deep screen in region B (still the current tab), then hand off to hop 2.
BackFromDeepScreen ->
NavigateTo(AbsoluteTarget(tabBHomePath)) thenEnqueue ReturnToTabA(event.target)
// Hop 2: switch the app's tab to A and navigate A to the target.
is ReturnToTabA -> {
currentTab = tabARegionId
NavigateTo(AbsoluteTarget(tabATargetPath))
}
else -> Ignore
}The app updates currentTab itself; the library stores nothing. Cross-region targets are absolute
(explicit) rather than resolved against a "focused" region. If tab A should simply be restored as the
user left it, hop 2 collapses to { currentTab = tabARegionId; Stay }. Note the region-B pop is a
NavigateTo(AbsoluteTarget(tabBHomePath)), which resets region B to that screen rather than
popping exactly one entry — fine when the deep screen sits directly on tab B's home, but be aware it
also drops any intermediate screens.
NavigationService behavior:
start(payload) sends internal init event and enters root flow(s).NavigationState with per-region active/alive node paths.All regions of a parallel node start together with it. To start some of them later (a tab which loads on its first
selection), list the ones which start at once in initialRegions and start the others with startRegion:
class HomeNode : ParallelFlowNode<Unit>(), ComposableNode {
private var focused by mutableStateOf(schema.mainTabRegionId)
override val initialRegions get() = setOf(schema.mainTabRegionId)
override fun transition(event: Event): FlowTransition<Unit> = when (event) {
is TabSelected -> {
focused = event.regionId
startRegion(event.regionId) // Stay if the region is already started
}
Event.Back -> DispatchBackTo(focused)
else -> Ignore
}
@Composable
override fun Content(modifier: Modifier) {
FocusedRegionHost(focused, modifier)
}
}A region which is not started has no nodes and receives no events. A NavigateTo with a target inside of it
starts it too. A started region lives as long as its parallel node: the regions of a
parallel nested in a flow start when this parallel is entered and are removed when it is left. A region of the root
parallel which startRegion starts gets the payload of service.start(payload).
initialRegions must name at least one region, and a region whose root is itself a parallel flow cannot be started
later.
FocusedRegionHost (way-compose) composes only the given region: the other ones cannot show a dialog or a sheet,
and their saved state is restored when they get focus again. Its modifier goes to the container which switches
the regions, contentModifier to the content of the shown one.
Inside of transition a parallel node can ask about its regions: isRegionStarted(regionId) and
isRegionAtRoot(regionId) (the region rests on its first node, nothing is open above it). The second one picks the
region for Back when one of the regions is an overlay:
Event.Back -> DispatchBackTo(if (isRegionAtRoot(schema.fullScreenRegionId)) focused else schema.fullScreenRegionId)A parallel node which needs hooks (for example a coroutine scope hook) extends BaseParallelFlowNode, the same way
a flow node extends BaseFlowNode.
way-compose provides:
ComposableNode interface with @Composable fun Content(modifier: Modifier)
ParallelFlowNode renders by implementing ComposableNode — its Content() lays out the parallel and calls NodeHost(regionId) inside it to render each sub-region's screen stack.LocalNavigationService — CompositionLocal<NavigationService<*>> provided by NodeHost(service). Available inside any Content() for reading state.NodeHost(service) composable — auto-starts service, observes root region's active node, renders ComposableNode.Content(), applies animated transitions.LocalEventSink — the EventSink of the node being rendered, provided by NodeHost to every node's Content(). Always use it in UI: an event sent from a screen's Content() starts at that screen, one sent from a parallel's Content() at the active children of its regions; it then reaches every parallel enclosing the node and bubbles up through each parallel's ancestors, like service.send, and a tap on a screen which is animating out after Back is dropped instead of being applied to the new screen. Outside any node (but under LocalNavigationService) it is the service itself, the root sink.NodeHost(regionId) composable — renders the active screen in a specific sub-region. Call from a parallel node's Content() for each sub-region. Requires a parent NodeHost(service) to have provided LocalNavigationService.FocusedRegionHost(regionId) composable — renders only the given sub-region and keeps the saved state of the other ones.rememberTransitionSpec() — a transition spec which slides on push and pop (by comparing the paths) and asks ambiguousTransitionResolver for the rest; pushTransition(), popTransition(), fadeTransition(), noTransition() are its parts.Sending screen events:
class DetailsNode(private val id: String) : ScreenNode, ComposableNode {
@Composable
override fun Content(modifier: Modifier) {
val sink = LocalEventSink.current
Button(onClick = { sink.send(PackageClicked(packageId = "p1")) }) { Text("Open package") }
}
}
// in the flow: is PackageClicked -> NavigateTo(Target.myESimFlow.packageDetails(event.packageId))Parallel node example (tab bar with state preservation):
class MainTabsNode : ParallelFlowNode<Unit>(), ComposableNode {
override val dismissResult = Unit
// homeRegionId / exploreRegionId come from generated MainTabsSchema constants.
// The app owns "current tab" — the library stores nothing.
var currentTab: RegionId by mutableStateOf(homeRegionId); private set
override fun transition(event: Event) = when (event) {
is TabSelected -> { currentTab = event.regionId; Stay } // update own field
is Event.Back -> DispatchBackTo(currentTab) // Back follows the visible tab
else -> Ignore
}
@Composable
override fun Content(modifier: Modifier) {
val regionIds = listOf(homeRegionId, exploreRegionId)
Scaffold(modifier, bottomBar = { TabBar(selected = currentTab) }) { padding ->
// Render all regions simultaneously: state is preserved across tab switches
regionIds.forEach { regionId ->
key(regionId) {
Box(if (regionId == currentTab) Modifier.padding(padding) else Modifier.size(0.dp)) {
NodeHost(regionId)
}
}
}
}
}
}The :way runtime is UI-agnostic — it contains no rendering code and no UI-framework
dependencies. way-compose is a reference implementation of a pluggable UI module, not a
privileged one: everything it does goes through the public API of :way, so an integration for
another UI framework (Android Views, SwiftUI, desktop, …) can be built the same way as a separate
way-view-style module, with zero changes to the core.
This mirrors the SCXML heritage: the statechart standard deliberately has no presentation concept, so all rendering lives outside the core, in per-framework modules.
A UI integration needs only these public APIs:
service.addTransitionListener { state: NavigationState -> ... } — called with the
new state after every committed transition (remove the listener with removeTransitionListener
when the host is torn down). Call service.start() on first attach (guard with isStarted()).state.regions[regionId].active / .activeNode — the currently active leaf of each region;
this is the node to render for that region.state.rootNode / state.rootNodePath — non-null only when the schema's root is a
ParallelFlowNode (it owns no region of its own; its sub-regions live one level deeper).
Render this node's container chrome at the top and host each sub-region inside it.service.cleanDispose() (fires onDispose() leaf-to-root and clears listeners)
when the host owns the service and leaves; plain hosts just remove their listener.way-compose demonstrates the three pieces an integration provides:
way-compose has
ComposableNode { @Composable fun Content(modifier) }; a hypothetical way-view would have
e.g. ViewNode { fun createView(context: Context): View }. Node classes opt into rendering by
implementing it; the runtime never sees this interface.NodeHost(service) in way-compose).NodeHost(regionId) in way-compose).A regular FlowNode (an OR-state) shows exactly one child at a time — there is nothing for it to
lay out, so it stays headless. A ParallelFlowNode (an AND-state) has all sub-regions active
simultaneously, which raises a question only presentation can answer: how do N regions share the
screen — tabs, side-by-side panes, an overlay sheet? That layout decision belongs to the app, so
the parallel node's subclass provides it through the UI module's opt-in interface, exactly the way
screens do. Routing (transition()) stays UI-independent either way.
If your node classes must not depend on any UI framework (shared routing modules, KMP), hold
presentation state such as the selected tab in a UI-neutral observable — e.g.
MutableStateFlow<RegionId> (:way already uses kotlinx-coroutines) instead of Compose's
mutableStateOf — and keep the framework-specific rendering in a module above the routing one.
The field itself stays on the node either way, because transition(Event.Back) uses it for
DispatchBackTo(currentTab).
.dot schema file(s) under src/*/way.FlowNode, ScreenNode, etc.).*NodeBuilder.Factory (often via DI).object AppFlow {
fun nodeBuilder(component: AppFlowComponent): AppNodeBuilder =
AppNodeBuilder(component.nodeFactory(), schema)
val schema: AppSchema = AppSchema(
LoginFlow.schema,
MainFlow.schema,
)
}NavigationService and route events.NodeHost(service).Run from repo root:
./gradlew spotlessCheck
./gradlew spotlessApply
./gradlew :way-gradle-plugin:test
./gradlew :way:jvmTest
./gradlew :sample:assemble
./gradlew :sample-compose:app:assembleDebugWorkflows:
.github/workflows/ci.yml - PR validation (Spotless + tests + sample-compose assemble matrix)..github/workflows/release.yml - publish :way and :way-compose to Maven Central on tag..github/workflows/release-plugin.yml - publish :way-gradle-plugin to Gradle Plugin Portal on tag.Tag rule for release workflows:
versionName from root gradle.properties
<version> and v<version> are acceptedRoot gradle.properties is the single source for:
versionNamepomGroupIdpomName, pomDescription, licenses, SCM, developer info, etc.)Library modules use Vanniktech Maven Publish plugin and read these properties. Gradle plugin publication also resolves version/group from project/shared properties.
:way-gradle-plugin and excluded from Javadoc warnings.org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled).@Composable function naming allowance.Build -> Assemble Project only executes tasks for selected/needed modules; if a module is not part of that task graph, its generateWayClasses task will not run and no generated folder will appear for that module in that build invocation.Way is a navigation library built around statechart-like node graphs.
Way's model is a Harel statechart and maps directly onto the W3C SCXML standard: flows are compound (OR) states, parallel nodes are parallel (AND) states, and screens are atomic states. See Relationship to statecharts / SCXML for the full concept mapping.
A graph is defined in .dot files, and the ru.kode.way Gradle plugin generates:
The runtime (:way) executes transitions, keeps navigation state, and supports extension points.
The Compose integration (:way-compose) renders active nodes with NodeHost.
:way - Runtime library structured as a Kotlin Multiplatform project (common + JVM). Current release targets JVM/Android only. iOS and other native targets are planned for a future release.
:way-compose - Android Compose integration for NavigationService.:way-gradle-plugin - Gradle plugin (id("ru.kode.way")) that generates code from .dot schemas.:sample - JVM sample (KMP multi-target support is not yet released).:sample-compose:* - Android sample split into feature modules.
:sample-compose:main-parallel:* - tab-bar example using ParallelFlowNode (see Compose Integration).:sample-compose:app:routing - google/huawei product flavors with a per-flavor .dot extension (see Flavor-Specific Routing).8.0+ (plugin checks this at apply time).2.3.20.Notes:
gradle.properties.dependencies {
implementation("ru.kode:way:<version>")
implementation("ru.kode:way-compose:<version>") // optional, for Compose host
}plugins {
id("ru.kode.way") version "<version>"
}For Android modules that also use KSP, apply KSP plugin as usual:
plugins {
id("com.google.devtools.ksp")
}Place schema files in a sibling way directory next to your Kotlin sources:
src/main/kotlin -> src/main/way
src/commonMain/kotlin -> src/commonMain/way
src/commonTest/kotlin -> src/commonTest/way (for KMP test generation)Example:
digraph App {
package = "ru.kode.way.sample.compose.app.routing"
app [type=flow]
login [type=schema, resultType = "ru.kode.way.sample.compose.login.routing.LoginFlowResult"]
main [type=schema, resultType = "ru.kode.way.sample.compose.main.routing.MainFlowResult"]
app -> login
app -> main
}package - output package for generated code.schemaFileName - override generated schema file/class base name.targetsFileName - override generated targets file/class base name.mode = "extend" - the file adds nodes and edges to the schema with the same graph id instead of declaring one
(see Flavor-Specific Routing).type
flow - local flow node.schema - imported child schema flow.parallelFlow - parallel flow node (all sub-regions active at once).history / deepHistory - a history pseudostate: a childless leaf declared under a plain flow or a parallel (parentFlow -> histNodeId). Generates a typed HistoryTarget accessor and is codegen-time-only (it never becomes a runtime Schema.NodeType).resultType - result type for Finish(...) from a flow.parameterName / parameterType - typed payload for that node target.All nodes without type are treated as screen nodes.
The plugin registers:
generateWayClassesgenerateTestWayClasses (for commonTest in KMP)Generated sources are written under:
build/generated/way/code/<sourceSet>/...Typical generated types (from graph id App):
AppSchemaAppTargets (+ Target.Companion.app accessor)AppNodeBuilder and nested AppNodeBuilder.Factory
AppChildFinishRequest (sealed interface with nested child events, when child flows exist)<ParallelName>NodeBuilder also emits named val <child>RegionId: RegionId properties for each sub-region, so callers never need to hardcode RegionId(Path(...)) strings.For main -> details(eSimId) -> packageDetails(packageId) two builders are generated:
Target.myESimFlow.packageDetails(packageId) // short: only the node's own parameter
Target.myESimFlow.packageDetails(eSimId, packageId) // full: every parameterized node on the pathdetails's own transitions. A param-less node gets a property (Target.myESimFlow.info).FlowNode.initial, AbsoluteTarget or a jump into a branch which is not alive.If a short target is sent after its ancestor was left (e.g. a late tap after Back), the event is dropped, see
Dropped events. An ancestor parameter whose name clashes with another one on the path is prefixed
with the node id (detailsId).
An imported schema reachable from several parents (app -> a -> perm, app -> b -> perm, perm [type = schema]) gets
one target per parent path, named <node>Via<Parent>:
Target.app.permViaA(reason) // short
Target.app.permViaB(bId, reason) // full: payloads of the `b` path onlyWith a single parent the plain name (perm) is kept. The schema is built with the payloads of the path it was entered
through, and its finish request reaches that path's parent. Targets inside the imported schema need no parent in their
name: they are resolved relative to the alive instance of that schema. All parents must be in the same region, and
none of them can be a parallel node.
commonMain (and commonTest when present).ksp<Variant>Kotlin tasks depend on generation and receive generated roots.main.Android product flavors can each get their own .dot file for the same schema — e.g. a step only
some flavors need. Priority of the source sets, low to high:
main < flavor < build type < flavor+buildType variant-specific.
A flavor file either extends the schema or replaces it.
Extend. A file with the mode = "extend" graph attribute holds only what the flavor adds. Its nodes and
edges are added to the schema with the same graph id, and the result is validated as a whole:
src/main/way/app-flow.dot # app -> main
src/google/way/app-flow.dot # extension: app -> login (only google has a login step)
digraph App {
mode = "extend"
login [type=schema, resultType = "ru.kode.way.sample.compose.login.routing.LoginFlowResult"]
app -> login
}digraph App), so the file may have any name. With the name of
the base file it sits next to it in the flavor's source set, as above.package, schemaFileName, targetsFileName. Attributes it gives to an untyped node of the schema (a
screen's parameter) do overwrite the schema's ones, as a repeated declaration in one file does.Replace. A file without the attribute at src/<flavor>/way/<name>.dot fully replaces the file at the
same relative path in a lower-priority source set (together with the extensions at this path), exactly like
Android resource merging. Use it when a flavor has to drop something.
The plugin registers one generate<Variant>WayClasses task per Android variant
(generateGoogleDebugWayClasses, generateHuaweiDebugWayClasses, ...), each writing to
build/generated/way/code/<variant>/. A flavor without files of its own simply reuses
main's file as its own input — there is no generated/way/code/main/, since main is a
contributing source set, never a buildable variant on its own.
The gotcha: hand-written code can't straddle flavors. Whichever way a flavor changes a schema, its generated
code differs, while ordinary Kotlin/Java source sets (src/main/kotlin, src/google/kotlin,
src/huawei/kotlin) use plain Gradle union — every flavor's compilation always includes
main/kotlin. So if one flavor's schema has a node another flavor's doesn't, the
flavor missing it won't generate the matching class member (e.g. AppChildFinishRequest.Login),
and hand-written code in src/main/kotlin referencing it fails with Unresolved reference for
that flavor.
Rule: anything touching a symbol that isn't in every flavor's generated output must live in
that flavor's own Kotlin source set (src/google/kotlin, src/huawei/kotlin, ...), not
src/main/kotlin. Code that stays in main may only reference the intersection of what every
flavor generates. The node factory is such code: the generated Factory interface has a create… method
for every node, so a flavor with an added node needs its own factory class (the shared part can stay in main
as an abstract class).
Worked example: sample-compose/app/routing (google / huawei flavors) —
src/google/way/app-flow.dot extends src/main/way/app-flow.dot with a Google-only login
step, and the hand-written AppFlow.kt / AppFlowNode.kt / di/AppFlowComponent.kt are
duplicated per flavor under src/google/kotlin / src/huawei/kotlin accordingly.
src/test/kotlin/.../AppFlowNodeTest.kt is a shared test that only touches the symbols common to
both flavors, compiled once per flavor to prove both generate usable code.
Core runtime types in :way:
FlowNode<R>, ScreenNode, ParallelFlowNode<R>
NavigationService<R>FlowTransition / ScreenTransition (a ParallelFlowNode returns FlowTransition<R>)Target (FlowTarget, ScreenTarget, AbsoluteTarget, HistoryTarget)NavigationService drops an event, instead of applying its transition, when:
DropReason.MissingPayload(path): the target needs the parameterized node at path, which is not alive and has no
payload (a short target sent after its flow was left).DropReason.StaleSource(path): the event was sent through an EventSink of a node which has left navigation, or
was recreated, by the time the event is dispatched.A dropped event changes nothing: state is rolled back, no node lifecycle callbacks and no transition listeners run,
and the remaining enqueued events are still processed. A drop is all-or-nothing: a multi-target NavigateTo or an
event broadcast to parallel regions is dropped entirely when any of its targets misses a payload. The event is resolved
before onPreTransition, so neither onPreTransition nor onPostTransition is called for it (except for a payload
missed by a hand-rolled Schema which does not report the node as parameterized: that one is only caught when the node
builder throws MissingPayloadException, after onPreTransition). Every ServiceExtensionPoint.onEventDropped is
called:
service.addServiceExtensionPoint(object : ServiceExtensionPoint<Unit> {
override fun onPreTransition(service: NavigationService<Unit>, event: Event, state: NavigationState) = Unit
override fun onPostTransition(service: NavigationService<Unit>, event: Event, state: NavigationState) = Unit
override fun onEventDropped(service: NavigationService<Unit>, event: Event, reason: DropReason) {
log.w("dropped $event: $reason")
}
})With service.strictEventDropping = true, send (on the service or any sink) throws
EventDroppedException(event, reason) after the rollback for a DropReason.MissingPayload (a bug in the app's
targets). A DropReason.StaleSource is an expected race (a tap on a leaving screen) and is never thrown. Events still enqueued after the thrown one stay queued for the next
send. The default is false, so apps which construct NavigationService directly are lenient unless they
enable it (recommended for debug builds and tests). NodeHost(nodeBuilder, ...) sets it from the app's
FLAG_DEBUGGABLE. A missing payload during start() is always rethrown, without calling onEventDropped.
NavigateTo a node which is already alive with a different argument rebuilds it: the node and its alive
descendants get onExit, are built again with the new argument, get onEntry and a new generation, so sinks
obtained from the old instances become stale. An equal argument keeps the node as it is.HistoryTarget into a parameterized node which is not alive rebuilds it with the argument it had when it was left:
the history record keeps the payloads of the nodes it left and is replaced on every exit, so it holds at most one
configuration. A node which is still alive keeps its current argument.service.eventSink(path) returns an EventSink bound to the node instance alive at path. Its events:
Ignore through the node to its ancestors within the region, and (except Event.Back) also reach
every parallel enclosing the node and bubble up through each parallel's ancestors, like service.send (same region
order, same merge);service.send would ask it, so a screen in region A can send an event only a node in sibling
region B handles. An event handled in scope is not sent again, and the nodes already asked in scope are not asked
again on the fallback, so each node gets the event at most once. A node which does its work and answers Ignore
does not stop the fallback, and neither does a parallel answering Stay;Event.Back stays in the node's region (never reaching enclosing parallels or sibling regions): a parallel's sink
routes it through that parallel's DispatchBackTo, a sink of a node inside a region keeps it in that region;DropReason.StaleSource.service.nodeGeneration(path) returns the generation of the node instance alive at path (null if none); it changes
whenever the node is recreated, so it is a stable cache key for a sink (NodeHost keys LocalEventSink on it). A
scheduler set with setEnqueuedEventsScheduler receives the plain event sent through a sink; the sink's source is
kept when the scheduled event is sent back.
BaseScreenNode, BaseFlowNode and ParallelFlowNode expose their own sink as eventSink, attached right before
every onEntry (usable in onEntry and entry hooks; reading it before the first entry throws). It is not cleared on
exit: an event sent through it after the node left is dropped with DropReason.StaleSource. Every entry attaches a new
sink, so if the node builder returns the same instance on re-entry, a later read of eventSink yields the new entry's
live sink. For async work (and presenters) capture it first, val sink = eventSink in onEntry or before launching
the work: the captured sink goes stale when that entry ends. Events a node sends from onEntry of a transition which is
then rolled back are discarded without onEventDropped, since that entry never happened. A node re-entered by the
rollback keeps (and can use again) the sink of its original entry.
Events go into an EventSink; take the nearest one:
LocalEventSink.current (the rendered node's sink, or the service outside any node);eventSink (BaseScreenNode / BaseFlowNode / ParallelFlowNode), or service.eventSink(path) for a
custom node;NavigationService itself, which is an EventSink:
service.send(event) is the root sink (whole tree, never stale), equivalent to the root node's sink. A node's sink
falls back to the same whole-tree resolution for non-Back events nobody handles in its scope.Same threading rules as NavigationService (one thread; re-entrant sends are queued).
Way is a Harel statechart engine. Its runtime model corresponds directly to the W3C SCXML standard — in particular Appendix B, "Algorithm for SCXML Interpretation". If you know SCXML (or UML state machines), the concepts map one-to-one:
| Way concept | SCXML / statechart concept |
|---|---|
FlowNode<R> |
Compound (OR) state — <state> with children; exactly one child active at a time |
ParallelFlowNode<R> |
Parallel (AND) state — <parallel>; all regions active simultaneously |
ScreenNode |
Atomic state — a leaf <state> with no children |
FlowNode.initial |
The default/initial transition of a compound state (<initial>) |
| Region (sub-region of a parallel node) | Orthogonal region of a <parallel> state |
| Set of alive paths across all regions | The active configuration (the set of currently active states) |
| Navigating to a deep target auto-enters every intermediate state | Entry-set computation (computeEntrySet = addAncestorStatesToEnter + addDescendantStatesToEnter), scoped by the LCCA |
History node (type="history" / "deepHistory") → HistoryTarget
|
History pseudostate (<history>) — restore a state's most-recently-active configuration (see History) |
Entering a deep target automatically enters every state on the way in: each
intermediate compound state contributes its default/initial child, and each
intermediate parallel state contributes all of its regions. This is exactly
SCXML's entry-set computation, bounded by the LCCA (Least Common Compound
Ancestor) of the transition. Way implements these standard building blocks in
StatechartAlgorithm.kt: findLCCA, getTransitionDomain, computeExitSet,
getProperAncestors, and entryAncestors (the schema-static "ancestor fill"
half of computeEntrySet).
In a true statechart all parallel regions are active at the same time —
there is no notion of a "focused", "selected", or "current" region in the
control-flow sense. Which region is visible is a presentation concern,
orthogonal to the active-state set, and Way keeps it entirely on the app
side: the library stores no "current region". Your ParallelFlowNode
subclass holds its own field (e.g. var currentTab: RegionId) and uses it for
both rendering (in Content()) and Back routing — see
Parallel back dispatch.
"Back" is likewise not a statechart concept: it is layered on as a separate navigation facet, independent of the active-state configuration.
A history pseudostate restores the most-recently-active configuration of a flow or parallel
instead of its default initial. It is the SCXML <history> state, exposed as HistoryTarget.
When to reach for it — the one thing nothing else does: restore all regions of a non-root parallel that was fully torn down and later re-entered. The per-region back-stack and Back only resume a region while it is still alive; once a parallel is fully exited its alive chains are gone, so only recorded history can bring every region's leaf back.
Declare a childless history node under the flow/parallel whose configuration you want to remember:
digraph App {
package = "com.example"
appFlow [type=flow]
homeScreen [type=screen]
settingsParallel [type=parallelFlow]
settingsHist [type="deepHistory"] // history pseudostate for the parallel
appFlow -> homeScreen
appFlow -> settingsParallel
settingsParallel -> settingsHist
settingsParallel -> profileTab -> profileScreen -> editProfileScreen
settingsParallel -> devicesTab -> devicesScreen -> deviceDetailScreen
}The plugin generates a typed accessor in the enclosing flow's Targets class, whose path points at the
remembered flow/parallel (segment ids elided for readability):
// AppTargets
val settingsHist: HistoryTarget =
HistoryTarget(Path([appFlow, settingsParallel]), deep = true)Use it in a transition like any other target:
// appFlow's FlowNode
override fun transition(event: Event) = when (event) {
is OpenSettings -> NavigateTo(Target.appFlow.settingsHist) // resume Settings where the user left off
else -> Ignore
}Suppose the user drills profileTab → editProfile and devicesTab → deviceDetail, then backs all
the way out to Home — settingsParallel is fully destroyed. Re-opening:
NavigateTo(Target.appFlow.settingsParallel) (a plain FlowTarget) resets both tabs to their
defaults (profileScreen, devicesScreen).NavigateTo(Target.appFlow.settingsHist) (deep history) restores both tabs at once —
editProfileScreen and deviceDetailScreen. On the very first visit, with nothing recorded yet, it
falls back to the default initial, exactly like a FlowTarget.Shallow vs deep (type="history" vs type="deepHistory") differ only when the remembered child has
deeper state of its own:
onboarding -> wizard -> step1 // step1 is the default
wizard -> step2If the user was on wizard/step2 when onboarding was torn down, then re-entering via history:
type="deepHistory" restores wizard/step2 — the exact leaf you were on.type="history" (shallow) restores wizard but re-enters it at its default step1 — it remembers
which child was active, not how deep you had gone.They coincide when the child is a plain screen (nothing deeper to forget). For a parallel, deep
history restores every region's exact leaf, whereas shallow re-enters every region at its default (close
to a plain FlowTarget) — so for "resume the tabs exactly where I left off", reach for deepHistory.
When you don't need it. A root parallel (e.g. a bottom-nav bar that is never torn down) already keeps every tab alive, so the back-stack resumes each tab for free — no history required. Flows you re-compose explicitly (see Backing out of a cross-tab jump) don't need it either. Reach for history only for the genuine tear-down-and-return case above.
Parallel regions are all active at once, so Back must target one of them. Since "which region the user is looking at" is presentation state the library doesn't store, the parallel node decides in its ordinary transition(Event.Back):
DispatchBackTo(regionId) to route the structural back-pop into that region (supply the region from your own field);Stay to swallow Back, or Finish(...) / NavigateTo(...) to redirect;Ignore (the default) and Back goes to the deepest active region.// Tab bar: Back goes within the selected tab. The app owns "current tab".
class MainTabsNode : ParallelFlowNode<Unit>(), ComposableNode {
var currentTab: RegionId by mutableStateOf(homeRegionId); private set
override fun transition(event: Event) = when (event) {
is TabSelected -> { currentTab = event.regionId; Stay } // update own field
is Event.Back -> DispatchBackTo(currentTab) // route Back via own field
is LogoutRequested -> Finish(Unit)
else -> Ignore
}
}Pass a RegionId from NavigationState.regions keys or a generated <Schema>.<child>RegionId constant. A DispatchBackTo naming a region that is no longer alive is not an error — Back soft-falls-back to the deepest active region, so the back button never crashes. Back to a region which is not started yet (see initialRegions below) is ignored.
Transitions:
NavigateTo(targets)Finish(result)EnqueueEvent(event)NavigateAndEnqueue(navigate, events) — a NavigateTo plus follow-up events (build with navigateTo thenEnqueue event)DispatchBackTo(regionId) — from a ParallelFlowNode, route a Back-press into regionId (see Parallel back dispatch)StayIgnore (bubble to parent flow)A common tab-bar pattern: from tab A the user deep-jumps into a screen owned by tab B, then presses
Back expecting to both pop it in region B and return to tab A. Because presentation is
app-side, you drive it from the parallel node — track currentTab yourself, cross regions with
explicit AbsoluteTargets, and compose two hops:
var currentTab: RegionId by mutableStateOf(tabARegionId); private set
override fun transition(event: Event): FlowTransition<Unit> = when (event) {
// Hop 1: pop the deep screen in region B (still the current tab), then hand off to hop 2.
BackFromDeepScreen ->
NavigateTo(AbsoluteTarget(tabBHomePath)) thenEnqueue ReturnToTabA(event.target)
// Hop 2: switch the app's tab to A and navigate A to the target.
is ReturnToTabA -> {
currentTab = tabARegionId
NavigateTo(AbsoluteTarget(tabATargetPath))
}
else -> Ignore
}The app updates currentTab itself; the library stores nothing. Cross-region targets are absolute
(explicit) rather than resolved against a "focused" region. If tab A should simply be restored as the
user left it, hop 2 collapses to { currentTab = tabARegionId; Stay }. Note the region-B pop is a
NavigateTo(AbsoluteTarget(tabBHomePath)), which resets region B to that screen rather than
popping exactly one entry — fine when the deep screen sits directly on tab B's home, but be aware it
also drops any intermediate screens.
NavigationService behavior:
start(payload) sends internal init event and enters root flow(s).NavigationState with per-region active/alive node paths.All regions of a parallel node start together with it. To start some of them later (a tab which loads on its first
selection), list the ones which start at once in initialRegions and start the others with startRegion:
class HomeNode : ParallelFlowNode<Unit>(), ComposableNode {
private var focused by mutableStateOf(schema.mainTabRegionId)
override val initialRegions get() = setOf(schema.mainTabRegionId)
override fun transition(event: Event): FlowTransition<Unit> = when (event) {
is TabSelected -> {
focused = event.regionId
startRegion(event.regionId) // Stay if the region is already started
}
Event.Back -> DispatchBackTo(focused)
else -> Ignore
}
@Composable
override fun Content(modifier: Modifier) {
FocusedRegionHost(focused, modifier)
}
}A region which is not started has no nodes and receives no events. A NavigateTo with a target inside of it
starts it too. A started region lives as long as its parallel node: the regions of a
parallel nested in a flow start when this parallel is entered and are removed when it is left. A region of the root
parallel which startRegion starts gets the payload of service.start(payload).
initialRegions must name at least one region, and a region whose root is itself a parallel flow cannot be started
later.
FocusedRegionHost (way-compose) composes only the given region: the other ones cannot show a dialog or a sheet,
and their saved state is restored when they get focus again. Its modifier goes to the container which switches
the regions, contentModifier to the content of the shown one.
Inside of transition a parallel node can ask about its regions: isRegionStarted(regionId) and
isRegionAtRoot(regionId) (the region rests on its first node, nothing is open above it). The second one picks the
region for Back when one of the regions is an overlay:
Event.Back -> DispatchBackTo(if (isRegionAtRoot(schema.fullScreenRegionId)) focused else schema.fullScreenRegionId)A parallel node which needs hooks (for example a coroutine scope hook) extends BaseParallelFlowNode, the same way
a flow node extends BaseFlowNode.
way-compose provides:
ComposableNode interface with @Composable fun Content(modifier: Modifier)
ParallelFlowNode renders by implementing ComposableNode — its Content() lays out the parallel and calls NodeHost(regionId) inside it to render each sub-region's screen stack.LocalNavigationService — CompositionLocal<NavigationService<*>> provided by NodeHost(service). Available inside any Content() for reading state.NodeHost(service) composable — auto-starts service, observes root region's active node, renders ComposableNode.Content(), applies animated transitions.LocalEventSink — the EventSink of the node being rendered, provided by NodeHost to every node's Content(). Always use it in UI: an event sent from a screen's Content() starts at that screen, one sent from a parallel's Content() at the active children of its regions; it then reaches every parallel enclosing the node and bubbles up through each parallel's ancestors, like service.send, and a tap on a screen which is animating out after Back is dropped instead of being applied to the new screen. Outside any node (but under LocalNavigationService) it is the service itself, the root sink.NodeHost(regionId) composable — renders the active screen in a specific sub-region. Call from a parallel node's Content() for each sub-region. Requires a parent NodeHost(service) to have provided LocalNavigationService.FocusedRegionHost(regionId) composable — renders only the given sub-region and keeps the saved state of the other ones.rememberTransitionSpec() — a transition spec which slides on push and pop (by comparing the paths) and asks ambiguousTransitionResolver for the rest; pushTransition(), popTransition(), fadeTransition(), noTransition() are its parts.Sending screen events:
class DetailsNode(private val id: String) : ScreenNode, ComposableNode {
@Composable
override fun Content(modifier: Modifier) {
val sink = LocalEventSink.current
Button(onClick = { sink.send(PackageClicked(packageId = "p1")) }) { Text("Open package") }
}
}
// in the flow: is PackageClicked -> NavigateTo(Target.myESimFlow.packageDetails(event.packageId))Parallel node example (tab bar with state preservation):
class MainTabsNode : ParallelFlowNode<Unit>(), ComposableNode {
override val dismissResult = Unit
// homeRegionId / exploreRegionId come from generated MainTabsSchema constants.
// The app owns "current tab" — the library stores nothing.
var currentTab: RegionId by mutableStateOf(homeRegionId); private set
override fun transition(event: Event) = when (event) {
is TabSelected -> { currentTab = event.regionId; Stay } // update own field
is Event.Back -> DispatchBackTo(currentTab) // Back follows the visible tab
else -> Ignore
}
@Composable
override fun Content(modifier: Modifier) {
val regionIds = listOf(homeRegionId, exploreRegionId)
Scaffold(modifier, bottomBar = { TabBar(selected = currentTab) }) { padding ->
// Render all regions simultaneously: state is preserved across tab switches
regionIds.forEach { regionId ->
key(regionId) {
Box(if (regionId == currentTab) Modifier.padding(padding) else Modifier.size(0.dp)) {
NodeHost(regionId)
}
}
}
}
}
}The :way runtime is UI-agnostic — it contains no rendering code and no UI-framework
dependencies. way-compose is a reference implementation of a pluggable UI module, not a
privileged one: everything it does goes through the public API of :way, so an integration for
another UI framework (Android Views, SwiftUI, desktop, …) can be built the same way as a separate
way-view-style module, with zero changes to the core.
This mirrors the SCXML heritage: the statechart standard deliberately has no presentation concept, so all rendering lives outside the core, in per-framework modules.
A UI integration needs only these public APIs:
service.addTransitionListener { state: NavigationState -> ... } — called with the
new state after every committed transition (remove the listener with removeTransitionListener
when the host is torn down). Call service.start() on first attach (guard with isStarted()).state.regions[regionId].active / .activeNode — the currently active leaf of each region;
this is the node to render for that region.state.rootNode / state.rootNodePath — non-null only when the schema's root is a
ParallelFlowNode (it owns no region of its own; its sub-regions live one level deeper).
Render this node's container chrome at the top and host each sub-region inside it.service.cleanDispose() (fires onDispose() leaf-to-root and clears listeners)
when the host owns the service and leaves; plain hosts just remove their listener.way-compose demonstrates the three pieces an integration provides:
way-compose has
ComposableNode { @Composable fun Content(modifier) }; a hypothetical way-view would have
e.g. ViewNode { fun createView(context: Context): View }. Node classes opt into rendering by
implementing it; the runtime never sees this interface.NodeHost(service) in way-compose).NodeHost(regionId) in way-compose).A regular FlowNode (an OR-state) shows exactly one child at a time — there is nothing for it to
lay out, so it stays headless. A ParallelFlowNode (an AND-state) has all sub-regions active
simultaneously, which raises a question only presentation can answer: how do N regions share the
screen — tabs, side-by-side panes, an overlay sheet? That layout decision belongs to the app, so
the parallel node's subclass provides it through the UI module's opt-in interface, exactly the way
screens do. Routing (transition()) stays UI-independent either way.
If your node classes must not depend on any UI framework (shared routing modules, KMP), hold
presentation state such as the selected tab in a UI-neutral observable — e.g.
MutableStateFlow<RegionId> (:way already uses kotlinx-coroutines) instead of Compose's
mutableStateOf — and keep the framework-specific rendering in a module above the routing one.
The field itself stays on the node either way, because transition(Event.Back) uses it for
DispatchBackTo(currentTab).
.dot schema file(s) under src/*/way.FlowNode, ScreenNode, etc.).*NodeBuilder.Factory (often via DI).object AppFlow {
fun nodeBuilder(component: AppFlowComponent): AppNodeBuilder =
AppNodeBuilder(component.nodeFactory(), schema)
val schema: AppSchema = AppSchema(
LoginFlow.schema,
MainFlow.schema,
)
}NavigationService and route events.NodeHost(service).Run from repo root:
./gradlew spotlessCheck
./gradlew spotlessApply
./gradlew :way-gradle-plugin:test
./gradlew :way:jvmTest
./gradlew :sample:assemble
./gradlew :sample-compose:app:assembleDebugWorkflows:
.github/workflows/ci.yml - PR validation (Spotless + tests + sample-compose assemble matrix)..github/workflows/release.yml - publish :way and :way-compose to Maven Central on tag..github/workflows/release-plugin.yml - publish :way-gradle-plugin to Gradle Plugin Portal on tag.Tag rule for release workflows:
versionName from root gradle.properties
<version> and v<version> are acceptedRoot gradle.properties is the single source for:
versionNamepomGroupIdpomName, pomDescription, licenses, SCM, developer info, etc.)Library modules use Vanniktech Maven Publish plugin and read these properties. Gradle plugin publication also resolves version/group from project/shared properties.
:way-gradle-plugin and excluded from Javadoc warnings.org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled).@Composable function naming allowance.Build -> Assemble Project only executes tasks for selected/needed modules; if a module is not part of that task graph, its generateWayClasses task will not run and no generated folder will appear for that module in that build invocation.