Skip to content

oussemaAr/ksp_demo_app

Repository files navigation

@ComposeActions — KSP Processor for Jetpack Compose

A production-grade KSP (Kotlin Symbol Processing) library that eliminates callback hell in Jetpack Compose by generating type-safe action dispatchers from a simple annotated interface.


The Problem It Solves

As Compose screens grow, best practices require hoisting all callbacks up to the screen's entry point. This quickly creates massive, unreadable signatures:

// ❌ Before — painful to maintain
@Composable
fun LoginScreen(
    email: String,
    password: String,
    isLoading: Boolean,
    onEmailChanged: (String) -> Unit,
    onPasswordChanged: (String) -> Unit,
    onLoginClicked: () -> Unit,
    onForgotPasswordClicked: () -> Unit,
    onDismissError: () -> Unit,
    onTogglePasswordVisibility: () -> Unit
) {  }

// Every @Preview needs all of these filled:
@Preview
@Composable
fun LoginPreview() {
    LoginScreen(
        email = "",
        password = "",
        isLoading = false,
        onEmailChanged = {},
        onPasswordChanged = {},
        onLoginClicked = {},
        onForgotPasswordClicked = {},
        onDismissError = {},
        onTogglePasswordVisibility = {}
    )
}

Adding one new button means updating:

  • The Composable signature
  • The ViewModel call site
  • Every single @Preview

The Solution

Annotate an interface. KSP generates everything else.

// ✅ After — you write only this
@ComposeActions
interface LoginActions {
    fun onEmailChanged(email: String)
    fun onPasswordChanged(password: String)
    fun onLoginClicked()
    fun onForgotPasswordClicked()
    fun onDismissError()
    fun onTogglePasswordVisibility()
}

KSP generates three artifacts automatically:

// 1. Data class dispatcher
data class LoginActionsDispatcher(
    val onEmailChanged: (String) -> Unit,
    val onPasswordChanged: (String) -> Unit,
    val onLoginClicked: () -> Unit,
    val onForgotPasswordClicked: () -> Unit,
    val onDismissError: () -> Unit,
    val onTogglePasswordVisibility: () -> Unit
) {
    // 2. Preview stub — zero boilerplate in @Preview
    companion object {
        val Preview: LoginActionsDispatcher
            get() = LoginActionsDispatcher(
                onEmailChanged = { _ -> },
                onPasswordChanged = { _ -> },
                onLoginClicked = {},
                onForgotPasswordClicked = {},
                onDismissError = {},
                onTogglePasswordVisibility = {}
            )
    }
}

// 3. remember() helper — stable across recompositions
@Composable
fun rememberLoginActions(
    onEmailChanged: (String) -> Unit,
    onPasswordChanged: (String) -> Unit,
    onLoginClicked: () -> Unit,
    onForgotPasswordClicked: () -> Unit,
    onDismissError: () -> Unit,
    onTogglePasswordVisibility: () -> Unit
): LoginActionsDispatcher = remember() {
    LoginActionsDispatcher()
}

Now your screen signature becomes clean and maintainable:

// ✅ ONE parameter instead of N callbacks
@Composable
fun LoginScreen(
    state: LoginState,
    actions: LoginActionsDispatcher
) {  }

// ✅ Preview is trivial
@Preview
@Composable
fun LoginPreview() {
    LoginScreen(
        state = LoginState.Initial,
        actions = LoginActionsDispatcher.Preview  // done.
    )
}

How the Processor Works

┌─────────────────────────────────────────────────────────────┐
│                      Kotlin Compiler                        │
│                                                             │
│  Your .kt files ──► KSP Plugin ──► Your Processor runs      │
│                          │                                  │
│                          ▼                                  │
│              ComposeActionsProvider.create()                │
│                    (called once)                            │
│                          │                                  │
│                          ▼                                  │
│              ComposeActionsProcessor.process()              │
│                    (called each round)                      │
│                          │                                  │
│              getSymbolsWithAnnotation(...)                  │
│              filterIsInstance<KSClassDeclaration>()         │
│              partition { it.validate() }                    │
│                          │                                  │
│                          ▼                                  │
│              ComposeActionsVisitor.visitClassDeclaration()  │
│                    (called per annotated interface)         │
│                          │                                  │
│              Extract functions + parameters                 │
│              Build FileSpec with KotlinPoet                 │
│              Write to build/generated/ksp/                  │
└─────────────────────────────────────────────────────────────┘

The Three Classes Explained

Class Role Called
ComposeActionsProvider Factory — creates the Processor Once per compilation
ComposeActionsProcessor Orchestrator — finds symbols, validates, guards Once per KSP round
ComposeActionsVisitor Worker — extracts data, generates code Once per annotated interface

Annotation Reference

@ComposeActions(
    suffix = "Dispatcher",      // generated class name = InterfaceName + suffix
    generatePreview = true,     // generate Companion.Preview no-op stub
    generateRemember = true     // generate rememberXxx() Composable helper
)
interface MyActions {
    fun onSomething()
    fun onSomethingElse(value: String)
}
Parameter Type Default Description
suffix String "Dispatcher" Appended to interface name for the generated class
generatePreview Boolean true Generate Companion.Preview no-op stub
generateRemember Boolean true Generate rememberXxx() Composable helper

Rules & Validation

The processor enforces these rules at compile time — you get a clear error message pointing to the exact file and line if any rule is violated.

Rule Error Message
Must be an interface @ComposeActions must be applied to an interface. Found CLASS 'LoginActions'
No properties allowed @ComposeActions interface must only contain functions. Found properties: [name]
All functions must return Unit Function 'getUser' must return Unit. Action functions are fire-and-forget
Must have at least one function Warning: has no functions — nothing to generate

Usage Guide

Step 1 — Define your actions interface

// Only rules: interface, Unit return, no properties
@ComposeActions
interface EditProfileActions {
    fun onNameChanged(name: String)
    fun onEmailChanged(email: String)
    fun onAvatarClicked()
    fun onSaveClicked()
    fun onCancelClicked()
}

Step 2 — Use the dispatcher in your Composable

@Composable
fun EditProfileScreen(
    state: EditProfileState,
    actions: EditProfileActionsDispatcher  // generated
) {
    TextField(
        value = state.name,
        onValueChange = actions.onNameChanged  // direct reference — no wrapping lambda
    )
    Button(onClick = actions.onSaveClicked) {
        Text("Save")
    }
}

Step 3 — Use Preview stub in @Preview

@Preview(showBackground = true)
@Composable
fun EditProfilePreview() {
    EditProfileScreen(
        state = EditProfileState(name = "John Doe", email = "john@example.com"),
        actions = EditProfileActionsDispatcher.Preview  // generated — no boilerplate
    )
}

Where Generated Files Live

After running ./gradlew :app:build, find generated files at:

app/build/generated/ksp/main/kotlin/
└── tn.studio.kspdemoapp.presentation/
    └── LoginActionsDispatcher.kt

These files are automatically included in compilation — you never need to reference them manually. Never edit them — they are overwritten on every build.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages