Skip to content

Latest commit

 

History

History
1048 lines (799 loc) · 25.1 KB

File metadata and controls

1048 lines (799 loc) · 25.1 KB

ComposeGuard Documentation

ComposeGuard is a real-time Jetpack Compose best practices inspector for IntelliJ IDEA and Android Studio. It provides instant feedback as you write code with visual indicators, quick fixes, and detailed explanations for Compose rule violations.


Table of Contents


Installation

Requirements

  • IDE: IntelliJ IDEA 2024.2+ or Android Studio Ladybug+
  • Kotlin Plugin: Required
  • K2 Mode: Fully supported

Install from JetBrains Marketplace

  1. Open Settings/PreferencesPlugins
  2. Search for "ComposeGuard"
  3. Click Install
  4. Restart your IDE

Install from Disk

  1. Download the plugin .zip file
  2. Open Settings/PreferencesPlugins
  3. Click the gear icon → Install Plugin from Disk...
  4. Select the downloaded file
  5. Restart your IDE

Features

Real-Time Code Analysis

ComposeGuard analyzes your Compose code as you type and provides:

  • Inline Highlighting - Colored underlines for violations
  • Gutter Icons - Visual indicators in the left margin
  • Inline Hints - Small badges next to function names
  • Hover Tooltips - Detailed explanations on mouse hover

Severity Levels

Color Severity Description
Red Error Critical issues that will cause bugs
Orange Warning Best practice violations
Gray Weak Warning Minor style issues
Blue Info Informational suggestions

Quick Fixes

Press Alt+Enter (Windows/Linux) or Cmd+Enter (macOS) on any highlighted issue to see available quick fixes.


Rules Reference

ComposeGuard includes 39 rules organized into 6 categories.

Naming Rules

ComposableNaming

Severity: Warning

Composable functions that return Unit should use PascalCase. Functions that return a value should use camelCase.

// Wrong
@Composable
fun userCard(user: User) { }  // Should be PascalCase

// Correct
@Composable
fun UserCard(user: User) { }

// Value-returning composables use camelCase
@Composable
fun rememberScrollState(): ScrollState { }

CompositionLocalNaming

Severity: Warning

CompositionLocal properties must be prefixed with Local.

// Wrong
val CurrentTheme = compositionLocalOf { Theme.Light }

// Correct
val LocalTheme = compositionLocalOf { Theme.Light }

PreviewNaming

Severity: Weak Warning

Preview functions should contain "Preview" in their name.

// Wrong
@Preview
@Composable
private fun UserCard() { }

// Correct
@Preview
@Composable
private fun UserCardPreview() { }

MultipreviewNaming

Severity: Weak Warning

Multipreview annotations should start with Previews.

// Wrong
@Preview(name = "Light")
@Preview(name = "Dark")
annotation class LightDarkPreview

// Correct
@Preview(name = "Light")
@Preview(name = "Dark")
annotation class PreviewsLightDark

EventParameterNaming

Severity: Weak Warning

Event callback parameters should follow the onX pattern with present tense.

// Wrong
@Composable
fun Button(onClicked: () -> Unit) { }  // Past tense

// Correct
@Composable
fun Button(onClick: () -> Unit) { }

ComposableAnnotationNaming

Severity: Weak Warning

Custom composable annotations should end with "Composable".

// Wrong
@Composable
annotation class GoogleMap

// Correct
@Composable
annotation class GoogleMapComposable

Modifier Rules

ModifierRequired

Severity: Warning

Public composables that emit UI should accept a modifier parameter.

// Wrong
@Composable
fun ProductCard(product: Product) {
    Card { /* ... */ }
}

// Correct
@Composable
fun ProductCard(
    product: Product,
    modifier: Modifier = Modifier
) {
    Card(modifier = modifier) { /* ... */ }
}

ModifierDefaultValue

Severity: Warning

Modifier parameters should have = Modifier as the default value.

// Wrong
@Composable
fun Card(modifier: Modifier) { }

// Correct
@Composable
fun Card(modifier: Modifier = Modifier) { }

ModifierNaming

Severity: Weak Warning

The main modifier parameter must be named modifier. Additional modifiers use an xModifier suffix.

// Wrong
@Composable
fun Card(mod: Modifier = Modifier) { }

// Wrong - secondary modifier without a descriptive prefix
@Composable
fun Card(modifier: Modifier = Modifier, modifier2: Modifier = Modifier) { }

// Correct
@Composable
fun Card(modifier: Modifier = Modifier, contentModifier: Modifier = Modifier) { }

ModifierTopMost

Severity: Warning

The modifier parameter should be applied to the root/top-most layout.

// Wrong
@Composable
fun Card(modifier: Modifier = Modifier) {
    Column {
        Text("Title", modifier = modifier)  // Applied to child, not root
    }
}

// Correct
@Composable
fun Card(modifier: Modifier = Modifier) {
    Column(modifier = modifier) {
        Text("Title")
    }
}

ModifierReuse

Severity: Warning

Don't reuse the same modifier instance on multiple children.

// Wrong
@Composable
fun Card(modifier: Modifier = Modifier) {
    Column {
        Text("First", modifier = modifier)
        Text("Second", modifier = modifier)  // Reused!
    }
}

// Correct
@Composable
fun Card(modifier: Modifier = Modifier) {
    Column(modifier = modifier) {
        Text("First")
        Text("Second")
    }
}

ModifierOrder

Severity: Warning

Modifier chain order matters. Certain modifiers must come before others.

// Wrong - clickable area won't be clipped
Box(
    modifier = Modifier
        .clickable { }
        .clip(CircleShape)
)

// Correct - click area is properly clipped
Box(
    modifier = Modifier
        .clip(CircleShape)
        .clickable { }
)

AvoidComposed

Severity: Warning

Avoid Modifier.composed { }. Use Modifier.Node for better performance.

// Wrong
fun Modifier.fade() = composed {
    val alpha by animateFloatAsState(1f)
    this.alpha(alpha)
}

// Correct - Use Modifier.Node pattern instead

State Rules

RememberState

Severity: Error

State creators must be wrapped in remember { } to survive recomposition.

// Wrong - State is recreated on every recomposition!
@Composable
fun Counter() {
    val count = mutableStateOf(0)
}

// Correct
@Composable
fun Counter() {
    val count = remember { mutableStateOf(0) }
    // or using delegate syntax:
    var count by remember { mutableStateOf(0) }
}

TypeSpecificState

Severity: Warning

Use type-specific state functions for primitives.

// Wrong
val count = remember { mutableStateOf(0) }

// Correct - Better performance
val count = remember { mutableIntStateOf(0) }

// Available type-specific functions:
// - mutableIntStateOf()
// - mutableLongStateOf()
// - mutableFloatStateOf()
// - mutableDoubleStateOf()

HoistState

Severity: Info

State should be hoisted to the appropriate parent level.

// Wrong - State is too low in the hierarchy
@Composable
fun Parent() {
    val state = remember { mutableStateOf("") }
    Child(state)  // Passing state down
}

// Correct - Hoist state and pass value + callback
@Composable
fun Parent() {
    var value by remember { mutableStateOf("") }
    Child(value = value, onValueChange = { value = it })
}

MutableStateParameter

Severity: Warning

Don't pass MutableState<T> as a parameter. Use value + callback pattern.

// Wrong
@Composable
fun TextField(state: MutableState<String>) { }

// Correct
@Composable
fun TextField(
    value: String,
    onValueChange: (String) -> Unit
) { }

DerivedStateOfCandidate

Severity: Warning

Values computed from other state should be wrapped in derivedStateOf so they only trigger recomposition when the derived result actually changes.

// Wrong - recomposes on every scroll position change
val showButton = listState.firstVisibleItemIndex > 0

// Correct - only recomposes when the boolean flips
val showButton by remember {
    derivedStateOf { listState.firstVisibleItemIndex > 0 }
}

FrequentRecomposition

Severity: Warning

Hot observable sources (Flow, LiveData) should be collected in a lifecycle-aware way so they don't drive excessive recomposition.

// Wrong - collects regardless of lifecycle state
val state by viewModel.uiState.collectAsState()

// Correct - pauses collection when the UI isn't visible
val state by viewModel.uiState.collectAsStateWithLifecycle()

DeferStateReads

Severity: Warning

Defer reads of fast-changing state to the latest possible phase (e.g. a lambda-based modifier) so only layout/draw re-runs instead of the whole composable.

// Wrong - reads offset during composition, recomposing every frame
Box(modifier = Modifier.offset(x = scrollState.value.dp))

// Correct - reads offset in the layout phase via the lambda overload
Box(modifier = Modifier.offset { IntOffset(scrollState.value, 0) })

Parameter Rules

ParameterOrdering

Severity: Weak Warning

Parameters should be ordered: required params → modifier → optional params → content slot.

// Wrong
@Composable
fun Card(
    content: @Composable () -> Unit,
    title: String,
    modifier: Modifier = Modifier,
) { }

// Correct
@Composable
fun Card(
    title: String,                          // Required first
    modifier: Modifier = Modifier,          // Modifier after required
    subtitle: String = "",                  // Optional params
    content: @Composable () -> Unit         // Content slot last
) { }

TrailingLambda

Severity: Weak Warning

Content slot parameters should be last to enable trailing lambda syntax.

// Wrong - Cannot use trailing lambda syntax
@Composable
fun Card(
    content: @Composable () -> Unit,
    title: String,
) { }

// Correct - Enables: Card("Title") { /* content */ }
@Composable
fun Card(
    title: String,
    content: @Composable () -> Unit
) { }

MutableParameter

Severity: Warning

Don't use mutable collection types as parameters.

// Wrong
@Composable
fun ItemList(items: MutableList<Item>) { }

// Correct
@Composable
fun ItemList(items: List<Item>) { }

// Best - Use immutable collections for stability
@Composable
fun ItemList(items: ImmutableList<Item>) { }

ExplicitDependencies

Severity: Weak Warning

ViewModel and DI dependencies should be explicit parameters.

// Wrong - Implicit dependency
@Composable
fun UserScreen() {
    val viewModel: UserViewModel = viewModel()
}

// Correct - Explicit dependency
@Composable
fun UserScreen(
    viewModel: UserViewModel = viewModel()
) { }

ViewModelForwarding

Severity: Warning

Don't pass ViewModels through composable layers.

// Wrong - ViewModel passed to children
@Composable
fun Parent(viewModel: MainViewModel) {
    Child(viewModel = viewModel)
}

// Correct - Pass only necessary data and callbacks
@Composable
fun Parent(viewModel: MainViewModel) {
    Child(
        data = viewModel.data,
        onAction = viewModel::handleAction
    )
}

Composable Rules

ContentEmission

Severity: Warning

Composables should either emit UI content OR return a value, not both.

// Wrong - Both emits content and returns value
@Composable
fun BadComposable(): State<Int> {
    Text("Hello")
    return remember { mutableIntStateOf(0) }
}

// Correct - Emit only
@Composable
fun GoodComposable() {
    Text("Hello")
}

// Correct - Return only
@Composable
fun rememberCounter(): State<Int> {
    return remember { mutableIntStateOf(0) }
}

MultipleContentEmitters

Severity: Warning

Don't emit multiple top-level layout nodes.

// Wrong - Multiple top-level nodes
@Composable
fun Card() {
    Text("First")
    Text("Second")
}

// Correct - Single root container
@Composable
fun Card() {
    Column {
        Text("First")
        Text("Second")
    }
}

ContentSlotReused

Severity: Warning

Content slots shouldn't be invoked multiple times in branching code.

// Wrong - Content invoked in multiple branches
@Composable
fun Container(content: @Composable () -> Unit) {
    if (condition) {
        content()
    } else {
        content()  // Reused in another branch
    }
}

// Correct - Use movableContentOf
@Composable
fun Container(content: @Composable () -> Unit) {
    val movableContent = remember { movableContentOf(content) }
    if (condition) {
        movableContent()
    } else {
        movableContent()
    }
}

EffectKeys

Severity: Warning

Effects must have proper restart keys.

// Wrong - Missing key
@Composable
fun Timer(duration: Int) {
    LaunchedEffect(Unit) {
        delay(duration.toLong())  // duration not in keys!
    }
}

// Correct - Include all dependencies as keys
@Composable
fun Timer(duration: Int) {
    LaunchedEffect(duration) {
        delay(duration.toLong())
    }
}

LambdaParameterInEffect

Severity: Warning

Lambda parameters used in effects must be keys or use rememberUpdatedState.

// Wrong
@Composable
fun Button(onClick: () -> Unit) {
    LaunchedEffect(Unit) {
        onClick()  // Lambda not in keys
    }
}

// Correct - Use rememberUpdatedState
@Composable
fun Button(onClick: () -> Unit) {
    val currentOnClick by rememberUpdatedState(onClick)
    LaunchedEffect(Unit) {
        currentOnClick()
    }
}

MovableContent

Severity: Error

movableContentOf must be wrapped in remember.

// Wrong - Recreated on every recomposition
@Composable
fun Container(content: @Composable () -> Unit) {
    val movable = movableContentOf(content)
}

// Correct
@Composable
fun Container(content: @Composable () -> Unit) {
    val movable = remember { movableContentOf(content) }
}

PreviewVisibility

Severity: Warning

Preview functions should be private.

// Wrong
@Preview
@Composable
fun CardPreview() { }

// Correct
@Preview
@Composable
private fun CardPreview() { }

ComponentDefaultsVisibility

Severity: Warning

A <Component>Defaults object should have the same visibility as the composable it accompanies, so callers can read and build on those defaults.

// Wrong - public composable, but its defaults are hidden
@Composable
fun Badge(modifier: Modifier = Modifier, color: Color = BadgeDefaults.color) { }

private object BadgeDefaults {
    val color = Color.Red
}

// Correct - defaults match the composable's visibility
@Composable
fun Badge(modifier: Modifier = Modifier, color: Color = BadgeDefaults.color) { }

object BadgeDefaults {
    val color = Color.Red
}

LazyListMissingKey

Severity: Info

items in a lazy list should provide a stable key so Compose can track items across data changes.

// Wrong
LazyColumn {
    items(users) { user -> UserRow(user) }
}

// Correct
LazyColumn {
    items(users, key = { it.id }) { user -> UserRow(user) }
}

LazyListContentType

Severity: Info

Heterogeneous lazy lists should set a contentType so Compose can reuse compositions of the same type efficiently.

// Wrong - mixed item types with no contentType hint
LazyColumn {
    items(feed, key = { it.id }) { item -> FeedItem(item) }
}

// Correct
LazyColumn {
    items(
        feed,
        key = { it.id },
        contentType = { it.type },
    ) { item -> FeedItem(item) }
}

ComposableNestingDepth

Severity: Weak Warning (opt-in)

Composable calls nested deeper than the configured threshold (default 3) are reported. Extract the inner content into its own composable. Enable it in settings or with compose_composable_nesting_depth_enabled = true; tune the limit with compose_composable_nesting_depth_threshold.

// Wrong (threshold 3)
Column { Row { Box { Card { Text("deep") } } } }

// Right
Column { Row { Box { DeepCard() } } }

Stricter Rules

These rules are enabled by default but can be disabled if they don't fit your project.

Material2Usage

Severity: Info

Detects Material 2 usage and suggests migration to Material 3.

// Detected - Material 2 import
import androidx.compose.material.Button

// Suggested - Material 3
import androidx.compose.material3.Button

CompositionLocalAllowlist

Severity: Warning (opt-in)

Declaring a custom CompositionLocal is reported unless its name is listed in compose_allowed_composition_locals. CompositionLocals make dependencies implicit; prefer explicit parameters.

// .editorconfig
// compose_allowed_composition_locals = LocalTheme

val LocalTheme = compositionLocalOf { Theme() }   // allowed
val LocalUser = compositionLocalOf<User?> { null } // reported

UnstableCollections

Severity: Warning

Use immutable collections for Compose stability.

// Wrong - Unstable collection
@Composable
fun ItemList(items: List<Item>) { }

// Correct - Stable immutable collection
@Composable
fun ItemList(items: ImmutableList<Item>) { }

Quick Fixes

ComposeGuard provides automatic fixes for most violations. Press Alt+Enter (Windows/Linux) or Cmd+Enter (macOS) to see available fixes.

Quick Fix Description
Rename Composable Fix naming convention violations
Add Modifier Parameter Add missing modifier parameter
Add Default Value Add = Modifier default
Wrap in Remember Wrap state in remember { }
Use Type-Specific State Convert to mutableIntStateOf, etc.
Make Preview Private Add private modifier to preview
Use Immutable Collection Replace with ImmutableList, etc.
Migrate to Material 3 Update imports to Material 3
Reorder Parameters Fix parameter ordering
Reorder Modifiers Fix modifier chain order
Move to Trailing Lambda Move content parameter to last position
Move Modifier to Root Apply modifier to root layout
Add Local Prefix Add "Local" prefix to CompositionLocal
Suppress Rule Add @Suppress annotation

Configuration

Settings Location

Settings/PreferencesToolsComposeGuard

Available Options

  • Enable/disable entire rule categories
  • Toggle individual rules on/off
  • Settings persist between sessions

Project Configuration

Project configuration (.editorconfig)

ComposeGuard reads the same compose_* keys that the upstream Compose Rules ktlint ruleset uses, so a team can commit one .editorconfig and share it between the IDE plugin and CI. Keys are read from the nearest .editorconfig (walking up to the one marked root = true) in any section that applies to Kotlin files, for example [*.{kt,kts}]. Lists are comma-separated; entries containing regex metacharacters are treated as regular expressions.

Key Affects Meaning
compose_allowed_composable_function_names ComposableNaming Names (or regexes) exempt from the PascalCase/camelCase check
compose_content_emitters ContentEmission, MultipleContentEmitters, ModifierRequired, TrailingLambda Extra composables that count as emitting UI
compose_content_emitters_denylist same as above Composables that must never count as emitting UI
compose_check_modifiers_for_visibility ModifierRequired only_public (default), public_and_internal, or all
compose_modifier_missing_ignore_annotated ModifierRequired Annotation names whose composables are skipped
compose_custom_modifiers ModifierRequired, ModifierNaming, ModifierDefaultValue, ParameterOrdering, TrailingLambda Extra types treated as Modifier (e.g. GlanceModifier)
compose_treat_as_lambda ParameterOrdering, TrailingLambda Type aliases treated as plain lambdas
compose_treat_as_composable_lambda ParameterOrdering, TrailingLambda Type aliases treated as @Composable content slots
compose_view_model_factories ExplicitDependencies Extra ViewModel factory functions
compose_allowed_composition_locals ExplicitDependencies, CompositionLocalAllowlist CompositionLocals that may be read or declared
compose_allowed_state_holder_names ViewModelForwarding Type name regexes that are not treated as forwarded ViewModels
compose_allowed_forwarding ViewModelForwarding Composables a ViewModel may be forwarded to
compose_allowed_forwarding_of_types ViewModelForwarding ViewModel types that may be forwarded
compose_allowed_from_m2 Material2Usage Material 2 imports (or package prefixes) that are allowed
compose_allowed_lambda_parameter_names EventParameterNaming Event parameter names exempt from the present-tense check
compose_preview_naming_strategy PreviewNaming anywhere (default), suffix, or prefix
compose_composable_nesting_depth_threshold ComposableNestingDepth Maximum nesting depth (default 3)
compose_disallow_material2, compose_disallow_unstable_collections, compose_preview_naming_enabled, compose_composable_nesting_depth_enabled rule enablement true turns the rule on for this project regardless of IDE settings
root = true

[*.{kt,kts}]
compose_allowed_from_m2 = androidx.compose.material.icons
compose_treat_as_composable_lambda = Slot
compose_composable_nesting_depth_enabled = true
compose_composable_nesting_depth_threshold = 4

Suppressing Rules

Using @Suppress Annotation

@Suppress("ComposableNaming")
@Composable
fun myComposable() { }

// Suppress multiple rules
@Suppress("ComposableNaming", "ModifierRequired")
@Composable
fun myComposable() { }

// The named-argument form and @Suppress("ALL") are honoured too
@Suppress(names = ["ComposableNaming"])
@Composable
fun myComposable() { }

@Suppress on an enclosing class or object applies to every declaration inside it.

Suppressing for a whole file

@file:Suppress("ModifierRequired")
package demo

Every violation also offers a "Suppress '' in file" quick fix that adds or extends this annotation.

Using IntelliJ Comment

// noinspection ComposableNaming
@Composable
fun myComposable() { }

// noinspection ComposableNaming, ModifierRequired
@Composable
fun myComposable() { }

Rule IDs for Suppression

Category Rule IDs
Naming ComposableNaming, CompositionLocalNaming, PreviewNaming, MultipreviewNaming, EventParameterNaming, ComposableAnnotationNaming
Modifier ModifierRequired, ModifierDefaultValue, ModifierNaming, ModifierTopMost, ModifierReuse, ModifierOrder, AvoidComposed
State RememberState, TypeSpecificState, HoistState, MutableStateParameter, DerivedStateOfCandidate, FrequentRecomposition, DeferStateReads
Parameter ParameterOrdering, TrailingLambda, MutableParameter, ExplicitDependencies, ViewModelForwarding
Composable ContentEmission, MultipleContentEmitters, ContentSlotReused, EffectKeys, LambdaParameterInEffect, MovableContent, PreviewVisibility, ComponentDefaultsVisibility, LazyListMissingKey, LazyListContentType, ComposableNestingDepth
Stricter Material2Usage, UnstableCollections, CompositionLocalAllowlist

Examples

Before ComposeGuard

@Composable
fun userProfile(
    content: @Composable () -> Unit,
    user: User,
    onClicked: () -> Unit,
) {
    val expanded = mutableStateOf(false)
    val items = mutableListOf<Item>()

    Column {
        Text(user.name)
        Button(onClick = onClicked) {
            Text("Click")
        }
    }
    Text("Footer")
}

After ComposeGuard Fixes

@Composable
fun UserProfile(                              // PascalCase naming
    user: User,                               // Required params first
    modifier: Modifier = Modifier,            // Modifier parameter added
    onClick: () -> Unit = {},                 // Present tense naming
    content: @Composable () -> Unit           // Content slot last
) {
    var expanded by remember { mutableStateOf(false) }  // Wrapped in remember
    val items: ImmutableList<Item> = persistentListOf() // Immutable collection

    Column(modifier = modifier) {             // Modifier applied to root
        Text(user.name)
        Button(onClick = onClick) {
            Text("Click")
        }
        Text("Footer")                        // Single root container
        content()
    }
}

Resources


Support


License

Apache License 2.0