Skip to content

NadeemIqbal/tutorial-view

Repository files navigation

TutorialView

Spotlight onboarding tours for Compose Multiplatform — dim the screen, cut out a rounded spotlight around the UI element you want to introduce, and surface an explanatory tooltip with Back / Skip / Next controls. One module, every CMP target.

Maven Central License Build Kotlin Android iOS Desktop Web

TutorialView spotlight tour on iOS — dimmed scrim with a lit cutout and a tooltip card

Why this library

Every product app eventually needs a first-run tour. The pattern is well-known — dim the screen, spotlight one UI element, explain it, Next, repeat — but no notable CMP library ships it. Teams rebuild it from scratch each time, and the cutout/anchor/lifecycle math is annoying enough that the result usually feels half-baked. TutorialView is the polished primitive: declare your steps, mark your targets, call start().

Platform support

Platform Supported Tested
Android ✅ (unit + UI)
iOS ✅ (UI, Skiko)
Desktop ✅ (unit + UI)
Web ✅ (compile + logic)

Installation

gradle/libs.versions.toml:

[libraries]
tutorial-view = { module = "io.github.nadeemiqbal:tutorial-view", version = "0.2.0" }

commonMain dependencies:

kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation(libs.tutorial.view)
        }
    }
}

Quick start

Three pieces: mark targets, declare steps, host them.

val state = rememberTutorialState(
    steps = listOf(
        TutorialStep("compose", "Compose", "Tap here to write a new message"),
        TutorialStep("inbox",   "Inbox",   "Your messages live here"),
        TutorialStep("profile", "Profile", "Open your account and settings"),
    )
)

TutorialView(state = state) {
    Scaffold(...) {
        Button(onClick = { ... }, modifier = Modifier.tutorialTarget("compose")) { Text("Compose") }
        LazyColumn(Modifier.tutorialTarget("inbox")) { ... }
        Avatar(Modifier.tutorialTarget("profile"))
    }
}

// Start the tour whenever you like — e.g. on first run.
LaunchedEffect(Unit) { if (firstRun) state.start() }

API examples

Programmatic navigation

state.start()      // begin the tour at step 0
state.next()       // advance (or finish, on the last step)
state.previous()   // back one step
state.skip()       // dismiss without finishing
state.finish()     // explicitly end

state.isActive            // Boolean — overlay visible?
state.currentStepIndex    // Int
state.currentStep         // TutorialStep? (null when inactive)

Custom button labels (localization)

TutorialView(
    state = state,
    nextLabel = "Continue",
    finishLabel = "Got it",
    previousLabel = "Back",
    skipLabel = "Maybe later",
) { ... }

Per-step cutout shape

TutorialStep(
    targetKey = "fab",
    title = "Quick action",
    description = "Tap to add a new entry",
    cornerRadius = 32.dp,   // circle-ish cutout for the FAB
    padding = 12.dp,        // extra breathing room
)

Custom scrim colour

TutorialView(
    state = state,
    scrimColor = Color.Black.copy(alpha = 0.85f),
) { ... }

Pick or build a transition animation

// One of the curated presets — Default, Subtle, Bouncy, None.
TutorialView(state = state, animations = TutorialAnimations.Bouncy) { ... }

// Or tweak a preset in one line:
TutorialView(
    state = state,
    animations = TutorialAnimations.Default.copy(
        spotlightSpec = spring(dampingRatio = 0.4f, stiffness = 200f),
        pulseEnabled = false,
    ),
) { ... }

Targets safely no-op outside the tour

// Modifier.tutorialTarget is a no-op when no TutorialView is hosting the tree.
// Leave the keys on production UI; the runtime cost is zero outside a tour.
Button(Modifier.tutorialTarget("compose")) { ... }

Customization

  • scrimColor — the dim wash. Use higher alpha for more focus, lower for a hint of the underlying UI.
  • tooltipShape / tooltipMargin — visual chrome of the tooltip card.
  • nextLabel / finishLabel / previousLabel / skipLabel — button text for i18n.
  • onFinish callback — fires once when the tour ends (Done or Skip). Use it to persist "first-run done" so the tour doesn't replay.
  • TutorialStep.cornerRadius / padding — per-step cutout shape; mix and match between steps.
  • animations — transitions between steps + on start/dismiss. Pass TutorialAnimations.Default / Subtle / Bouncy / None, or build your own. See below.

Animations

TutorialView ships with four presets and a fully customizable TutorialAnimations config object.

Preset Spotlight Tooltip Pulse
Default tween(380) — smooth slide horizontal slide + fade
Subtle tween(260) — quicker, no slide pure fade
Bouncy spring(damp=0.55) — playful overshoot scale-in + fade
None snap none

Sample tour cycling through all four presets and a custom config — spotlight slides between targets, tooltip slides+fades, scrim fades on dismiss

The clip above (iPhone 17 sim) cycles through every preset plus a custom override. Reproduce per-preset GIFs locally with ./scripts/record-animations.sh.

Every field on TutorialAnimations is a standard Compose animation type, so anything you'd write inline with fadeIn() + slideInVertically() slots straight in:

val mine = TutorialAnimations(
    overlayEnter = fadeIn(tween(220)),
    overlayExit = fadeOut(tween(180)),
    spotlightSpec = spring(dampingRatio = 0.5f, stiffness = 240f),
    tooltipForwardEnter = slideInHorizontally { it } + fadeIn(),
    tooltipForwardExit = slideOutHorizontally { -it } + fadeOut(),
    tooltipBackEnter = slideInHorizontally { -it } + fadeIn(),
    tooltipBackExit = slideOutHorizontally { it } + fadeOut(),
    pulseEnabled = true,
    pulseAmplitudeDp = 3.dp,
    pulseDurationMillis = 1200,
)

TutorialView(state = state, animations = mine) { ... }

For most cases, .copy() on a preset is the shortest path:

animations = TutorialAnimations.Default.copy(pulseEnabled = false)

Tooltip placement

The tooltip is anchored at the top or bottom of the screen, picked automatically so it stays clear of the highlighted element: target in the upper half of the screen → tooltip at the bottom, and vice-versa. There's no "speech bubble" pointer in v0.1.0 — the dim scrim + glowing cutout already direct attention to the target.

Lifecycle gotchas

  • Target bounds are tracked live via onGloballyPositioned. If your UI animates, the cutout follows — no manual updates needed.
  • Targets are automatically unregistered when their composable leaves composition, so a stale tutorialTarget("foo") on a since-removed screen can't anchor the cutout in the wrong place.
  • If the current step's target doesn't exist (yet), the scrim renders full-dim with no cutout and the tooltip still appears — the user can Next past it cleanly.

Comparison

TutorialView Hand-rolled overlay Android TapTargetView
Multiplatform ✅ A/iOS/Desktop/Web ⚠️ you build 4 of them ❌ Android only
Spotlight cutout ✅ rounded-rect, configurable ⚠️ DIY path math ✅ circular
Step-through controls ✅ Back / Skip / Next built-in ❌ DIY ⚠️ chained callbacks
Live target tracking ⚠️ DIY layout coords ⚠️ partial
Lifecycle-safe targets ✅ auto-unregister ⚠️ DIY n/a (Views)
Pointer & tap blocking ✅ scrim consumes taps ⚠️ DIY

Roadmap

  • Speech-bubble pointer from the tooltip to the target
  • Built-in "show once" persistence helper (multiplatform storage)
  • Per-step custom tooltip content slot (content: @Composable () -> Unit)
  • Reduced-motion / accessibility flag that respects the OS setting per target

Contributing

See CONTRIBUTING.md. Bug reports and feature requests are welcome via GitHub Issues.

License

Copyright 2026 Nadeem Iqbal

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

See LICENSE for the full text.