This document outlines the API surface of AzNavRailScope. This interface defines the DSL used to configure the rail, whether manually or via the generated code.
The KSP Processor injects reactive logic. When using @Az annotations with property bindings (e.g., textProperty = "myTitle"), the generated code no longer passes hardcoded literals. Instead, it accesses your AzActivity instance directly.
Because these properties are ideally mutableStateOf, the entire AzGraph will recompose automatically when your Activity state changes.
azConfigControls behavioral and geometrical mechanics.
fun azConfig(
dockingSide: AzDockingSide = AzDockingSide.LEFT,
packButtons: Boolean = false,
noMenu: Boolean = false,
vibrate: Boolean = false,
displayAppName: Boolean = false,
activeClassifiers: Set<String> = emptySet(), // items with a matching classifier read as ACTIVE
secondaryClassifiers: Set<String> = emptySet(), // …and these wear the SECONDARY highlight
usePhysicalDocking: Boolean = false,
expandedWidth: Dp = 160.dp,
collapsedWidth: Dp = 100.dp,
showFooter: Boolean = true,
appRepositoryUrl: String = "",
// Menu-drawer look-and-feel:
dimBehindMenu: Boolean = false, // opt-in dim scrim behind the drawer
dimBehindMenuAlpha: Float = 0.4f, // 0..1 alpha of the scrim
menuItemAlignment: AzMenuItemAlignment = AzMenuItemAlignment.SIDE, // SIDE (default) | CENTER
justifyMenuItems: Boolean = true, // full-justify labels via letter-spacing
)
The menu-drawer knobs only affect the expanded-menu drawer labels (the standalone
AzDropdownMenu mirrors them on its own azConfig). Small rail-button labels are unaffected.
SIDE alignment resolves to TextAlign.Start when docked LEFT and TextAlign.End when RIGHT.
justifyMenuItems runs a hybrid kerning + font-scale solver: it fills the row with
letterSpacing alone until tracking would exceed α · fontSize (default α = 0.15); once the
kerning cap is hit, the font scales up so both letter-spacing and font-size converge on the mix
that lands the label exactly on the row width. Font growth is capped at 1.5×. When the natural
width overflows the row, the solver shrinks the font (scale = rowWidth / naturalWidth,
clamped to ≥ 0.5×) so the label fits on one line without breaking mid-word — line breaks in
drawer labels are explicit-only (soft-wrap is disabled). Labels shorter than 2 characters are
skipped. AzDivider’s default color is now LocalContentColor.current on Compose and
currentColor on the web, so the divider matches the surrounding font — never a muted outline.
appRepositoryUrl is an optional override for the About reader’s repo. Blank (the default)
auto-derives the repo from the app namespace on Android: com.<owner>.<repo> →
https://github.com/<owner>/<repo> (owner = 2nd segment, repo = last segment; trailing build suffixes
like .debug are stripped). It never falls back to the AzNavRail library repo. On web there is
no namespace, so appRepositoryUrl is required (no auto-derivation); when unset the About entry is
hidden.
A hamburger drop-down menu is not a rail mode. Use the standalone
AzDropdownMenucomposable — placed inline likeAzButton, with noAzNavHost, no scope, and no safe zones. See “AzDropdownMenu” below.
azThemeControls the visual style of the rail.
fun azTheme(
activeColor: Color = Color.Unspecified,
defaultShape: AzButtonShape = AzButtonShape.CIRCLE,
headerIconShape: AzHeaderIconShape = AzHeaderIconShape.CIRCLE,
translucentBackground: Color = Color.Unspecified,
helpLineColors: List<Color> = emptyList(),
headerIconSize: Dp = Dp.Unspecified,
focusColor: Color = Color.Unspecified, // the FOCUS highlight; unset = same as activeColor
secondaryColor: Color = Color.Unspecified // the SECONDARY highlight; unset = the rail accent
)
The three highlights. An item can be lit three ways, and each answers a different question:
| Highlight | Question | Lit by |
|---|---|---|
activeColor |
Where am I? | the item’s route is the current destination, or a classifier in activeClassifiers |
focusColor |
What am I touching? | a press, or being the last-tapped item with no route of its own |
secondaryColor |
Whatever you decide | only you — azItemState(id, secondary = true) or secondaryClassifiers |
Focus beats active beats secondary when more than one applies. Override any of the three for a single
item with azHighlight.
azHighlightOverrides one item’s highlight colours.
fun azHighlight(
id: String,
active: Color? = null,
focus: Color? = null,
secondary: Color? = null,
)
Null means “leave it to the rail”, so setting one highlight never clears the other two. Unknown ids
are ignored — like azItemState, it is applied after the whole DSL block runs, so declaration order
is irrelevant.
azRailItem(id = "record", text = "Rec")
azHighlight(id = "record", active = Color.Red)
azItemState(id = "record", secondary = armed)
defaultShape accepts CIRCLE, SQUARE, RECTANGLE, TRIANGLE, and the borderless family —
NONE (rectangle footprint), NONE_SQUARE, NONE_CIRCLE. A borderless shape keeps the footprint of
the base shape it names, so dropping the border never reflows the rail around the item.
activeColor is also what every other AzNavRail surface draws itself in — a second unattached or
floating rail, a drop-down menu, the About reader, the Help overlay. When it is left unset the accent
is derived from the rail’s own items (the colour most of them use) rather than from the app’s theme:
a rail whose every button is white is a white rail.
headerIconSize pins the header app-icon to an exact diameter. When left Dp.Unspecified the icon
sizes itself to the rail width (legacy behavior).
azKineticsConfigures the WP7-style kinetic typography: the staggered turnstile entrance/exit on the
expanded menu items, the press-tilt, and the big screen-boundary title’s sweep. Config-driven (preset
enums, no free-composable escape hatch). Defaults animate — pass AzEntrance.None/AzExit.None to
opt a surface out. In FAB/floating mode the cascade degrades to a vertical up/down slide.
fun azKinetics(
itemEntrance: AzEntrance = AzEntrance.Turnstile,
itemExit: AzExit = AzExit.Turnstile,
itemTextStyle: TextStyle? = null,
entranceStaggerMs: Int = AzMotion.ItemStaggerMs, // 22 ms at 8 items; rescaled by count
entranceDurationMs: Int = AzMotion.ItemDurationMs, // 280 ms per item
entranceEasing: Easing = AzEasing.Wp7Decelerate,
entranceStartAngle: Float = 90f, // pure edge-on → flat, no fade, no slide
tiltOnPress: Boolean = false, // off by default on the rail (drag-safe)
maxTiltDegrees: Float = 10f,
titleEntrance: AzEntrance = AzEntrance.Turnstile,
titleTextStyle: TextStyle? = null
)
AzEntrance = None | Fade | SlideUp | Turnstile; AzExit = None | Fade | Turnstile;
AzEasing.Wp7Decelerate is the signature snappy easing. tiltOnPress is automatically suppressed for
draggable/relocatable items. The default Turnstile entrance is a pure 90° rotationY sweep
hinged on the docked edge — no fade, no vertical slide. Because the stagger (22 ms) is much smaller
than the duration (280 ms), items overlap heavily. The stagger is rescaled by item count
(AzMotion.CascadeReferenceCount = 8), so every cascade spans 176 ms whatever its length: three
items fold as fast as twelve. The footer (About / Feedback / @HereLiesAz) then
unfolds like an accordion from the top edge, starting one stagger tick after the last item
begins (delay = the full 176 ms span — the footer is the natural next beat in the cascade rhythm).
In React, the rail reads these from settings (itemEntrance, itemExit, titleEntrance, …).
On native React Native the hinge is emulated via a translateX ±(width/2) correction around
rotateY because RN ignores transformOrigin; on web the CSS property applies natively.
azAdvancedControls system overrides, loading states, and floating window bindings.
fun azAdvanced(
isLoading: Boolean = false,
helpEnabled: Boolean = false,
onDismissHelp: (() -> Unit)? = null,
overlayService: Class<out android.app.Service>? = null,
onUndock: (() -> Unit)? = null,
enableRailDragging: Boolean = false,
onRailDrag: ((Float, Float) -> Unit)? = null,
onOverlayDrag: ((Float, Float) -> Unit)? = null,
onItemGloballyPositioned: ((String, Rect) -> Unit)? = null,
helpList: Map<String, Any> = emptyMap(),
tutorials: Map<String, AzTutorial> = emptyMap(),
onInteraction: ((String, AzNavItem) -> Unit)? = null
)
Note.
isLoading,onRailDrag,onOverlayDragandoverlayServicewere collected into the config but never acted on. They now behave as documented:isLoadingdraws a screen-centredAzLoadabove everything and swallows input;onRailDragreports(dx, dy)while the rail is dragged in FAB mode;onOverlayDragdoes the same when anoverlayServiceis configured; and supplyingoverlayServicemakes undocking hand off to that system-overlay service (Android only — a no-op elsewhere).
azAboutConfigures the built-in About reader and the “More from Az” carousel.
fun azAbout(
inAppAbout: Boolean = true, // footer "About" opens the in-app reader vs a browser
moreFromAzEnabled: Boolean = true, // show the "More from Az" entry in the About screen
moreFromAzJsonUrl: String = "https://raw.githubusercontent.com/HereLiesAz/AzNavRail/main/more-from-az.json",
moreRailItem: Boolean = false, // also pin a "More" item at the bottom of the rail
aboutRailItem: Boolean = true, // end the rail with the built-in About ("?") item
dedupeAbout: Boolean = true // draw About in exactly one surface, never two
)
dedupeAbout is what stops About appearing three times in an app that has both a rail and a
drop-down. Every surface that could offer one registers itself, and only the highest-ranked draws it:
a azAboutRailItem you declared, then the rail’s expanded-menu footer, then a drop-down menu’s
footer, then the automatic ?. Set it false to draw About wherever it is configured.
The reader’s content is warmed in the background while the rail composes — the docs listing, the first document, and the More-from-Az manifest — so About opens populated rather than spinning. Its carousel snaps card-to-card, and the page ends with @HereLiesAz, Feedback and hereliesaz.com.
The rail ends with an About (?) rail item whenever nothing else is already offering About —
which is what a noMenu rail (no drawer, no footer) always is. With dedupeAbout on, a rail that
does have a menu footer carries About there instead and drops the automatic ?. It persists but it
is not fixed: azAboutRailItem(...) declares your own anywhere in the item order, outranks every
other surface, and suppresses the automatic one; aboutRailItem = false drops it. Tapping it toggles the reader; tapping any other rail or menu item, or the app icon, closes
it. The reader never covers the rail’s own gutter, so the app icon stays reachable behind it.
The About reader auto-discovers the markdown docs (root + docs/) of the resolved repo via the GitHub
API (cached; public repos only) and renders them inline. On Android the repo is auto-derived from
the app namespace (azConfig’s appRepositoryUrl is an optional override, never the AzNavRail library
repo); on web appRepositoryUrl is required (the About entry is hidden when unset). “More from Az”
is a link-only, CI-versioned carousel — see the README’s “In-App About Reader” and “More from Az”
sections.
While a footer screen (About or More from Az) is open, visible Help cards and any guidance callouts are hidden, and they return exactly where they were when the footer screen closes (all platforms).
onInteraction is called whenever any rail item is interacted with — click, toggle, cycler advance, nested rail open, reloc drag, or host expand/collapse. It fires for both leaf items (azRailItem / azRailSubItem) and host items (azRailHostItem), in both the compact rail and the expanded menu, so opening a host menu is observable just like tapping a leaf. It receives the item’s id and the AzNavItem itself, enabling analytics and UI feedback without per-item callbacks. (Guidance advancement no longer relies on this — the status-driven engine observes status predicates directly; see the Guidance DSL below.)
AzDropdownMenu (standalone drop-down; three-dot trigger by default)A drop-down menu is a standalone widget declared with the same opinionated DSL as the rail: it
accepts only the configuration the rest of the library sanctions (no arbitrary panel background,
offsets, icon tint/source, or free composable escape hatch). Its trigger is the app icon
(auto-drawn like the rail’s header; shape/size set via azConfig), dropped inline like any widget.
Tapping it unfolds an overlay panel (a Popup) of the items you declare; tapping outside or
pressing back folds it up.
@Composable
fun AzDropdownMenu(
modifier: Modifier = Modifier,
navController: NavController? = LocalAzNavHostScope.current?.navController, // for item routes
expanded: Boolean? = null, // optional controlled state
onExpandedChange: ((Boolean) -> Unit)? = null,
content: AzDropdownMenuScope.() -> Unit // a plain builder, like the rail's
)
// Inside the builder:
interface AzDropdownMenuScope {
fun azConfig(
design: AzDropdownDesign = AzDropdownDesign.MENU, // RAIL = rail width, MENU = menu width
dockingSide: AzDockingSide = AzDockingSide.LEFT, // which screen edge the panel pins to
vibrate: Boolean = false,
expandedWidth: Dp = 160.dp,
collapsedWidth: Dp = 100.dp,
headerIconShape: AzHeaderIconShape = AzHeaderIconShape.CIRCLE, // app-icon clip, like azTheme
headerIconSize: Dp = 48.dp, // app-icon diameter, like azTheme
showFooter: Boolean = true, // MENU footer (About/Feedback/@HereLiesAz)
inAppAbout: Boolean = true, // "About" opens a full-screen in-app reader
appRepositoryUrl: String = "", // optional override; else derived from namespace
// — kinetic typography (on by default; pass None to opt out) —
itemTextStyle: TextStyle? = null, // merged over each MENU row label
itemEntrance: AzEntrance = AzEntrance.Turnstile, // None | Fade | SlideUp | Turnstile
entranceStaggerMs: Int = AzMotion.ItemStaggerMs, // 22 ms at 8 items; rescaled by count
entranceDurationMs: Int = AzMotion.ItemDurationMs, // 280 ms
entranceEasing: Easing = AzEasing.Wp7Decelerate,
entranceStartAngle: Float = 70f,
tiltOnPress: Boolean = false, // WP7 3D tilt toward the press
maxTiltDegrees: Float = 10f,
itemExit: AzExit = AzExit.Turnstile // None | Fade | Turnstile
)
fun azItem(text, route = null, color, textColor, fillColor, shape, enabled, closeOnClick = true, onClick)
fun azToggle(isChecked, toggleOnText, toggleOffText, route = null, …, onToggle)
fun azCycler(options, selectedOption, route = null, …, onCycle)
fun azDivider()
}
azConfig mirrors the rail’s azConfig/azTheme: design sets the look + width
(AzDropdownDesign.RAIL = compact rail buttons at collapsedWidth ≈100dp; AzDropdownDesign.MENU
= full-width labeled rows at expandedWidth ≈160dp), dockingSide pins the panel to the LEFT
or RIGHT screen edge, and headerIconShape/headerIconSize set the app-icon trigger’s clip and
diameter (the same knobs the rail exposes via azTheme). The MENU design renders rows at the
rail’s menu-item text size and — like the rail’s expanded menu — carries the footer
(About / Feedback / @HereLiesAz, gated by showFooter). Because the dropdown has no onscreen/host
area, tapping About opens a full-screen in-app reader drawn as its own layer when
inAppAbout = true (the default); inAppAbout = false opens the repo in a browser. The repo is
auto-derived from the app namespace on Android, with appRepositoryUrl as an optional override (never
the AzNavRail library repo). The panel drops from the trigger automatically (downward when it fits,
else upward). Items accept only the
rail’s sanctioned per-item knobs, plus a navigation route: when set,
tapping navigates the navController (so the drop-down can drive an AzNavHost), then runs the
callback, then folds up if closeOnClick.
AzDropdownMenu(navController = navController) {
azConfig(design = AzDropdownDesign.MENU, dockingSide = AzDockingSide.LEFT)
azItem("Home", route = "home") { }
azToggle(isChecked = dark, toggleOnText = "Dark", toggleOffText = "Light") { dark = it }
azDivider()
azItem("Sign out") { signOut() }
}
On React the equivalent is <AzDropdownMenu design dockingSide onNavigate> with <AzDropdownItem>
children (each accepting an optional route); props mirror the Kotlin azConfig names.
azRailRelocItem)listItem(text, route)listItem(text, onClick)inputItem(hint, onValueChange)inputItem(hint, initialValue, onValueChange)The following functions are used to define the rail structure.
contentaccepts aString, aColor, a drawable/vector resource id (Int), a ComposeImageVectororPainter, or any image model Coil can load (Bitmap, URL,File,Uri). Non-text graphics fill the item’s shape (cover) and are clipped to it;ImageVectorcontent is tinted with the item color.
azMenuItem(id, text, route, content, color, shape, disabled, screenTitle, info, onClick)azHelpRailItem(id, text, content, color, shape)azAboutRailItem(id, text, content, color, shape, menuText, textColor, fillColor, badge, persistentBadge, isLoading) — the rail’s About (?) button, declared explicitly in the position you call it from. The rail appends one automatically when you declare none, so About is always reachable; yours replaces it. azAbout(aboutRailItem = false) drops it entirely.azRailItem(id, text, route, content, color, shape, disabled, screenTitle, info, classifiers, onFocus, onClick)@Composable azNestedRail(id, text, route, content, color, shape, alignment, disabled, screenTitle, info, classifiers, onFocus) { ... }azMenuToggle(id, isChecked, toggleOnText, toggleOffText, route, color, shape, disabled, screenTitle, info, onClick)azRailToggle(id, isChecked, toggleOnText, toggleOffText, route, color, shape, disabled, screenTitle, info, onClick)azMenuCycler(id, options, selectedOption, route, color, shape, disabled, disabledOptions, screenTitle, info, onClick)azRailCycler(id, options, selectedOption, route, color, shape, disabled, disabledOptions, screenTitle, info, onClick)azRailSlider(id, text, value, config, rangeValue, color, shape, disabled, info, classifiers, textColor, fillColor, valueFormatter, onValueChange, onRangeChange) — a rail item that unfolds into a slider in its own slot. Folded it is an ordinary rail button; tapped, the slot grows along the rail and the button becomes the track, with the value underneath. Tapping the value folds it back. Nothing opens over the rail, so the control arrives where the user was already looking.azDivider()azMenuHostItem(id, text, route, content, color, shape, disabled, screenTitle, info, classifiers, menuText, textColor, fillColor, initiallyExpanded, expandWhen, onExpandedChange, onClick)azRailHostItem(id, text, route, content, color, shape, disabled, screenTitle, info, classifiers, menuText, textColor, fillColor, initiallyExpanded, expandWhen, onExpandedChange, onClick)azMenuSubItem(id, hostId, text, route, content, color, shape, disabled, screenTitle, info, onClick)azRailSubItem(id, hostId, text, route, content, color, shape, disabled, screenTitle, info, classifiers, onFocus, onClick)azMenuSubHostItem(id, hostId, text, route, content, color, shape, disabled, screenTitle, info, classifiers, menuText, textColor, fillColor, initiallyExpanded, expandWhen, onExpandedChange, onClick) — a sub-item that is itself a host; nests to any depth.azRailSubHostItem(id, hostId, text, route, content, color, shape, disabled, screenTitle, info, classifiers, menuText, textColor, fillColor, initiallyExpanded, expandWhen, onExpandedChange, onClick) — a sub-item that is itself a host; nests to any depth.azMenuSubToggle(id, hostId, isChecked, toggleOnText, toggleOffText, route, color, shape, disabled, screenTitle, info, onClick)azRailSubToggle(id, hostId, isChecked, toggleOnText, toggleOffText, route, color, shape, disabled, screenTitle, info, onClick)azMenuSubCycler(id, hostId, options, selectedOption, route, color, shape, disabled, disabledOptions, screenTitle, info, onClick)azRailSubCycler(id, hostId, options, selectedOption, route, color, shape, disabled, disabledOptions, screenTitle, info, onClick)azRailRelocItem(id, hostId, text, route, content, color, shape, disabled, screenTitle, info, classifiers, onFocus, onClick, onRelocate, forceHiddenMenuOpen, onHiddenMenuDismiss) { ... } — hostId may name a rail host or an azUnattachedHostItem; tap, long-press hidden menu and drag-to-reorder work either way. Under an unattached host onRelocate reports that host’s direct reloc members, top-first, once on drop and only if the order changed.azRailRelocSubHostItem(id, hostId, text, route, content, color, shape, disabled, screenTitle, info, classifiers, menuText, textColor, fillColor, translucentBackgroundColor, badge, persistentBadge, isLoading, onFocus, initiallyExpanded, expandWhen, onExpandedChange, onRelocate, forceHiddenMenuOpen, onHiddenMenuDismiss) { ... } — a sub-host that is also a reloc member of hostId: one slot together with all its descendants (declare them right after it). Dragging it moves the whole block; siblings dragged past it jump the block; onRelocate reports the parent’s direct reloc members by id. Draggable under unattached hosts; in the docked strip it behaves as a plain sub-host.azUnattachedHostItem(id, text, anchor, route, content, color, shape, disabled, screenTitle, info, classifiers, menuText, textColor, fillColor, badge, persistentBadge, isLoading, initiallyExpanded, expandWhen, onExpandedChange, onClick) — a rail host drawn outside the rail strip, at an AzUnattachedAnchor (OPPOSITE / BOTTOM / FLOATING). Sub-items attach by hostId as usual and unfold beneath it; the host and its whole subtree leave both the rail and the drawer. OPPOSITE/BOTTOM hosts sharing an anchor stack into one fixed column; FLOATING hosts each float and dock independently — drag one to a screen edge to pin it there (several pinned to the same edge line up adjacent, not overlapping), or flush against another FLOATING host to attach to it instead, forming a two-column-capped grid with a shared grab bar. Each host’s own resting spot persists across launches; rail-to-rail attachments do not. When an expanded host’s sub-items (including unfolded relocatable sub-hosts) overflow the room below it, they scroll under the fixed host button — only on overflow; swipes scroll without clicking, taps still click, long-press-drag reordering still works and auto-scrolls near the list’s edges, and the active item is scrolled into view on expansion.azItemState(id, badge, persistentBadge, isLoading, alert, secondary) — decorates an already-declared item of any kind by id. Applied after the whole DSL block runs, so declaration order is irrelevant; null fields leave the item’s existing value alone. secondary = true lights the item’s secondary highlight (see azTheme).azHighlight(id, active, focus, secondary) — overrides that item’s three highlight colours.Every item builder above also accepts
badge,persistentBadgeandisLoading(per-item loading spins that item’s ownAzLoad, tinted to its colour, while the rest of the rail stays live).azItemStateis the uniform alternative when you’d rather not thread them through the builder.
expandWhen)All four host-item builders (azMenuHostItem, azRailHostItem, azMenuSubHostItem,
azRailSubHostItem) accept an optional expandWhen: (() -> Boolean)? parameter.
Behaviour
| Edge | Effect |
|---|---|
false → true |
Host auto-expands |
true → false |
Host auto-collapses |
User collapse while true |
Respected; condition acts again only on the next false → true edge |
How it works
expandWhen is a lambda — it cannot live on AzNavItem (a @Parcelize class). Instead it
is stored in AzNavRailScopeImpl.expandWhenMap alongside the other callback maps
(onClickMap, onFocusMap). Inside AzNavRail, a LaunchedEffect keyed on the stable set of
expandWhen host ids fans out one child coroutine per host, each observing the condition through
snapshotFlow { cond() } (instant when the lambda reads Compose snapshot state) merged with a
low-rate (~300 ms) poll. Expansion is applied only on an actual transition, and the very first
observation only ever expands (so it never clobbers an initiallyExpanded or manual state).
Keying on the host-id set rather than
scope.navItemsis deliberate: the item list’s contents change on any item-value update, which previously tore down and relaunched the collectors and swallowed the rising edge — leaving the host stuck collapsed.
Capabilities & limitations
mutableStateOf
reacts instantly; one backed by a non-Compose source (StateFlow.value, LiveData.value, a
plain var, an external store) still works via the poll — with up to ~300 ms latency.true is preserved (the condition acts on
transitions, never continuously), so the user can override it until the next false → true edge.expandWhen host keeps a tiny recurring timer alive while mounted. It is
one cheap boolean evaluation per host every ~300 ms — negligible, but not zero.aznavrail-react (render-time evaluation
plus a 300 ms setInterval fallback); see Capabilities & Limitations doc.Typical use — guidance framework
A guidance edge that highlights a sub-item inside a collapsed host would fail because the
sub-item is not laid out (and therefore has no measured bounds). Declare expandWhen on
the host to auto-expand it while a guidance goal is active:
azRailHostItem(
id = "my-host",
text = "Features",
expandWhen = { guidance.activeGoals.contains("onboarding") }
)
Both the expandWhen and initiallyExpanded qualifiers can coexist. initiallyExpanded
fires once on first appearance; expandWhen fires on every edge transition thereafter.
onExpandedChange)All four host-item builders accept an optional onExpandedChange callback that fires whenever the user manually expands or collapses that specific host item. It does NOT fire for programmatic changes driven by expandWhen or initiallyExpanded.
Callback signature
| Platform | Type |
|---|---|
| Android / Kotlin | ((Boolean) -> Unit)? |
| React / TypeScript | (expanded: boolean) => void |
Typical use — persist expansion per host
// Android
azRailHostItem(
id = "settings",
text = "Settings",
initiallyExpanded = savedExpandedIds.contains("settings"),
onExpandedChange = { isExpanded ->
if (isExpanded) savedExpandedIds.add("settings")
else savedExpandedIds.remove("settings")
}
)
// React
const [expanded, setExpanded] = useLocalStorage<Record<string, boolean>>('hostExpanded', {})
<AzRailHostItem
id="settings"
text="Settings"
initiallyExpanded={expanded['settings'] ?? false}
onExpandedChange={(isExpanded) => setExpanded(prev => ({ ...prev, settings: isExpanded }))}
/>
The callback covers all four builder types: azMenuHostItem, azRailHostItem, azMenuSubHostItem, and azRailSubHostItem, including hosts nested inside a nested-rail popup.
azStatus / azEdge / azGoal)The status-driven guidance framework replaces the old scripted azTutorial scene/card DSL. You declare
the userflow as a flowchart of statuses (boolean nodes), edges (transitions carrying an
instruction), and goals (target statuses), then activate goals on the AzGuidanceController. The
engine watches which statuses are true and always shows the instruction to reach the next status toward
each active goal, auto-advancing the moment a target becomes true. See
TUTORIAL_FRAMEWORK_PROPOSAL.md for the full reference.
The three DSL functions are declared inside the AzHostActivityLayout { ... } content lambda, which
returns an AzGuidanceController.
fun azStatus(id: String, predicate: () -> Boolean)
fun azEdge(
from: String, to: String? = null, text: String? = null, title: String? = null,
highlightItemId: String? = null, // a rail item, or the AZ_ITEM_ACTIVE token
highlightTargetId: String? = null, // a registered arbitrary shape (azGuidanceTarget)
highlightSelector: (() -> String?)? = null, // a rail item id resolved each frame
steps: List<AzInstructionStep> = emptyList(), // paged sub-pointers (tap / advanceWhen)
)
fun azGoal(id: String, target: String, label: String? = null, autoStartWhen: String? = null)
// Arbitrary on-screen highlight target (window-space px shape, recomputed each frame; null ⇒ text-only).
fun azGuidanceTarget(id: String, shape: () -> AzGuideShape?)
// Hide guidance while `predicate` is true; re-show after `settleMs` once it clears.
fun azSuppressGuide(settleMs: Long = 700, predicate: () -> Boolean)
// Draw the callout body yourself (the non-blocking outline + connector still draw).
fun azGuideRenderer(renderer: @Composable (AzGuidanceSnapshot, Rect?) -> Unit)
AzGuideShape is Circle / Rect / Path in window-space px (see §9.6 of the Complete Guide); a step
is AzInstructionStep(text, title?, highlightItemId?, highlightTargetId?, side?, highlightSelector?,
advanceWhen?). An informational step (no advanceWhen) advances on tap; an actionable one advances
when its status flips. The React port mirrors these as <AzGuidanceTarget>, <AzSuppressGuide>,
<AzGuideRenderer>, and steps / highlightTargetId props on <AzEdge>.
import com.hereliesaz.aznavrail.tutorial.*
val guidance = AzHostActivityLayout(navController = nav, currentDestination = route) {
azRailItem(id = "cart", text = "Cart", route = "cart")
azRailItem(id = "checkout", text = "Checkout", route = "checkout")
onscreen { AzNavHost(startDestination = "home") { /* … */ } }
// A developer status: a predicate over your own state.
azStatus("cart_open") { cart.isOpen }
// An interactive edge into a built-in status; highlight the control used to traverse it.
azEdge(from = "cart_open", to = "az.screen.checkout", text = "Tap Checkout", highlightItemId = "checkout")
// A passive edge (to = null): an ambient hint shown while `from` holds.
azEdge(from = "cart_open", text = "Review your items before checking out.")
// A goal: drive the user to the confirmation screen. Activate it later (developer-driven).
azGoal(id = "checkout", target = "az.screen.confirmation", label = "Check out")
// Optional self-activating onboarding goal.
azGoal(id = "onboarding", target = "az.rail.expanded", label = "Take the tour", autoStartWhen = "az.app.ready")
}
// Developer-driven activation.
guidance.activate("checkout")
You only hand-author edges into your custom statuses. The engine auto-generates edges for the rail’s
own affordances: “Open the menu” (az.rail.collapsed → az.rail.expanded), tapping a host item
(→ az.host.<id>.expanded), tapping a nested-rail item (→ az.nestedRail.<id>.open), and tapping a
routed item (→ az.screen.<route>). Instruction text is localizable via az_guide_open_menu /
az_guide_tap_item.
Declared as JSX children of the rail (under AzHostActivityLayout / AzNavRail). Get the controller
anywhere under the rail with useAzGuidanceController().
<AzStatus id="cart_open" predicate={() => cart.isOpen} />
<AzEdge from="cart_open" to="az.screen.checkout" text="Tap Checkout" highlightItemId="checkout" />
<AzGoal id="checkout" target="az.screen.confirmation" label="Check out" autoStartWhen={null} />
import {
AzHostActivityLayout, AzRailItem,
AzStatus, AzEdge, AzGoal, useAzGuidanceController,
} from '@HereLiesAz/aznavrail-react';
function Shell() {
return (
<AzHostActivityLayout dockingSide={AzDockingSide.LEFT}>
<AzRailItem id="cart" text="Cart" route="cart" />
<AzRailItem id="checkout" text="Checkout" route="checkout" />
<AzStatus id="cart_open" predicate={() => cart.isOpen} />
<AzEdge from="cart_open" to="az.screen.checkout" text="Tap Checkout" highlightItemId="checkout" />
<AzEdge from="cart_open" text="Review your items before checking out." />{/* passive: omit `to` */}
<AzGoal id="checkout" target="az.screen.confirmation" label="Check out" autoStartWhen={null} />
</AzHostActivityLayout>
);
}
function Launcher() {
const guidance = useAzGuidanceController();
return <button onClick={() => guidance.activate('checkout')}>Guide me</button>;
}
Guidance is presented as a non-blocking coach: a thin outline around each step’s target and a small
callout placed near it (with a connector), never dimming the screen or intercepting input outside a
callout. The user swipes a callout away to cancel (persisted); a step shown + acted on is never
re-shown. Starting is always developer-driven — call activate(...) (the autoStartWhen status-id
self-start still exists but is discouraged, and honours completion + skip).
AzGuidanceController (both platforms): enabled, activeGoals, completedGoals, dismissedGoals,
enable(), disable(), activate(goalId), deactivate(goalId), markReached(goalId),
isCompleted(goalId), isDismissed(goalId), skip(goalId?) (cancel one or, with no arg, all),
resetGuidance(goalId?) (replay). For paged edges it also exposes advance(stepKey) / next(stepKey)
/ back(stepKey) and a no-arg advance(), and for observation currentInstructions / current (an
AzGuidanceSnapshot list; Kotlin also has currentFlow). Completion persists under
az_navrail_completed_goals and skips under az_navrail_dismissed_goals (Android SharedPreferences
file az_tutorial_prefs; React localStorage / RN AsyncStorage).
The azBottomSheet DSL registers a four-detent bottom sheet that draws above the rail, the menu, and the onscreen area, spans the full screen width, and extends all the way to the bottom of the screen — so the HIDDEN-detent strip is reachable from the system-navigation-bar edge for a swipe-up reveal. It is not a background(); it sits at the top z-layer of AzHostActivityLayout (via Modifier.zIndex(2f)). If your sheet body needs to clear the system nav bar visually, pad inside your content lambda or use AzBottomSheetInsetAware directly outside the DSL.
The shell was ported from LogKitty; its detents, drag accumulator, and controller API are preserved verbatim so LogKitty can swap its custom sheet for AzBottomSheetWindowHost with no observable behavior change.
AzSheetDetent.HIDDEN — thin swipe strip (configurable, default 28dp). Stays visible with a dimmed drag-handle pill and accepts a tap that steps up to PEEK in addition to the swipe-up gesture, so the affordance is discoverable.AzSheetDetent.PEEK — single-line ticker (configurable, default 56dp).AzSheetDetent.HALF — ~50% of the available height (configurable via halfFraction).AzSheetDetent.FULL — ~90% of the available height (configurable via fullFraction).val sheetController = rememberAzSheetController()
AzHostActivityLayout(navController = nav, currentDestination = currentRoute) {
azConfig(dockingSide = AzDockingSide.LEFT)
azMenuItem(id = "home", text = "Home", route = "home", onClick = { /* … */ })
onscreen(alignment = Alignment.Center) {
AzNavHost(startDestination = "home") { /* … */ }
}
// One DSL line. Draws above rail/menu/onscreen, behind the system nav bar.
azBottomSheet(
controller = sheetController,
config = AzSheetConfig(peekDp = 48.dp, halfFraction = 0.5f, fullFraction = 0.9f),
) {
// Any composable content; the shell provides chrome (drag handle, scrim, gestures).
Column(Modifier.fillMaxSize().padding(16.dp)) {
Text("Sheet body")
Button(onClick = { sheetController.stepUp() }) { Text("Expand") }
Button(onClick = { sheetController.stepDown() }) { Text("Collapse") }
}
}
}
AzSheetController exposes:
var detent: AzSheetDetent — read/write current state.var isEnabled: Boolean — disabling forces HIDDEN and blocks stepUp/stepDown.val detentFlow: StateFlow<AzSheetDetent> — observed by the system-overlay flavor.val enabledFlow: StateFlow<Boolean>.stepUp() / stepDown() — one detent at a time, clamped at the ends.snapTo(target: AzSheetDetent) — jump directly.Use rememberAzSheetController(initial) inside Compose so detent state survives recomposition and configuration changes.
stepUp() per gesture when the threshold (config.dragThresholdDp, default 24dp) is crossed.snapTo(HIDDEN), dismissing the sheet entirely in one gesture rather than stepping down one detent at a time.HALF / FULL calls stepDown() (dim overlay visible).PEEK — a non-dimmed, full-screen tap catcher that calls stepDown(), transitioning to HIDDEN. Makes the dismiss affordance discoverable for users who tap rather than swipe.config.collapseOnBack = true calls stepDown() while the sheet is non-HIDDEN.config.horizontalSwipeEnabled = true and the onSwipeLeft / onSwipeRight callbacks (used by LogKitty for tab navigation).For Services that float a sheet over the active foreground app (LogKitty’s use case), use AzBottomSheetWindowHost:
val sheetController = AzSheetController(initial = AzSheetDetent.HIDDEN)
val host = AzBottomSheetWindowHost(
context = this,
controller = sheetController,
config = AzSheetConfig(backgroundColor = userBg, backgroundAlpha = userAlpha),
lifecycleOwner = this,
viewModelStoreOwner = this,
savedStateRegistryOwner = this,
navBarHeightPx = navBarPx,
) { /* same content slot as AzBottomSheet */ }
host.attach()
host.attachNavBarDecor() // call from your accessibility service's onServiceConnected
// onDestroy: host.detach()
The library ships no Service; consumers declare the SYSTEM_ALERT_WINDOW (and, for the nav-bar decoration, BIND_ACCESSIBILITY_SERVICE) permissions in their own manifest.
azPopup)azPopup(controller, dismissOnOutsideTap = true) { … } registers a popup window bound to a rail
item, driven by an AzPopupController from rememberAzPopupController().
controller.show(itemId, kind, title, message, payload) — itemId names the source item; omitting
it binds to the last touched rail item.AzPopupScope: kind, title, message, payload, dismiss(), and item
— an AzPopupItemHandle with setBadge, setLoading, setAlert, clear, plus the live
AzNavItem via item.item. Those writes survive the DSL re-running until the popup clears them.AzPopupKind.NOTICE / AzPopupKind.WARNING redraw the bound item as a yellow, rounded-corner
triangle outline while the popup is up, restoring it on dismiss.