Repository navigation
Home
Rules of detekt-rules-compose, described as of version 2.2.0.
| Rule | Active by default | Needs type resolution |
|---|---|---|
ReusedModifierInstance |
yes | yes |
UnnecessaryEventHandlerParameter |
yes | yes |
ComposableEventParameterNaming |
no | no |
ComposableParametersOrdering |
yes | no |
ModifierDefaultValue |
yes | no |
MissingModifierDefaultValue |
yes | no |
ModifierHeightWithText |
yes | no |
PublicComposablePreview |
yes | no |
TopLevelComposableFunctions |
no | no |
ComposableFunctionName |
yes | no |
ConditionCouldBeLifted |
no | yes |
UnnecessaryLayoutWrapper |
no | no |
StateReadCouldBeLowered |
no | yes |
NonImmutableDataClassParameter |
no | yes |
PartiallyUsedViewState |
no | yes |
UnwrappedValueClassImageModel |
no | yes |
UnnecessaryModifierArgument |
no | yes |
"Active by default" is the state in the configuration bundled with the rule set; every rule can be turned on or off
in your detekt-config.yml. Rules which need type resolution only run when detekt has it
(detekt 1, --analysis-mode full on detekt 2). Without it
such a rule reports nothing. All five rules added in 2.2.0 need type resolution.
The rule ids, options and behaviour are the same in both artifacts, ru.kode:detekt-rules-compose (detekt 1) and
ru.kode:detekt-rules-compose-detekt2 (detekt 2).
Active by default. Needs type resolution.
Finds usages of the modifier parameter on non-top-level children of a composable function. This tends to happen during refactorings and often leads to incorrect rendering of a composable.
For example, imagine that Column here used to be a top composable, but then it got wrapped by the Row. But the modifier parameter was moved along with it and is now applied to the wrong composable:
@Composable
fun MyComposable(modifier: Modifier) {
Row(modifier = Modifier.padding(30.dp)) {
Column(modifier = modifier.padding(20.dp)) {
}
}
}
@Composable
fun Content() {
MyComposable(modifier = Modifier.background(color = Color.Green))
}This should be fixed by using modifier parameter on the Row instead:
@Composable
fun MyComposable(modifier: Modifier) {
Row(modifier = modifier.height(30.dp)) {
Column(modifier = Modifier.padding(20.dp)) {
}
}
}A name which only shadows the parameter (a lambda or local function parameter, a local val not derived from the parameter, a loop variable and so on) is not mistaken for it.
Active by default. Needs type resolution.
Suggests hoisting event argument passing to the upper level which often simplifies individual composable components. This makes individual components less coupled to the structure of their parameters and leaves that to the parent, which in turn often leads to simplification of a composable.
For example here the PrettyButton is unnecessary coupled to the structure of Data — it extracts id field inside the onClick:
data class Data(id: Int, title: String)
fun PrettyButton(data: Data, onAction: (Int) -> Unit) {
Button(onClick = { onAction(data.id) })
}
fun Parent() {
val data = Data(id = 3, title = "foo")
PrettyButton(data = data, onAction = { id -> process(id) })
}This "knowledge" of id can be moved to the parent which would not only simplify the PrettyButton by removing unnecessary lambda wrapper around onAction call, but this also makes it easier to work with PrettyButton later, during refactorings. Here the data.id is hoisted into the parent:
fun PrettyButton(data: Data, onAction: () -> Unit) {
Button(onClick = onAction)
}
fun Parent() {
val data = Data(id = 3, title = "foo")
PrettyButton(data = data, onAction = { process(data.id) })
}A constant argument is reported for the same reason, the parent already knows it. A constant is a literal, a string without templates, a Kotlin const val, an enum entry or an object:
fun Toolbar(onUserIntent: (Intent) -> Unit) {
IconButton(onClick = { onUserIntent(Intent.Back) }) // ❌ should be onBack: () -> Unit
}Set reportConstantArguments: false to turn this part off.
An argument is reported only when the parent could really supply it:
-
every call of the handler (
onAction(...),onAction.invoke(...)oronAction?.invoke(...)) passes the same expression at that position. When the calls disagree, the argument is what tells them apart, and nothing is reported:fun Item(id: String, position: Int, visible: Boolean, onShown: (Any, Int) -> Unit) { onShown(id, position) onShown(visible, 0) // ✅ different arguments, not reported } fun Switch(onCheckedChange: (Boolean) -> Unit) { onCheckedChange(true) onCheckedChange(false) // ✅ different constants, not reported }
-
the handler is not used in any other way, for example passed on as a value (
Child(onClick = onClick)): its type is then dictated by that other user. A comparison withnulldoes not count as such a use.
There is one finding per handler for state arguments and one for constants, each lists everything that can be moved.
A function which only shares its name with the handler, like an extension Modifier.onShown(...) called inside a composable with an onShown parameter, is not a call of the handler and is not reported.
UnnecessaryEventHandlerParameter:
active: true
reportConstantArguments: true
Not active by default.
Ensures that all event handler parameters of composable functions are named in the same Compose-like style, i.e. they have on prefix and do not use past tense.
This rule suggests naming improvements
fun Button(click: () -> Unit) // ❌ wrong: missing "on"
fun Button(onClick: () -> Unit) // ✅ correct
fun Box(scroll: () -> Unit) // ❌ wrong: missing "on"
fun Box(onScroll: () -> Unit) // ✅ correct
fun Box(onScrolled: () -> Unit) // ❌ wrong: using past tense
fun Box(onScroll: () -> Unit) // ✅ correctActive by default.
Checks that parameters of Composable functions have a correct order:
- Required parameters come first
- Optional parameters come after required, the
modifierparameter first among them - Slots named in
trailingSlotNames(contentby default) come last, so they can be passed as a trailing lambda
Non-compliant:
Header(
title: String,
enabled: Boolean = false,
description: String,
)Compliant:
Header(
title: String,
description: String,
enabled: Boolean = false,
)modifier must be the first parameter after the required ones and before the other optional ones (this used to be a separate rule, ModifierParameterPosition):
@Composable
fun Button(
modifier: Modifier = Modifier,
text: String,
onClick: () -> Unit,
arrangement: Arrangement = Vertical,
)Should be replaced with:
@Composable
fun Button(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
arrangement: Arrangement = Vertical,
)Rationale: this would allow one to write a short form
Button("Continue", onClick = { ... })
Title("Hello")otherwise it would be required to always use named parameters
Button(text = "Continue", onClick = { ... })
Title(text = "Hello")Google's androidx.compose.material composables follow this convention.
Composable slots are not forced to the end: a required slot can stay among the required parameters and an optional one among the optional parameters, as in Material's TextField and AlertDialog. Two things are checked instead:
- a slot named in
trailingSlotNamesmust be the last parameter - after the first optional parameter only the last parameter may be a required composable slot
@Composable
fun Card(title: String, content: @Composable () -> Unit, footer: @Composable () -> Unit) // ❌ "content" should be last
@Composable
fun Card(modifier: Modifier = Modifier, icon: @Composable () -> Unit, label: @Composable () -> Unit) // ❌ "icon" is required but can't be a trailing lambda
@Composable
fun Card(
title: String,
icon: @Composable () -> Unit,
modifier: Modifier = Modifier,
footer: (@Composable () -> Unit)? = null,
content: @Composable () -> Unit,
) // ✅Lambdas at the end of the parameter list may follow the optional parameters. By default this includes required event handlers:
@Composable
fun Item(id: String, modifier: Modifier = Modifier, onClick: () -> Unit) // ✅ by defaultWith allowTrailingEventHandlers: false such a handler is reported and should be moved to the required parameters (id: String, onClick: () -> Unit, modifier: Modifier = Modifier). An event handler here is a non-composable lambda without a receiver which returns Unit. Lambdas with a receiver (content: LazyListScope.() -> Unit), lambdas returning a value (key: (Int) -> Any), handlers with a default value and parameters named in trailingSlotNames are not reported.
With allowTrailingLambdas: false the same applies to every required non-composable lambda, event handlers included: validator: (String) -> Boolean or onDraw: DrawScope.() -> Unit after optional parameters is reported too. Parameters named in trailingSlotNames are still not reported, so add the names of DSL builder lambdas which should stay last (like content: LazyListScope.() -> Unit) there.
Overriding and actual functions are skipped: their order is dictated by the overridden or expect declaration, which is checked itself.
ComposableParametersOrdering:
active: true
trailingSlotNames: [ 'content' ]
allowTrailingEventHandlers: true
allowTrailingLambdas: true
Active by default.
Checks that the modifier parameter of a Composable function has the correct default value.
Using a default value other than Modifier can lead to various non-obvious issues and inconveniences.
Non-compliant:
fun Content(modifier: Modifier = Modifier.fillMaxSize()) {
Text("Greetings", modifier) // fillMaxSize will be ignored here
}Compliant:
fun Content(modifier: Modifier = Modifier) {
Text("Greetings", modifier.fillMaxSize())
}Active by default.
Checks that the modifier parameter of a Composable function has a default value.
Non-compliant:
fun Content(modifier: Modifier) {
Text("Greetings")
}Compliant:
fun Content(modifier: Modifier = Modifier) {
Text("Greetings")
}Not reported:
- overriding and
actualfunctions, they can't declare default values - the function of a
fun interface, for the same reason - abstract and open functions and functions of interfaces, unless
checkAbstractFunctions: trueis set
checkAbstractFunctions is off by default because the Compose compiler accepts default values in abstract composables only from Kotlin language version 2.1 and in open ones from 2.2 (K2 compiler); older setups fail to compile such code. Turn it on if your project is past these versions:
interface Screen {
@Composable
fun Content(modifier: Modifier) // ❌ with checkAbstractFunctions: true
}MissingModifierDefaultValue:
active: true
checkAbstractFunctions: false
Active by default.
Suggests using Modifier.heightIn() instead of Modifier.height() on a layouts which have Text children, so that if the text turns out to be long and would overflow and wrap, layout will not cut it off
Row(modifier = Modifier.height(24.dp)) {
Text("hello")
}with
Row(modifier = Modifier.heightIn(min = 24.dp)) {
Text("hello")
}height(IntrinsicSize.Min) and height(IntrinsicSize.Max) size the layout to its content and can't clip the text, so they are not reported.
Active by default.
Finds and reports composable previews which are not marked as private
Not active by default.
Checks that composable function is defined as a top-level function.
Non-compliant code would look like this:
interface Screen {
@Composable
fun Content(modifier: Modifier = Modifier)
}
class ScreenImpl : Screen {
@Composable
override fun Content(modifier: Modifier) {
Text("Greetings", modifier.fillMaxSize())
}
}And the compliant code is to have a top-level composable function:
fun ScreenContent(modifier: Modifier = Modifier) {
Text("Greetings", modifier.fillMaxSize())
}Two options relax the rule:
-
allowInObjects: truepermits composable functions inobjects -
allowInInterfaces: truepermits composable functions declared ininterfaces (for example a composable provided by another module through DI)
Composable functions in classes are always reported.
TopLevelComposableFunctions:
active: true
allowInObjects: false
allowInInterfaces: false
Active by default.
The @Composable functions that return Unit should start with upper-case while the ones that return a value should
start with lower case.
Examples of compliant and non-compliant code are given below.
Non-compliant:
@Composable
fun button() {
…
}Correct:
@Composable
fun Button() {
…
}Non-compliant:
@Composable
fun Value(): Int = …Compliant:
@Composable
fun value(): Int = …See also: Compose API guidelines.
Not active by default. Needs type resolution.
Reports cases where a Compose layout contains a single conditional expression which could be "lifted" out of it.
For example in this code the if-condition could be moved outside of Column without any problems and this could potentially result in snappier rendering due to less composables having to be accounted for and/or recomposed:
Column {
if(condition) {
Row()
Row()
}
}When moved out:
if(condition) {
Column {
Row()
Row()
}
}Not reported, because the condition can't be lifted without changing what is shown:
-
a condition on a name declared inside the content lambda: its parameters (including the implicit
it) or a localvaldeclared before theifCrossfade(targetState = expanded) { isExpanded -> if (isExpanded) { Details() } // ✅ isExpanded exists only inside the lambda }
-
a layout which also has a composable call outside the
if, in any form:slot?.invoke(),slot?.let { it() },items.forEach { Text(it) },repeat(n) { Text() },when (x) { a -> Text() }
Use ignoreCallsWithArgumentNames config option to specify argument names which (when present) will make this rule ignore and skip those calls:
// in detekt-config.yaml
ConditionCouldBeLifted:
active: true
ignoreCallsWithArgumentNames: [ 'modifier', 'contentAlignment' ]
The bundled default is [ 'modifier' ].
Not active by default.
Reports a Box, Column or Row which has no parameters and only wraps a single Box, Column or Row: such a wrapper usually adds nothing but a layout node, and its child can be used directly.
Non-compliant:
Box {
Row(modifier = Modifier.padding(16.dp)) {
Text("hello")
}
}Compliant:
Row(modifier = Modifier.padding(16.dp)) {
Text("hello")
}The check is name-based and conservative: the wrapper must have nothing but a trailing content lambda, and the lambda must contain nothing but the child call. Still, removing a wrapper is not always a no-op, which is why the rule is not active by default:
- scope modifiers: a child using
weight,align,alignBy,alignByBaselineormatchParentSizedepends on the wrapper's scope and is not reported, but the same modifiers passed through a variable are not detected - min constraints: the wrapper measures its child with a minimum size of 0; without it the child gets the incoming minimum constraints (for example as the content of a
Surface) and can be stretched - name-based matching: project components named
Box,RoworColumnare matched too
Not active by default. Needs type resolution.
Reports a val of a composable function which holds a state read (.value of a State or .current of a CompositionLocal) and is only used minDepth or more composable lambdas deeper. A state read in the function body makes the whole function recompose when the state changes; read next to its use, it recomposes only the scope which needs it.
Non-compliant:
@Composable
fun Screen() {
val isPortrait = LocalOrientation.current == Orientation.Portrait
Card {
Surface {
if (isPortrait) Text("Portrait")
}
}
}Compliant:
@Composable
fun Screen() {
Card {
Surface {
val isPortrait = LocalOrientation.current == Orientation.Portrait
if (isPortrait) Text("Portrait")
}
}
}How the depth is counted:
-
a lambda passed to any composable lambda parameter counts, not only
content. The message names the deepest lambda shared by all usages:"count" is only used inside the content of "Surface", or, for another slot,"title" is only used inside the "topBar" slot of "Scaffold" -
the lambda of an
inlinecomposable (Box,Column,Row) does not count: it is inlined into the caller and is no recomposition scope of its own, a read moved into it is still a read of the scope aroundval count = state.value Card { Column { // inline, not a level Text(text = count.toString()) // ✅ one level deep only } }
-
a
crossinlinelambda (itemContentof the lazy listitems(list) { }) and anoinlinelambda of an inline composable do count: they end up in a lambda of their ownval count = state.value LazyColumn { items(list) { Text(text = (it + count).toString()) } // ❌ used inside the "itemContent" slot of "items" }
-
a lambda which is not composable (
onClick,forEach { }) does not count
Not reported:
-
.valueof a receiver which is not aStateand.currentof one which is not aCompositionLocal(subtypes likeMutableState, nullable and smart cast receivers are recognized) - a delegate (
val count by state), avar, avalwith aremember*call or a lambda in its initializer - a
valwhose initializer uses a name which is redeclared on the way down: moved there, it would read another declaration - a
valwhich is not used, is used in the function body or fewer thanminDepthlambdas deep
Limits: only the vals declared directly in the block body of a composable function are checked, the ones in nested blocks and functions with an expression body are not.
// in detekt-config.yaml
StateReadCouldBeLowered:
active: true
minDepth: 2
The bundled default of minDepth is 2.
Not active by default. Needs type resolution.
Reports a parameter of a composable function whose type, or one of its type arguments, is a data class which is not marked with @Immutable or @Stable.
With strong skipping (the default since Kotlin 2.0.20) a composable with an unstable parameter is skippable too, but such a parameter is compared by instance (===) instead of equals(): an equal data class which is created anew, by a copy() or a mapper, still recomposes the composable. The annotation brings the comparison by equals() back.
Non-compliant:
data class Profile(val name: String)
@Composable
fun ProfileCard(profile: Profile) {} // ❌ is a data class "Profile" which is not marked with @Immutable or @Stable
@Composable
fun ProfileList(profiles: List<Profile>) {} // ❌ uses a data class "Profile" which is not marked …Compliant:
@Immutable
data class Profile(val name: String)
@Composable
fun ProfileCard(profile: Profile) {}Not reported:
- data classes of the
kotlinpackage (Pair,Triple), they can't be annotated - data classes of a library built with the Compose compiler which are known to be stable at compile time (
@StabilityInferred). A generic one is stable together with the type arguments it depends on, and only those arguments are checked: withdata class Box<T>(val value: T)in a library,Box<Int>is not reported andBox<Profile>reportsProfile - data classes of the packages listed in
ignoredPackageSegments, for example generated ones. A segment is matched against the package only, not against the names of outer classes - data classes matching
stableTypePatterns -
varargparameters, types which are not data classes, types which can't be resolved
stableTypePatterns has the syntax of the stability configuration file of the Compose compiler. A detekt rule can't locate that file (stability_config.conf) itself, copy its lines into the option:
-
*is one or more characters within one name segment:java.time.*,com.example.P* -
**crosses the segments:com.example.**,com.**.Inner - a generic suffix tells which type arguments are still checked: in
com.example.Both<*,_>the first argument is checked and the second (_) is not. Without the suffix all arguments are checked - blank lines and lines starting with
//are skipped - a line which the Compose compiler would not accept (
com.example.*Profile, a trailing comment,com/example/Profile) fails the run withNonImmutableDataClassParameter: stableTypePatterns "<pattern>" is not a valid pattern
Limits:
- a class of the linted module is judged by its annotations only. The stability which the Compose compiler would infer for it (all properties are
vals of stable types) is not computed, the class has no@StabilityInferredinside detekt. Annotate such a class or list it instableTypePatterns - a library data class whose stability the Compose compiler left to the runtime (it has properties of classes of another module) is reported. List it in
stableTypePatterns - unlike the Compose compiler, the rule does not match a pattern against the supertypes of a class
- the type arguments are read from the written type, the ones behind a type alias are not seen
- only data classes are judged: an unstable
List,java.util.Dateor a class with avaris not reported, neither is the extension receiver of the composable.List<Profile>stays unstable for the Compose compiler even with an annotatedProfile, use an immutable collection or the stability configuration file for that
The ImmutableDataClass rule of the kode rule set reports the declaration of a data class in a ui package without @Immutable. This rule reports the usage, wherever the class is declared, and accepts @Stable too. When both are active, ignoredPackageSegments: [ 'ui' ] leaves the classes of the ui packages to ImmutableDataClass.
// in detekt-config.yaml
NonImmutableDataClassParameter:
active: true
ignoredPackageSegments: [ 'schema' ]
stableTypePatterns: [ 'com.example.model.**', 'java.time.*' ]
Both options are empty by default.
Not active by default. Needs type resolution.
Reports a composable function which takes a whole view state but reads only a few of its properties. Such a function is recomposed on every change of the state, it should take the properties it needs instead.
Non-compliant:
data class ViewState(val title: String, val subtitle: String, val items: List<Item>, val isLoading: Boolean)
@Composable
fun Header(state: ViewState) { // ❌ reads only 1 of 4 "ViewState" properties (title)
Text(text = state.title)
}Compliant:
@Composable
fun Header(title: String) {
Text(text = title)
}A parameter is checked when the simple name of its class matches the regex stateClassPattern and the class has at least usedFieldsThreshold properties. It is reported when at least one and fewer than usedFieldsThreshold of them are read. A nullable state (state: ViewState?, state?.title, state!!.title, state != null) is handled the same way.
What counts:
- only public and internal properties, the inherited ones included; private and extension properties are neither counted nor reads
state.title-
titleinsidewith(state) { },state.run { }andstate.apply { }. The read is matched by the receiver instance, so inside nested scope functions a name belongs to the receiver it is resolved on - a destructuring declaration:
val (title, _) = statereadstitle
Not reported:
- a function which uses the state as a whole: passes it on (
log(state),remember(state) { }), calls its function (state.copy(),describe()insidewith(state) { }), mentionsthisinside the scope function, or uses the result ofstate.apply { }as a value, also as the value of anif,whenortrybranch -
state.let { it.title }andstate.also { }: they pass the state on, so they are uses of the whole state - a function whose signature is not its own:
override,open,abstract,expect,actualor declared in an interface - a state which is not read at all or is only compared with
null -
varargparameters and functions which are not composable
An invalid stateClassPattern fails the run with PartiallyUsedViewState: stateClassPattern "<pattern>" is not a valid regex.
Limits: with, run and apply are matched by name.
// in detekt-config.yaml
PartiallyUsedViewState:
active: true
stateClassPattern: '.*ViewState'
usedFieldsThreshold: 3
The bundled defaults are '.*ViewState' and 3.
Not active by default. Needs type resolution.
Reports a value class passed as is as a Coil image model. The model parameter is Any?, so the code compiles, but the value class gets boxed and Coil can't load an image from it. Pass the underlying value.
Non-compliant:
@JvmInline
value class ImageUrl(val value: String)
@Composable
fun Avatar(url: ImageUrl) {
AsyncImage(model = url, contentDescription = null)
AsyncImage(model = ImageRequest.Builder(context).data(url).build(), contentDescription = null)
}Compliant:
@Composable
fun Avatar(url: ImageUrl) {
AsyncImage(model = url.value, contentDescription = null)
AsyncImage(model = ImageRequest.Builder(context).data(url.value).build(), contentDescription = null)
}Checked arguments:
- the
modelargument (named or positional) of the functions listed inimageFunctions, matched by the name of the function - the
dataargument of adata(...)call whose resolved declaring class ends with.ImageRequest.Builder, so bothcoil.request.ImageRequest.Builderand the one ofcoil3, also when the builder is not the start of a call chain (builder.apply { data(url) })
A nullable value class (ImageUrl?) and a model which is smart cast to a value class (if (model is ImageUrl) AsyncImage(model = model, …)) are reported too.
Not reported:
- value classes of the
kotlinpackage (UInt,Result) - an unwrapped value (
url.value), a regular class,Any?, an unresolved expression - a value class passed to other functions or other parameters
// in detekt-config.yaml
UnwrappedValueClassImageModel:
active: true
imageFunctions: [ 'AsyncImage', 'SubcomposeAsyncImage', 'rememberAsyncImagePainter' ]
This list is the bundled default of imageFunctions; add the names of your own image wrappers which take a model to it.
Not active by default. Needs type resolution.
Reports a Modifier passed to a parameter which already has Modifier as its default value. The argument changes nothing and can be removed.
Non-compliant:
@Composable
fun Header(title: String) {
Text(title, modifier = Modifier)
AppCard(Modifier) { Text(title) }
}Compliant:
@Composable
fun Header(title: String) {
Text(title)
AppCard { Text(title) }
Text(title, modifier = Modifier.padding(8.dp))
}Checked arguments:
- an argument which is
Modifieritself, also fully qualified (androidx.compose.ui.Modifier) or in parentheses, whether positional, named or out of order - passed to a parameter whose default value is
Modifieritself. The name of the parameter does not matter,contentModifier: Modifier = Modifieris checked the same; neither does the kind of the function: composables, regular functions, members (inherited and delegated ones included), extensions and constructors called in an expression (AppCard(Modifier), also through a type alias) are checked - of the overload which is actually called
Not reported:
- a parameter without a default value, or with another one (
modifier: Modifier = Modifier.fillMaxWidth()) - another argument: a modifier chain (
Modifier.padding(8.dp)), a variable or a parameter which holds a modifier - a function of a library. Only the functions declared in the analyzed sources have their default values available, so
Text(title, modifier = Modifier)is reported for your ownTextwrapper and not for the one of Compose - a generated function, like the
copyof a data class - the constructor call of a supertype (
class Child : Base(Modifier)) or of an enum entry - a call which can't be resolved, or which is resolved with an error (a wrong argument type, an extra argument)
A positional argument followed by another positional one can't be removed alone, the next argument would move to its parameter. The message tells to pass the following arguments by name then: AppCard("Title", Modifier, false) becomes AppCard("Title", enabled = false). A trailing lambda outside the parentheses does not count, AppCard("Title", Modifier) { } only needs the argument removed, and so does an argument followed by named ones.
Removing the argument can make the call resolve to another overload which has no such parameter. The rule does not look for one, suppress the finding with @Suppress("UnnecessaryModifierArgument") in that case.
// in detekt-config.yaml
UnnecessaryModifierArgument:
active: true
The rule has no options.