Skip to content

Commit d5a2190

Browse files
authored
Introduce compose.ui dependency and add Modifier to Ui.Content() (#422)
This is a breaking API change to `Ui.Content()` to add a `Modifier` parameter. There are breaking changes, but if using code gen it's fairly minimal. ```diff public interface Ui<UiState : CircuitUiState> { - @composable public fun Content(state: UiState) + @composable public fun Content(state: UiState, modifier: Modifier) } ``` We've been getting stuck on this for awhile now when trying to bridge to UI land and finally pulling the trigger on this to ease a bunch of things. At a high level, this replaces the toe-hold `ScreenUi` we've been keeping around as modifiers give us a means of plumbing down extra UI metadata, so I've removed it from the API. We can also offer a default onUnavailableContent API now, so I've made it non-nullable in `CircuitConfig` using that default. If people want the previous erroring behavior, they can still achieve this with a custom function of their own. One place this hits friction is UI libraries that don't use Modifiers (i.e. Mosaic), but it's easy enough to just ignore that parameter coming from `ui { state, _ -> ... }`. Other things - Realized our code gen artifact was sorta halfway multiplatform and resulted in confusing KSP configuration errors. Cleaned that up and made it just a standard JVM artifact. - Reworked how we generate UI factories a bit in code gen to make it simpler to maintain. Put a thorough diagram in for good measure. - Removed redundant wrapper `Box`es that were shims for this functionality missing. - It's not possible in abstract composable functions to add a default value. i.e. can't write `modifier: Modifier = Modifier` in the `Ui.Content(...)` signature. This does _add_ the compose UI dependency to circuit core, but after a lot of thought I think this is ok. Our android artifact already imposed it for all intents and purposes, this just drops that facade and embraces the fact that this is a UI architecture and not just a business logic layer using compose runtime only. Supersedes #406. Helps #50 too. Eases state sharing via modifiers. It would be easy to build modifiers that build upon each other with this and we no longer have a missing UI link.
1 parent b0aeda1 commit d5a2190

31 files changed

Lines changed: 304 additions & 212 deletions

File tree

circuit-codegen-annotations/dependencies/androidReleaseRuntimeClasspath.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ com.squareup.anvil:annotations
4444
javax.inject:javax.inject
4545
org.jetbrains.compose.runtime:runtime-saveable
4646
org.jetbrains.compose.runtime:runtime
47+
org.jetbrains.compose.ui:ui
4748
org.jetbrains.kotlin:kotlin-bom
4849
org.jetbrains.kotlin:kotlin-stdlib-jdk7
4950
org.jetbrains.kotlin:kotlin-stdlib-jdk8

circuit-codegen-annotations/dependencies/jvmRuntimeClasspath.txt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,30 @@
11
com.google.dagger:dagger
22
com.squareup.anvil:annotations
33
javax.inject:javax.inject
4+
org.jetbrains.compose.animation:animation-core-desktop
5+
org.jetbrains.compose.animation:animation-core
6+
org.jetbrains.compose.animation:animation-desktop
7+
org.jetbrains.compose.animation:animation
8+
org.jetbrains.compose.foundation:foundation-desktop
9+
org.jetbrains.compose.foundation:foundation-layout-desktop
10+
org.jetbrains.compose.foundation:foundation-layout
11+
org.jetbrains.compose.foundation:foundation
412
org.jetbrains.compose.runtime:runtime-desktop
513
org.jetbrains.compose.runtime:runtime-saveable-desktop
614
org.jetbrains.compose.runtime:runtime-saveable
715
org.jetbrains.compose.runtime:runtime
16+
org.jetbrains.compose.ui:ui-desktop
17+
org.jetbrains.compose.ui:ui-geometry-desktop
18+
org.jetbrains.compose.ui:ui-geometry
19+
org.jetbrains.compose.ui:ui-graphics-desktop
20+
org.jetbrains.compose.ui:ui-graphics
21+
org.jetbrains.compose.ui:ui-text-desktop
22+
org.jetbrains.compose.ui:ui-text
23+
org.jetbrains.compose.ui:ui-unit-desktop
24+
org.jetbrains.compose.ui:ui-unit
25+
org.jetbrains.compose.ui:ui-util-desktop
26+
org.jetbrains.compose.ui:ui-util
27+
org.jetbrains.compose.ui:ui
828
org.jetbrains.kotlin:kotlin-bom
929
org.jetbrains.kotlin:kotlin-stdlib-jdk7
1030
org.jetbrains.kotlin:kotlin-stdlib-jdk8
@@ -14,4 +34,6 @@ org.jetbrains.kotlinx:atomicfu
1434
org.jetbrains.kotlinx:kotlinx-coroutines-bom
1535
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm
1636
org.jetbrains.kotlinx:kotlinx-coroutines-core
37+
org.jetbrains.skiko:skiko-awt
38+
org.jetbrains.skiko:skiko
1739
org.jetbrains:annotations

circuit-codegen/build.gradle.kts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,5 @@ dependencies {
1313
implementation(libs.dagger)
1414
implementation(libs.kotlinpoet)
1515
implementation(libs.kotlinpoet.ksp)
16-
implementation(projects.circuit)
17-
implementation(projects.circuitCodegenAnnotations)
1816
implementation(libs.anvil.annotations)
1917
}

circuit-codegen/dependencies/runtimeClasspath.txt

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,10 @@ com.squareup.anvil:annotations
55
com.squareup:kotlinpoet-ksp
66
com.squareup:kotlinpoet
77
javax.inject:javax.inject
8-
org.jetbrains.compose.runtime:runtime-desktop
9-
org.jetbrains.compose.runtime:runtime-saveable-desktop
10-
org.jetbrains.compose.runtime:runtime-saveable
11-
org.jetbrains.compose.runtime:runtime
128
org.jetbrains.kotlin:kotlin-bom
139
org.jetbrains.kotlin:kotlin-reflect
1410
org.jetbrains.kotlin:kotlin-stdlib-common
1511
org.jetbrains.kotlin:kotlin-stdlib-jdk7
1612
org.jetbrains.kotlin:kotlin-stdlib-jdk8
1713
org.jetbrains.kotlin:kotlin-stdlib
18-
org.jetbrains.kotlinx:atomicfu-jvm
19-
org.jetbrains.kotlinx:atomicfu
20-
org.jetbrains.kotlinx:kotlinx-coroutines-bom
21-
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm
22-
org.jetbrains.kotlinx:kotlinx-coroutines-core
2314
org.jetbrains:annotations

circuit-codegen/gradle.properties

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
POM_ARTIFACT_ID=circuit-codegen
22
POM_NAME=Circuit (Codegen)
33
POM_DESCRIPTION=Circuit (Codegen)
4+
circuit.noCompose=true

circuit-codegen/src/main/kotlin/com/slack/circuit/codegen/CircuitSymbolProcessorProvider.kt

Lines changed: 106 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,9 @@ import com.google.devtools.ksp.symbol.KSDeclaration
2323
import com.google.devtools.ksp.symbol.KSFunctionDeclaration
2424
import com.google.devtools.ksp.symbol.KSType
2525
import com.google.devtools.ksp.symbol.Visibility
26-
import com.slack.circuit.CircuitContext
27-
import com.slack.circuit.CircuitUiState
28-
import com.slack.circuit.Navigator
29-
import com.slack.circuit.Presenter
30-
import com.slack.circuit.Screen
31-
import com.slack.circuit.ScreenUi
32-
import com.slack.circuit.Ui
33-
import com.slack.circuit.codegen.annotations.CircuitInject
3426
import com.squareup.anvil.annotations.ContributesMultibinding
3527
import com.squareup.kotlinpoet.AnnotationSpec
28+
import com.squareup.kotlinpoet.ClassName
3629
import com.squareup.kotlinpoet.CodeBlock
3730
import com.squareup.kotlinpoet.FileSpec
3831
import com.squareup.kotlinpoet.FunSpec
@@ -45,7 +38,6 @@ import com.squareup.kotlinpoet.STAR
4538
import com.squareup.kotlinpoet.TypeName
4639
import com.squareup.kotlinpoet.TypeSpec
4740
import com.squareup.kotlinpoet.asClassName
48-
import com.squareup.kotlinpoet.asTypeName
4941
import com.squareup.kotlinpoet.joinToCode
5042
import com.squareup.kotlinpoet.ksp.addOriginatingKSFile
5143
import com.squareup.kotlinpoet.ksp.toClassName
@@ -55,9 +47,18 @@ import dagger.assisted.AssistedFactory
5547
import javax.inject.Inject
5648
import javax.inject.Provider
5749

58-
private val CIRCUIT_INJECT_ANNOTATION = CircuitInject::class.java.canonicalName
59-
private val CIRCUIT_PRESENTER = Presenter::class.java.canonicalName
60-
private val CIRCUIT_UI = Ui::class.java.canonicalName
50+
private const val CIRCUIT_BASE_PACKAGE = "com.slack.circuit"
51+
private val MODIFIER = ClassName("androidx.compose.ui", "Modifier")
52+
private val CIRCUIT_INJECT_ANNOTATION =
53+
ClassName("$CIRCUIT_BASE_PACKAGE.codegen.annotations", "CircuitInject")
54+
private val CIRCUIT_PRESENTER = ClassName(CIRCUIT_BASE_PACKAGE, "Presenter")
55+
private val CIRCUIT_PRESENTER_FACTORY = CIRCUIT_PRESENTER.nestedClass("Factory")
56+
private val CIRCUIT_UI = ClassName(CIRCUIT_BASE_PACKAGE, "Ui")
57+
private val CIRCUIT_UI_FACTORY = CIRCUIT_UI.nestedClass("Factory")
58+
private val CIRCUIT_UI_STATE = ClassName(CIRCUIT_BASE_PACKAGE, "CircuitUiState")
59+
private val SCREEN = ClassName(CIRCUIT_BASE_PACKAGE, "Screen")
60+
private val NAVIGATOR = ClassName(CIRCUIT_BASE_PACKAGE, "Navigator")
61+
private val CIRCUIT_CONTEXT = ClassName(CIRCUIT_BASE_PACKAGE, "CircuitContext")
6162
private const val FACTORY = "Factory"
6263

6364
@AutoService(SymbolProcessorProvider::class)
@@ -68,9 +69,11 @@ public class CircuitSymbolProcessorProvider : SymbolProcessorProvider {
6869
}
6970

7071
private class CircuitSymbols private constructor(resolver: Resolver) {
71-
val circuitUiState = resolver.loadKSType<CircuitUiState>()
72-
val screen = resolver.loadKSType<Screen>()
73-
val navigator = resolver.loadKSType<Navigator>()
72+
val modifier = resolver.loadKSType(MODIFIER.canonicalName)
73+
val circuitUiState = resolver.loadKSType(CIRCUIT_UI_STATE.canonicalName)
74+
val screen = resolver.loadKSType(SCREEN.canonicalName)
75+
val navigator = resolver.loadKSType(NAVIGATOR.canonicalName)
76+
7477
companion object {
7578
fun create(resolver: Resolver): CircuitSymbols? {
7679
@Suppress("SwallowedException")
@@ -83,24 +86,23 @@ private class CircuitSymbols private constructor(resolver: Resolver) {
8386
}
8487
}
8588

86-
private inline fun <reified T> Resolver.loadKSType(): KSType {
87-
return loadOptionalKSType<T>()
88-
?: error("Could not find ${T::class.java.canonicalName} in classpath")
89-
}
89+
private fun Resolver.loadKSType(name: String): KSType =
90+
loadOptionalKSType(name) ?: error("Could not find $name in classpath")
9091

91-
private inline fun <reified T> Resolver.loadOptionalKSType(): KSType? {
92-
return getClassDeclarationByName(getKSNameFromString(T::class.java.canonicalName))
93-
?.asType(emptyList())
92+
private fun Resolver.loadOptionalKSType(name: String?): KSType? {
93+
if (name == null) return null
94+
return getClassDeclarationByName(getKSNameFromString(name))?.asType(emptyList())
9495
}
9596

9697
private class CircuitSymbolProcessor(
9798
private val logger: KSPLogger,
98-
private val codeGenerator: CodeGenerator
99+
private val codeGenerator: CodeGenerator,
99100
) : SymbolProcessor {
100101

101102
override fun process(resolver: Resolver): List<KSAnnotated> {
102103
val symbols = CircuitSymbols.create(resolver) ?: return emptyList()
103-
resolver.getSymbolsWithAnnotation(CIRCUIT_INJECT_ANNOTATION).forEach { annotatedElement ->
104+
resolver.getSymbolsWithAnnotation(CIRCUIT_INJECT_ANNOTATION.canonicalName).forEach {
105+
annotatedElement ->
104106
when (annotatedElement) {
105107
is KSClassDeclaration -> {
106108
generateFactory(annotatedElement, InstantiationType.CLASS, symbols)
@@ -121,12 +123,12 @@ private class CircuitSymbolProcessor(
121123
private fun generateFactory(
122124
annotatedElement: KSAnnotated,
123125
instantiationType: InstantiationType,
124-
symbols: CircuitSymbols
126+
symbols: CircuitSymbols,
125127
) {
126128
val circuitInjectAnnotation =
127129
annotatedElement.annotations.first {
128130
it.annotationType.resolve().declaration.qualifiedName?.asString() ==
129-
CIRCUIT_INJECT_ANNOTATION
131+
CIRCUIT_INJECT_ANNOTATION.canonicalName
130132
}
131133
val screenKSType = circuitInjectAnnotation.arguments[0].value as KSType
132134
val screenIsObject =
@@ -185,12 +187,12 @@ private class CircuitSymbolProcessor(
185187
val packageName: String,
186188
val factoryType: FactoryType,
187189
val constructorParams: List<ParameterSpec>,
188-
val codeBlock: CodeBlock
190+
val codeBlock: CodeBlock,
189191
)
190192

191193
/** Computes the data needed to generate a factory. */
192194
// Detekt and ktfmt don't agree on whether or not the rectangle rule makes for readable code.
193-
@Suppress("ComplexMethod", "LongMethod")
195+
@Suppress("ComplexMethod", "LongMethod", "ReturnCount")
194196
@OptIn(KspExperimental::class)
195197
private fun computeFactoryData(
196198
annotatedElement: KSAnnotated,
@@ -232,34 +234,72 @@ private class CircuitSymbolProcessor(
232234
assistedParams
233235
)
234236
FactoryType.UI -> {
237+
// State param is optional
235238
val stateParam =
236239
fd.parameters.singleOrNull { parameter ->
237240
symbols.circuitUiState.isAssignableFrom(parameter.type.resolve())
238241
}
239-
if (stateParam == null) {
240-
CodeBlock.of(
241-
"%M<%T>·{·%M(%L)·}",
242-
MemberName("com.slack.circuit", "ui"),
243-
CircuitUiState::class.java,
244-
MemberName(packageName, name),
245-
assistedParams
246-
)
247-
} else {
248-
val block =
249-
if (assistedParams.isEmpty()) {
250-
CodeBlock.of("")
251-
} else {
252-
CodeBlock.of(",·%L", assistedParams)
242+
243+
// Modifier param is required
244+
val modifierParam =
245+
fd.parameters.singleOrNull { parameter ->
246+
symbols.modifier.isAssignableFrom(parameter.type.resolve())
247+
}
248+
?: run {
249+
logger.error("UI composable functions must have a Modifier parameter!", fd)
250+
return null
253251
}
254-
CodeBlock.of(
255-
"%M<%T>·{·state·->·%M(%L·=·state%L)·}",
256-
MemberName("com.slack.circuit", "ui"),
257-
stateParam.type.resolve().toTypeName(),
258-
MemberName(packageName, name),
259-
stateParam.name!!.getShortName(),
260-
block
261-
)
262-
}
252+
253+
/*
254+
Diagram of what goes into generating a function!
255+
- State parameter is _optional_ and can be omitted if it's static state.
256+
- When omitted, the argument becomes _ and the param is omitted entirely.
257+
- <StateType> is either the State or CircuitUiState if no state param is used.
258+
- Modifier parameter is required.
259+
- Assisted parameters can be 0 or more extra supported assisted parameters.
260+
261+
Optional state param
262+
Optional state arg │
263+
│ │ Required modifier param
264+
│ Req modifier arg │ │
265+
┌─── ui function │ │ │ │ Any assisted params
266+
│ │ │ Composable │ │ │
267+
│ State type │ │ │ │ │ │
268+
│ │ │ │ │ │ │ │
269+
│ │ │ │ │ │ │ │
270+
└──────┴───── ──┴── ───┴──── ──┴───── ───────┴───── ────────┴────────── ────────┴────────
271+
ui<StateType> { state, modifier -> Function(state = state, modifier = modifier, <assisted params>) }
272+
────────────────────────────────────────────────────────────────────────────────────────────────────
273+
274+
Diagram generated with asciiflow. You can make new ones or edit with this link.
275+
https://asciiflow.com/#/share/eJzVVM1KxDAQfpVhTgr1IizLlt2CCF4F9ZhLdGclkKbd%2FMCW0rfwcXwan8SsWW27sd0qXixzmDTffN83bSY1Kp4TpspJmaDkFWlMsWa4Y5guZvOEYeWzy%2FnCZ5Z21i8Ywg%2Be29KKQnEJxnJLUHLNc8bUKIjr55jo7eXVR1R62Dplo4Xc0dYJTWvIi7XYCNIDnnpVvqjF9%2BzF2sFlsBvCCdg49bTvsVcx4vtb2u7ySlXAjRHG%2BlY%2BOjBBdYSqza6LvCwMf5Q0VS%2FqDjq%2FzVYlDfV1zPMrpWHSf6w1JeDz4HfejGCH9ybrTQT%2BUan%2FEk4s7%2Fdj%2F%2BAPUQZ1uAOSdtwuMrg5TM9ZuB9WEWb1lSawPBqL7Bwahg027%2Byjz8s%3D)
276+
*/
277+
278+
@Suppress("IfThenToElvis") // The elvis is less readable here
279+
val stateType =
280+
if (stateParam == null) CIRCUIT_UI_STATE else stateParam.type.resolve().toTypeName()
281+
val stateArg = if (stateParam == null) "_" else "state"
282+
val stateParamBlock =
283+
if (stateParam == null) CodeBlock.of("")
284+
else CodeBlock.of("%L·=·state,·", stateParam.name!!.getShortName())
285+
val modifierParamBlock =
286+
CodeBlock.of("%L·=·modifier", modifierParam.name!!.getShortName())
287+
val assistedParamsBlock =
288+
if (assistedParams.isEmpty()) {
289+
CodeBlock.of("")
290+
} else {
291+
CodeBlock.of(",·%L", assistedParams)
292+
}
293+
CodeBlock.of(
294+
"%M<%T>·{·%L,·modifier·->·%M(%L%L%L)·}",
295+
MemberName("com.slack.circuit", "ui"),
296+
stateType,
297+
stateArg,
298+
MemberName(packageName, name),
299+
stateParamBlock,
300+
modifierParamBlock,
301+
assistedParamsBlock
302+
)
263303
}
264304
}
265305
}
@@ -291,8 +331,8 @@ private class CircuitSymbolProcessor(
291331
.getAllSuperTypes()
292332
.mapNotNull {
293333
when (it.declaration.qualifiedName?.asString()) {
294-
CIRCUIT_UI -> FactoryType.UI
295-
CIRCUIT_PRESENTER -> FactoryType.PRESENTER
334+
CIRCUIT_UI.canonicalName -> FactoryType.UI
335+
CIRCUIT_PRESENTER.canonicalName -> FactoryType.PRESENTER
296336
else -> null
297337
}
298338
}
@@ -361,7 +401,7 @@ private fun KSFunctionDeclaration.assistedParameters(
361401
symbols: CircuitSymbols,
362402
logger: KSPLogger,
363403
screenType: KSType,
364-
allowNavigator: Boolean
404+
allowNavigator: Boolean,
365405
): CodeBlock {
366406
return buildSet {
367407
for (param in parameters) {
@@ -410,22 +450,17 @@ private fun KSType.isInstanceOf(type: KSType): Boolean {
410450
private fun TypeSpec.Builder.buildUiFactory(
411451
originatingSymbol: KSAnnotated,
412452
screenBranch: CodeBlock,
413-
instantiationCodeBlock: CodeBlock
453+
instantiationCodeBlock: CodeBlock,
414454
): TypeSpec {
415-
return addSuperinterface(Ui.Factory::class)
455+
return addSuperinterface(CIRCUIT_UI_FACTORY)
416456
.addFunction(
417457
FunSpec.builder("create")
418458
.addModifiers(KModifier.OVERRIDE)
419-
.addParameter("screen", Screen::class)
420-
.addParameter("context", CircuitContext::class)
421-
.returns(ScreenUi::class.asClassName().copy(nullable = true))
459+
.addParameter("screen", SCREEN)
460+
.addParameter("context", CIRCUIT_CONTEXT)
461+
.returns(CIRCUIT_UI.parameterizedBy(STAR).copy(nullable = true))
422462
.beginControlFlow("return·when·(screen)")
423-
.addStatement(
424-
"%L·->·%T(%L)",
425-
screenBranch,
426-
ScreenUi::class.asTypeName(),
427-
instantiationCodeBlock
428-
)
463+
.addStatement("%L·->·%L", screenBranch, instantiationCodeBlock)
429464
.addStatement("else·->·null")
430465
.endControlFlow()
431466
.build()
@@ -437,7 +472,7 @@ private fun TypeSpec.Builder.buildUiFactory(
437472
private fun TypeSpec.Builder.buildPresenterFactory(
438473
originatingSymbol: KSAnnotated,
439474
screenBranch: CodeBlock,
440-
instantiationCodeBlock: CodeBlock
475+
instantiationCodeBlock: CodeBlock,
441476
): TypeSpec {
442477
// The TypeSpec below will generate something similar to the following.
443478
// public class AboutPresenterFactory : Presenter.Factory {
@@ -452,14 +487,14 @@ private fun TypeSpec.Builder.buildPresenterFactory(
452487
// }
453488
// }
454489

455-
return addSuperinterface(Presenter.Factory::class)
490+
return addSuperinterface(CIRCUIT_PRESENTER_FACTORY)
456491
.addFunction(
457492
FunSpec.builder("create")
458493
.addModifiers(KModifier.OVERRIDE)
459-
.addParameter("screen", Screen::class)
460-
.addParameter("navigator", Navigator::class)
461-
.addParameter("context", CircuitContext::class)
462-
.returns(Presenter::class.asClassName().parameterizedBy(STAR).copy(nullable = true))
494+
.addParameter("screen", SCREEN)
495+
.addParameter("navigator", NAVIGATOR)
496+
.addParameter("context", CIRCUIT_CONTEXT)
497+
.returns(CIRCUIT_PRESENTER.parameterizedBy(STAR).copy(nullable = true))
463498
.beginControlFlow("return when (screen)")
464499
.addStatement("%L·->·%L", screenBranch, instantiationCodeBlock)
465500
.addStatement("else·->·null")

circuit-test/dependencies/androidReleaseRuntimeClasspath.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ app.cash.turbine:turbine
4545
com.google.guava:listenablefuture
4646
org.jetbrains.compose.runtime:runtime-saveable
4747
org.jetbrains.compose.runtime:runtime
48+
org.jetbrains.compose.ui:ui
4849
org.jetbrains.kotlin:kotlin-bom
4950
org.jetbrains.kotlin:kotlin-stdlib-jdk7
5051
org.jetbrains.kotlin:kotlin-stdlib-jdk8

0 commit comments

Comments
 (0)