From 358821050f193624d44895284c0f0bf0774b2bc0 Mon Sep 17 00:00:00 2001 From: Jake Wharton Date: Thu, 21 Aug 2025 12:48:58 -0400 Subject: [PATCH 1/2] Initial Compose UI-specific code generation --- .../api/redwood-gradle-plugin.api | 5 + redwood-gradle-plugin/build.gradle | 6 + .../redwood/gradle/RedwoodGeneratorPlugin.kt | 5 + .../api/redwood-tooling-codegen.api | 1 + .../tooling/codegen/GenerateCommand.kt | 1 + .../cash/redwood/tooling/codegen/codegen.kt | 9 + .../app/cash/redwood/tooling/codegen/kpx.kt | 7 + .../redwood/tooling/codegen/sharedHelpers.kt | 1 + .../app/cash/redwood/tooling/codegen/types.kt | 16 ++ .../codegen/widgetComposeUiGeneration.kt | 264 ++++++++++++++++++ 10 files changed, 315 insertions(+) create mode 100644 redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/widgetComposeUiGeneration.kt diff --git a/redwood-gradle-plugin/api/redwood-gradle-plugin.api b/redwood-gradle-plugin/api/redwood-gradle-plugin.api index 41b9625139..667cb67f46 100644 --- a/redwood-gradle-plugin/api/redwood-gradle-plugin.api +++ b/redwood-gradle-plugin/api/redwood-gradle-plugin.api @@ -27,6 +27,7 @@ public final class app/cash/redwood/gradle/RedwoodGeneratorPlugin$Strategy : jav public static final field ProtocolHost Lapp/cash/redwood/gradle/RedwoodGeneratorPlugin$Strategy; public static final field Testing Lapp/cash/redwood/gradle/RedwoodGeneratorPlugin$Strategy; public static final field Widget Lapp/cash/redwood/gradle/RedwoodGeneratorPlugin$Strategy; + public static final field WidgetComposeUi Lapp/cash/redwood/gradle/RedwoodGeneratorPlugin$Strategy; public static fun getEntries ()Lkotlin/enums/EnumEntries; public static fun valueOf (Ljava/lang/String;)Lapp/cash/redwood/gradle/RedwoodGeneratorPlugin$Strategy; public static fun values ()[Lapp/cash/redwood/gradle/RedwoodGeneratorPlugin$Strategy; @@ -66,6 +67,10 @@ public final class app/cash/redwood/gradle/RedwoodTestingGeneratorPlugin : app/c public fun ()V } +public final class app/cash/redwood/gradle/RedwoodWidgetComposeUiGeneratorPlugin : app/cash/redwood/gradle/RedwoodGeneratorPlugin { + public fun ()V +} + public final class app/cash/redwood/gradle/RedwoodWidgetGeneratorPlugin : app/cash/redwood/gradle/RedwoodGeneratorPlugin { public fun ()V } diff --git a/redwood-gradle-plugin/build.gradle b/redwood-gradle-plugin/build.gradle index 09a4d55502..f12dd10578 100644 --- a/redwood-gradle-plugin/build.gradle +++ b/redwood-gradle-plugin/build.gradle @@ -100,6 +100,12 @@ gradlePlugin { description = "Redwood schema widget code generation Gradle plugin" implementationClass = "app.cash.redwood.gradle.RedwoodWidgetGeneratorPlugin" } + redwoodWidgetComposeUiGenerator { + id = "app.cash.redwood.generator.widget.composeui" + displayName = "Redwood generator (Compose UI)" + description = "Redwood schema widget Compose UI code generation Gradle plugin" + implementationClass = "app.cash.redwood.gradle.RedwoodWidgetComposeUiGeneratorPlugin" + } redwoodWidgetProtocolGenerator { id = "app.cash.redwood.generator.widget.protocol" displayName = "Redwood generator (widget protocol)" diff --git a/redwood-gradle-plugin/src/main/kotlin/app/cash/redwood/gradle/RedwoodGeneratorPlugin.kt b/redwood-gradle-plugin/src/main/kotlin/app/cash/redwood/gradle/RedwoodGeneratorPlugin.kt index b637de3ebe..188b40f2de 100644 --- a/redwood-gradle-plugin/src/main/kotlin/app/cash/redwood/gradle/RedwoodGeneratorPlugin.kt +++ b/redwood-gradle-plugin/src/main/kotlin/app/cash/redwood/gradle/RedwoodGeneratorPlugin.kt @@ -21,6 +21,7 @@ import app.cash.redwood.gradle.RedwoodGeneratorPlugin.Strategy.ProtocolGuest import app.cash.redwood.gradle.RedwoodGeneratorPlugin.Strategy.ProtocolHost import app.cash.redwood.gradle.RedwoodGeneratorPlugin.Strategy.Testing import app.cash.redwood.gradle.RedwoodGeneratorPlugin.Strategy.Widget +import app.cash.redwood.gradle.RedwoodGeneratorPlugin.Strategy.WidgetComposeUi import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.logging.Logging @@ -55,6 +56,9 @@ public class RedwoodTestingGeneratorPlugin : RedwoodGeneratorPlugin(Testing) @Suppress("unused") // Invoked reflectively by Gradle. public class RedwoodWidgetGeneratorPlugin : RedwoodGeneratorPlugin(Widget) +@Suppress("unused") // Invoked reflectively by Gradle. +public class RedwoodWidgetComposeUiGeneratorPlugin : RedwoodGeneratorPlugin(WidgetComposeUi) + @Suppress("unused") // Invoked reflectively by Gradle. public class RedwoodWidgetProtocolGeneratorPlugin : RedwoodGeneratorPlugin(ProtocolHost) { override fun apply(project: Project) { @@ -78,6 +82,7 @@ public abstract class RedwoodGeneratorPlugin( ProtocolHost("--protocol-host", "redwood-protocol-host", androidxCollectionCoordinates), Testing("--testing", "redwood-testing"), Widget("--widget", "redwood-widget"), + WidgetComposeUi("--widget-compose-ui", "redwood-widget-composeui"), } override fun apply(project: Project) { diff --git a/redwood-tooling-codegen/api/redwood-tooling-codegen.api b/redwood-tooling-codegen/api/redwood-tooling-codegen.api index e1297518c1..bfcad59a95 100644 --- a/redwood-tooling-codegen/api/redwood-tooling-codegen.api +++ b/redwood-tooling-codegen/api/redwood-tooling-codegen.api @@ -7,6 +7,7 @@ public final class app/cash/redwood/tooling/codegen/CodegenType : java/lang/Enum public static final field Modifiers Lapp/cash/redwood/tooling/codegen/CodegenType; public static final field Testing Lapp/cash/redwood/tooling/codegen/CodegenType; public static final field Widget Lapp/cash/redwood/tooling/codegen/CodegenType; + public static final field WidgetComposeUi Lapp/cash/redwood/tooling/codegen/CodegenType; public static fun getEntries ()Lkotlin/enums/EnumEntries; public static fun valueOf (Ljava/lang/String;)Lapp/cash/redwood/tooling/codegen/CodegenType; public static fun values ()[Lapp/cash/redwood/tooling/codegen/CodegenType; diff --git a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/GenerateCommand.kt b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/GenerateCommand.kt index 52cb913c3d..8b5c5cdb0a 100644 --- a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/GenerateCommand.kt +++ b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/GenerateCommand.kt @@ -40,6 +40,7 @@ internal class GenerateCommand : CliktCommand(name = "generate") { "--protocol-host" to ProtocolCodegenType.Host, "--testing" to CodegenType.Testing, "--widget" to CodegenType.Widget, + "--widget-compose-ui" to CodegenType.WidgetComposeUi, ) .help("Type of code to generate") .required() diff --git a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/codegen.kt b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/codegen.kt index 79beb6ed42..d04dd25dc1 100644 --- a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/codegen.kt +++ b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/codegen.kt @@ -19,6 +19,7 @@ import app.cash.redwood.tooling.codegen.CodegenType.Compose import app.cash.redwood.tooling.codegen.CodegenType.Modifiers import app.cash.redwood.tooling.codegen.CodegenType.Testing import app.cash.redwood.tooling.codegen.CodegenType.Widget +import app.cash.redwood.tooling.codegen.CodegenType.WidgetComposeUi import app.cash.redwood.tooling.schema.SchemaSet import com.squareup.kotlinpoet.FileSpec import java.nio.file.Path @@ -28,6 +29,7 @@ public enum class CodegenType { Modifiers, Testing, Widget, + WidgetComposeUi, } public fun SchemaSet.generate(type: CodegenType, destination: Path) { @@ -72,6 +74,13 @@ internal fun SchemaSet.generateFileSpecs(type: CodegenType): List { add(generateWidget(schema, widget)) } } + + WidgetComposeUi -> { + add(generateComposeUiWidgetFactory(schema)) + for (widget in schema.widgets) { + add(generateComposeUiBinding(schema, widget)) + } + } } } } diff --git a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/kpx.kt b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/kpx.kt index 9d01772ae8..bb6dffa375 100644 --- a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/kpx.kt +++ b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/kpx.kt @@ -18,6 +18,7 @@ package app.cash.redwood.tooling.codegen import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.MemberName +import com.squareup.kotlinpoet.TypeSpec internal fun buildFileSpec(className: ClassName, builder: FileSpec.Builder.() -> Unit): FileSpec { return FileSpec.builder(className) @@ -36,3 +37,9 @@ internal fun buildFileSpec(packageName: String, fileName: String, builder: FileS .apply(builder) .build() } + +internal fun buildClassSpec(className: ClassName, builder: TypeSpec.Builder.() -> Unit): TypeSpec { + return TypeSpec.classBuilder(className) + .apply(builder) + .build() +} diff --git a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/sharedHelpers.kt b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/sharedHelpers.kt index ad93ce3d90..b203d13bac 100644 --- a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/sharedHelpers.kt +++ b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/sharedHelpers.kt @@ -67,6 +67,7 @@ internal val Event.lambdaType: TypeName internal val Schema.unscopedModifiers get() = modifiers.filter { it.scopes.isEmpty() } +internal fun Schema.composeUiPackage() = type.names[0] + ".composeui" internal fun Schema.composePackage() = type.names[0] + ".compose" internal fun Schema.modifierPackage() = type.names[0] + ".modifier" internal fun Schema.testingPackage() = type.names[0] + ".testing" diff --git a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/types.kt b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/types.kt index 01a9b2c9be..f15c084775 100644 --- a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/types.kt +++ b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/types.kt @@ -90,16 +90,32 @@ internal object RedwoodWidget { val MutableListChildren = ClassName("app.cash.redwood.widget", "MutableListChildren") } +internal object RedwoodWidgetComposeUi { + val ComposeWidgetChildren = ClassName("app.cash.redwood.widget.compose", "ComposeWidgetChildren") +} + internal object RedwoodCompose { val RedwoodComposeNode = MemberName("app.cash.redwood.compose", "RedwoodComposeNode") val WidgetNode = ClassName("app.cash.redwood.compose", "WidgetNode") } internal object ComposeRuntime { + val mutableStateOf = MemberName("androidx.compose.runtime", "mutableStateOf") + val mutableIntStateOf = MemberName("androidx.compose.runtime", "mutableIntStateOf") + val mutableLongStateOf = MemberName("androidx.compose.runtime", "mutableLongStateOf") + val mutableDoubleStateOf = MemberName("androidx.compose.runtime", "mutableDoubleStateOf") + val mutableFloatStateOf = MemberName("androidx.compose.runtime", "mutableFloatStateOf") + val getValue = MemberName("androidx.compose.runtime", "getValue") + val setValue = MemberName("androidx.compose.runtime", "setValue") val Composable = ClassName("androidx.compose.runtime", "Composable") + val MutableState = ClassName("androidx.compose.runtime", "MutableState") val Stable = ClassName("androidx.compose.runtime", "Stable") } +internal object ComposeUi { + val Modifier = ClassName("androidx.compose.ui", "Modifier") +} + internal object AndroidxCollection { val IntObjectMap = ClassName("androidx.collection", "IntObjectMap") val MutableIntObjectMap = ClassName("androidx.collection", "MutableIntObjectMap") diff --git a/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/widgetComposeUiGeneration.kt b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/widgetComposeUiGeneration.kt new file mode 100644 index 0000000000..4ea3ad44be --- /dev/null +++ b/redwood-tooling-codegen/src/main/kotlin/app/cash/redwood/tooling/codegen/widgetComposeUiGeneration.kt @@ -0,0 +1,264 @@ +/* + * Copyright (C) 2025 Square, Inc. + * + * 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 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package app.cash.redwood.tooling.codegen + +import app.cash.redwood.tooling.schema.ProtocolWidget.ProtocolTrait +import app.cash.redwood.tooling.schema.Schema +import app.cash.redwood.tooling.schema.Widget +import app.cash.redwood.tooling.schema.Widget.Children +import app.cash.redwood.tooling.schema.Widget.Event +import app.cash.redwood.tooling.schema.Widget.Property +import com.squareup.kotlinpoet.AnnotationSpec +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.CodeBlock +import com.squareup.kotlinpoet.DOUBLE +import com.squareup.kotlinpoet.FLOAT +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.INT +import com.squareup.kotlinpoet.KModifier.ABSTRACT +import com.squareup.kotlinpoet.KModifier.INTERNAL +import com.squareup.kotlinpoet.KModifier.OVERRIDE +import com.squareup.kotlinpoet.KModifier.PRIVATE +import com.squareup.kotlinpoet.LONG +import com.squareup.kotlinpoet.LambdaTypeName +import com.squareup.kotlinpoet.MemberName +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.TypeName +import com.squareup.kotlinpoet.UNIT +import com.squareup.kotlinpoet.joinToCode + +private val composeUiWidgetType = LambdaTypeName.get( + receiver = null, + ComposeUi.Modifier, + returnType = UNIT, +).copy( + annotations = listOf( + AnnotationSpec.builder(ComposeRuntime.Composable).build(), + ), +) + +private fun Schema.composeUiWidgetFactoryType(): ClassName { + return ClassName(composeUiPackage(), "AbstractComposeUi" + getWidgetFactoryType().simpleName) +} + +private fun Schema.composeUiWidgetType(widget: Widget): ClassName { + return ClassName(composeUiPackage(), "ComposeUi" + widget.type.flatName) +} + +private fun Widget.factoryFunction() = type.flatName + "Binding" + +internal fun generateComposeUiWidgetFactory(schema: Schema): FileSpec { + val widgetFactoryType = schema.getWidgetFactoryType() + val thisType = schema.composeUiWidgetFactoryType() + return buildFileSpec(thisType) { + addAnnotation(suppressDeprecations) + addType( + buildClassSpec(thisType) { + addModifiers(ABSTRACT) + addSuperinterface(widgetFactoryType.parameterizedBy(composeUiWidgetType)) + + for (widget in schema.widgets) { + addFunction( + FunSpec.builder(widget.factoryFunction()) + .addModifiers(ABSTRACT) + .addAnnotation(ComposeRuntime.Composable) + .apply { + for (trait in widget.traits) { + val type = when (trait) { + is Property -> trait.type.asTypeName() + is Event -> trait.lambdaType + is Children -> RedwoodWidgetComposeUi.ComposeWidgetChildren + is ProtocolTrait -> throw AssertionError() + } + addParameter(trait.name, type) + } + } + .addParameter("modifier", ComposeUi.Modifier) + .build(), + ) + } + + // Put the interface implementations at the bottom. + for (widget in schema.widgets) { + addFunction( + FunSpec.builder(widget.type.flatName) + .returns(schema.widgetType(widget).parameterizedBy(composeUiWidgetType)) + .addModifiers(OVERRIDE) + .addStatement("return %T(this)", schema.composeUiWidgetType(widget)) + .build(), + ) + } + + for (modifier in schema.unscopedModifiers) { + addFunction( + FunSpec.builder(modifier.type.flatName) + .addModifiers(OVERRIDE) + .addParameter("value", composeUiWidgetType) + .addParameter("modifier", schema.modifierType(modifier)) + .build(), + ) + } + }, + ) + } +} + +internal fun generateComposeUiBinding(schema: Schema, widget: Widget): FileSpec { + val widgetType = schema.widgetType(widget) + val widgetFactoryType = schema.composeUiWidgetFactoryType() + val thisType = schema.composeUiWidgetType(widget) + return buildFileSpec(thisType) { + addAnnotation(suppressDeprecations) + addType( + buildClassSpec(thisType) { + addSuperinterface(widgetType.parameterizedBy(composeUiWidgetType)) + addModifiers(INTERNAL) + + primaryConstructor( + FunSpec.constructorBuilder() + .addParameter("factory", widgetFactoryType) + .build(), + ) + addProperty( + PropertySpec.builder("factory", widgetFactoryType) + .addModifiers(PRIVATE) + .initializer("factory") + .build(), + ) + + addProperty( + PropertySpec.builder("modifier", Redwood.Modifier) + .addModifiers(OVERRIDE) + .mutable(true) + .initializer("%T", Redwood.Modifier) + .build(), + ) + + val delegateArguments = mutableListOf() + + for (trait in widget.traits) { + if (trait is Children) { + val backingPropertyName = "_" + trait.name + delegateArguments += CodeBlock.of("%N", backingPropertyName) + + addProperty( + PropertySpec.builder(backingPropertyName, RedwoodWidgetComposeUi.ComposeWidgetChildren) + .addModifiers(PRIVATE) + .initializer("%T()", RedwoodWidgetComposeUi.ComposeWidgetChildren) + .build(), + ) + addProperty( + PropertySpec.builder(trait.name, RedwoodWidget.WidgetChildren.parameterizedBy(composeUiWidgetType)) + .addModifiers(OVERRIDE) + .getter( + FunSpec.getterBuilder() + .addStatement("return %N", backingPropertyName) + .build(), + ) + .build(), + ) + } else { + val traitType = when (trait) { + is Property -> trait.type.asTypeName() + is Event -> trait.lambdaType + is Children -> throw AssertionError() + is ProtocolTrait -> throw AssertionError() + } + + val stateFactory: MemberName + val stateDefault: CodeBlock + val stateType: TypeName + when (traitType) { + INT -> { + stateFactory = ComposeRuntime.mutableIntStateOf + stateDefault = CodeBlock.of("0") + stateType = traitType + } + DOUBLE -> { + stateFactory = ComposeRuntime.mutableDoubleStateOf + stateDefault = CodeBlock.of("0.0") + stateType = traitType + } + FLOAT -> { + stateFactory = ComposeRuntime.mutableFloatStateOf + stateDefault = CodeBlock.of("0f") + stateType = traitType + } + LONG -> { + stateFactory = ComposeRuntime.mutableLongStateOf + stateDefault = CodeBlock.of("0L") + stateType = traitType + } + else -> { + stateFactory = ComposeRuntime.mutableStateOf + stateDefault = CodeBlock.of("null") + // Always widen to nullable since we need to use it as an uninitialized value. + stateType = traitType.copy(nullable = true) + } + } + + delegateArguments += if (stateType == traitType) { + CodeBlock.of("%N", trait.name) + } else { + CodeBlock.of("%N as %T", trait.name, traitType) + } + + // TODO Add addImport(MemberName) https://github.com/square/kotlinpoet/issues/2197 + addImport(ComposeRuntime.getValue.packageName, ComposeRuntime.getValue.simpleName) + addImport(ComposeRuntime.setValue.packageName, ComposeRuntime.setValue.simpleName) + + addProperty( + PropertySpec.builder(trait.name, stateType) + .mutable(true) + .addModifiers(PRIVATE) + .delegate("%M(%L)", stateFactory, stateDefault) + .build(), + ) + addFunction( + FunSpec.builder(trait.name) + .addParameter(trait.name, traitType) + .addModifiers(OVERRIDE) + .addStatement("this.%1N = %1N", trait.name) + .build(), + ) + } + } + + addProperty( + PropertySpec.builder("value", composeUiWidgetType) + .addModifiers(OVERRIDE) + .initializer( + CodeBlock.builder() + .add("{ modifier ->\n") + .indent() + .add("this.factory.%N(\n", widget.factoryFunction()) + .indent() + .add(delegateArguments.joinToCode(",\n")) + .add(",\nmodifier,\n") + .unindent() + .add(")\n") + .unindent() + .add("}\n") + .build(), + ) + .build(), + ) + }, + ) + } +} From c1bddef2705f0975f9eb67f38b2fe932482d36ca Mon Sep 17 00:00:00 2001 From: Jake Wharton Date: Thu, 21 Aug 2025 21:13:59 -0400 Subject: [PATCH 2/2] Move Compose UI LazyLayout to codegen --- redwood-lazylayout-composeui/build.gradle | 6 + .../lazylayout/composeui/ComposeUiLazyList.kt | 236 ------------------ ...RedwoodTreehouseLazyLayoutWidgetFactory.kt | 212 +++++++++++++++- 3 files changed, 210 insertions(+), 244 deletions(-) delete mode 100644 redwood-lazylayout-composeui/src/commonMain/kotlin/app/cash/redwood/lazylayout/composeui/ComposeUiLazyList.kt diff --git a/redwood-lazylayout-composeui/build.gradle b/redwood-lazylayout-composeui/build.gradle index 5da6c05266..1505764049 100644 --- a/redwood-lazylayout-composeui/build.gradle +++ b/redwood-lazylayout-composeui/build.gradle @@ -8,6 +8,7 @@ redwoodBuild { apply plugin: 'org.jetbrains.kotlin.plugin.compose' apply plugin: 'app.cash.burst' apply plugin: 'app.cash.paparazzi' +apply plugin: 'app.cash.redwood.generator.widget.composeui' kotlin { sourceSets { @@ -30,6 +31,11 @@ kotlin { } } +redwoodSchema { + source = projects.redwoodLazylayoutSchema + type = 'app.cash.redwood.lazylayout.RedwoodLazyLayout' +} + android { namespace 'app.cash.redwood.lazylayout.composeui' diff --git a/redwood-lazylayout-composeui/src/commonMain/kotlin/app/cash/redwood/lazylayout/composeui/ComposeUiLazyList.kt b/redwood-lazylayout-composeui/src/commonMain/kotlin/app/cash/redwood/lazylayout/composeui/ComposeUiLazyList.kt deleted file mode 100644 index 1ddedb2d42..0000000000 --- a/redwood-lazylayout-composeui/src/commonMain/kotlin/app/cash/redwood/lazylayout/composeui/ComposeUiLazyList.kt +++ /dev/null @@ -1,236 +0,0 @@ -/* - * Copyright (C) 2022 Square, Inc. - * - * 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 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package app.cash.redwood.lazylayout.composeui - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.pullrefresh.PullRefreshIndicator -import androidx.compose.material.pullrefresh.pullRefresh -import androidx.compose.material.pullrefresh.rememberPullRefreshState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import app.cash.redwood.Modifier as RedwoodModifier -import app.cash.redwood.layout.api.Constraint -import app.cash.redwood.layout.api.CrossAxisAlignment -import app.cash.redwood.lazylayout.api.ScrollItemIndex -import app.cash.redwood.lazylayout.widget.LazyList -import app.cash.redwood.lazylayout.widget.RefreshableLazyList -import app.cash.redwood.ui.Margin -import app.cash.redwood.ui.toPlatformDp -import app.cash.redwood.widget.compose.ComposeWidgetChildren - -@OptIn(ExperimentalMaterialApi::class) -internal class ComposeUiLazyList : LazyList<@Composable (Modifier) -> Unit> { - private var isVertical by mutableStateOf(false) - private var onViewportChanged: ((firstVisibleItemIndex: Int, lastVisibleItemIndex: Int) -> Unit)? by mutableStateOf(null) - private var itemsBefore by mutableIntStateOf(0) - private var itemsAfter by mutableIntStateOf(0) - private var isRefreshing by mutableStateOf(false) - private var onRefresh: (() -> Unit)? by mutableStateOf(null) - private var width by mutableStateOf(Constraint.Wrap) - private var height by mutableStateOf(Constraint.Wrap) - private var margin by mutableStateOf(Margin.Zero) - private var crossAxisAlignment by mutableStateOf(CrossAxisAlignment.Start) - private var scrollItemIndex by mutableStateOf(null) - private var pullRefreshContentColor by mutableStateOf(Color.Black) - - internal var testOnlyModifier: Modifier? = null - - override var modifier: RedwoodModifier = RedwoodModifier - - override val placeholder = ComposeWidgetChildren() - - override val items = ComposeWidgetChildren() - - override fun isVertical(isVertical: Boolean) { - this.isVertical = isVertical - } - - override fun onViewportChanged(onViewportChanged: (firstVisibleItemIndex: Int, lastVisibleItemIndex: Int) -> Unit) { - this.onViewportChanged = onViewportChanged - } - - override fun itemsBefore(itemsBefore: Int) { - this.itemsBefore = itemsBefore - } - - override fun itemsAfter(itemsAfter: Int) { - this.itemsAfter = itemsAfter - } - - fun refreshing(refreshing: Boolean) { - this.isRefreshing = refreshing - } - - fun onRefresh(onRefresh: (() -> Unit)?) { - this.onRefresh = onRefresh - } - - override fun width(width: Constraint) { - this.width = width - } - - override fun height(height: Constraint) { - this.height = height - } - - override fun margin(margin: Margin) { - this.margin = margin - } - - override fun crossAxisAlignment(crossAxisAlignment: CrossAxisAlignment) { - this.crossAxisAlignment = crossAxisAlignment - } - - override fun scrollItemIndex(scrollItemIndex: ScrollItemIndex) { - this.scrollItemIndex = scrollItemIndex - } - - fun pullRefreshContentColor(pullRefreshContentColor: UInt) { - this.pullRefreshContentColor = Color(pullRefreshContentColor.toLong()) - } - - override val value: @Composable (Modifier) -> Unit = { modifier -> - val content: LazyListScope.() -> Unit = { - items(items.widgets) { item -> - val modifier = if (crossAxisAlignment != CrossAxisAlignment.Stretch) { - Modifier - } else if (isVertical) { - Modifier.fillMaxWidth() - } else { - Modifier.fillMaxHeight() - } - item.value.invoke(modifier) - } - } - Box(modifier) { - val refreshState = rememberPullRefreshState( - refreshing = isRefreshing, - onRefresh = { - // This looks strange, but the other platforms all assume that `refreshing = true` after - // onRefresh is called. To maintain consistency we do the same, otherwise the refresh - // indicator disappears whilst we wait for the presenter to send `refreshing = true` - isRefreshing = true - onRefresh?.invoke() - }, - ) - PullRefreshIndicator( - refreshing = isRefreshing, - state = refreshState, - // Should this be placed somewhere different when horizontal - modifier = Modifier.align(Alignment.TopCenter), - contentColor = pullRefreshContentColor, - ) - - // TODO Fix item count truncation - val state = rememberLazyListState() - val lastVisibleItemIndex by remember { - derivedStateOf { state.layoutInfo.visibleItemsInfo.lastOrNull()?.index } - } - LaunchedEffect(lastVisibleItemIndex) { - lastVisibleItemIndex?.let { lastVisibleItemIndex -> - onViewportChanged!!(state.firstVisibleItemIndex, lastVisibleItemIndex) - } - } - LaunchedEffect(scrollItemIndex) { - scrollItemIndex?.index?.let { index -> - state.scrollToItem(index) - } - } - - val modifier = Modifier - .run { if (width == Constraint.Fill) fillMaxWidth() else this } - .run { if (height == Constraint.Fill) fillMaxHeight() else this } - .padding( - start = margin.start.toPlatformDp().dp, - top = margin.top.toPlatformDp().dp, - end = margin.end.toPlatformDp().dp, - bottom = margin.bottom.toPlatformDp().dp, - ) - .pullRefresh(state = refreshState, enabled = onRefresh != null) - .run { testOnlyModifier?.let { then(it) } ?: this } - if (isVertical) { - val horizontalAlignment = when (crossAxisAlignment) { - CrossAxisAlignment.Start -> Alignment.Start - CrossAxisAlignment.Center -> Alignment.CenterHorizontally - CrossAxisAlignment.End -> Alignment.End - CrossAxisAlignment.Stretch -> Alignment.Start - else -> throw AssertionError() - } - LazyColumn( - modifier = modifier, - state = state, - horizontalAlignment = horizontalAlignment, - content = content, - ) - } else { - LazyRow( - modifier = modifier, - state = state, - verticalAlignment = when (crossAxisAlignment) { - CrossAxisAlignment.Start -> Alignment.Top - CrossAxisAlignment.Center -> Alignment.CenterVertically - CrossAxisAlignment.End -> Alignment.Bottom - CrossAxisAlignment.Stretch -> Alignment.Top - else -> throw AssertionError() - }, - content = content, - ) - } - } - } -} - -internal class ComposeUiRefreshableLazyList : RefreshableLazyList<@Composable (Modifier) -> Unit> { - private val delegate = ComposeUiLazyList() - - override val value get() = delegate.value - override var modifier by delegate::modifier - - override val placeholder get() = delegate.placeholder - override val items get() = delegate.items - - override fun isVertical(isVertical: Boolean) = delegate.isVertical(isVertical) - override fun onViewportChanged(onViewportChanged: (Int, Int) -> Unit) = delegate.onViewportChanged(onViewportChanged) - override fun itemsBefore(itemsBefore: Int) = delegate.itemsBefore(itemsBefore) - override fun itemsAfter(itemsAfter: Int) = delegate.itemsAfter(itemsAfter) - override fun refreshing(refreshing: Boolean) = delegate.refreshing(refreshing) - override fun onRefresh(onRefresh: (() -> Unit)?) = delegate.onRefresh(onRefresh) - override fun width(width: Constraint) = delegate.width(width) - override fun height(height: Constraint) = delegate.height(height) - override fun margin(margin: Margin) = delegate.margin(margin) - override fun crossAxisAlignment(crossAxisAlignment: CrossAxisAlignment) = delegate.crossAxisAlignment(crossAxisAlignment) - override fun scrollItemIndex(scrollItemIndex: ScrollItemIndex) = delegate.scrollItemIndex(scrollItemIndex) - override fun pullRefreshContentColor(pullRefreshContentColor: UInt) = delegate.pullRefreshContentColor(pullRefreshContentColor) -} diff --git a/redwood-lazylayout-composeui/src/commonMain/kotlin/app/cash/redwood/lazylayout/composeui/ComposeUiRedwoodTreehouseLazyLayoutWidgetFactory.kt b/redwood-lazylayout-composeui/src/commonMain/kotlin/app/cash/redwood/lazylayout/composeui/ComposeUiRedwoodTreehouseLazyLayoutWidgetFactory.kt index ac5eb9c6c5..9af7b9cfc7 100644 --- a/redwood-lazylayout-composeui/src/commonMain/kotlin/app/cash/redwood/lazylayout/composeui/ComposeUiRedwoodTreehouseLazyLayoutWidgetFactory.kt +++ b/redwood-lazylayout-composeui/src/commonMain/kotlin/app/cash/redwood/lazylayout/composeui/ComposeUiRedwoodTreehouseLazyLayoutWidgetFactory.kt @@ -15,16 +15,212 @@ */ package app.cash.redwood.lazylayout.composeui +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import app.cash.redwood.lazylayout.widget.LazyList -import app.cash.redwood.lazylayout.widget.RedwoodLazyLayoutWidgetFactory -import app.cash.redwood.lazylayout.widget.RefreshableLazyList +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import app.cash.redwood.layout.api.Constraint +import app.cash.redwood.layout.api.CrossAxisAlignment +import app.cash.redwood.lazylayout.api.ScrollItemIndex +import app.cash.redwood.ui.Margin +import app.cash.redwood.ui.toPlatformDp +import app.cash.redwood.widget.compose.ComposeWidgetChildren -public class ComposeUiRedwoodLazyLayoutWidgetFactory : RedwoodLazyLayoutWidgetFactory<@Composable (Modifier) -> Unit> { - override fun LazyList(): LazyList<@Composable (Modifier) -> Unit> = - ComposeUiLazyList() +public class ComposeUiRedwoodLazyLayoutWidgetFactory : AbstractComposeUiRedwoodLazyLayoutWidgetFactory() { + @Composable + override fun LazyListBinding( + isVertical: Boolean, + onViewportChanged: (Int, Int) -> Unit, + itemsBefore: Int, + itemsAfter: Int, + width: Constraint, + height: Constraint, + margin: Margin, + crossAxisAlignment: CrossAxisAlignment, + scrollItemIndex: ScrollItemIndex, + placeholder: ComposeWidgetChildren, + items: ComposeWidgetChildren, + modifier: Modifier, + ) { + LazyList( + isVertical = isVertical, + onViewportChanged = onViewportChanged, + itemsBefore = itemsBefore, + itemsAfter = itemsAfter, + refreshing = false, + onRefresh = null, + width = width, + height = height, + margin = margin, + crossAxisAlignment = crossAxisAlignment, + scrollItemIndex = scrollItemIndex, + pullRefreshContentColor = 0U, + placeholder = placeholder, + items = items, + modifier = modifier, + ) + } - override fun RefreshableLazyList(): RefreshableLazyList<@Composable (Modifier) -> Unit> = - ComposeUiRefreshableLazyList() + @Composable + override fun RefreshableLazyListBinding( + isVertical: Boolean, + onViewportChanged: (Int, Int) -> Unit, + itemsBefore: Int, + itemsAfter: Int, + refreshing: Boolean, + onRefresh: (() -> Unit)?, + width: Constraint, + height: Constraint, + margin: Margin, + crossAxisAlignment: CrossAxisAlignment, + scrollItemIndex: ScrollItemIndex, + pullRefreshContentColor: UInt, + placeholder: ComposeWidgetChildren, + items: ComposeWidgetChildren, + modifier: Modifier, + ) { + LazyList( + isVertical = isVertical, + onViewportChanged = onViewportChanged, + itemsBefore = itemsBefore, + itemsAfter = itemsAfter, + refreshing = refreshing, + onRefresh = onRefresh, + width = width, + height = height, + margin = margin, + crossAxisAlignment = crossAxisAlignment, + scrollItemIndex = scrollItemIndex, + pullRefreshContentColor = pullRefreshContentColor, + placeholder = placeholder, + items = items, + modifier = modifier, + ) + } +} + +@Composable +@OptIn(ExperimentalMaterialApi::class) +private fun LazyList( + isVertical: Boolean, + onViewportChanged: (Int, Int) -> Unit, + itemsBefore: Int, + itemsAfter: Int, + refreshing: Boolean, + onRefresh: (() -> Unit)?, + width: Constraint, + height: Constraint, + margin: Margin, + crossAxisAlignment: CrossAxisAlignment, + scrollItemIndex: ScrollItemIndex, + pullRefreshContentColor: UInt, + placeholder: ComposeWidgetChildren, + items: ComposeWidgetChildren, + modifier: Modifier, +) { + val content: LazyListScope.() -> Unit = { + items(items.widgets) { item -> + val modifier = if (crossAxisAlignment != CrossAxisAlignment.Stretch) { + Modifier + } else if (isVertical) { + Modifier.fillMaxWidth() + } else { + Modifier.fillMaxHeight() + } + item.value.invoke(modifier) + } + } + Box(modifier) { + var isRefreshing by remember(refreshing) { mutableStateOf(refreshing) } + val refreshState = rememberPullRefreshState( + refreshing = isRefreshing, + onRefresh = { + // This looks strange, but the other platforms all assume that `refreshing = true` after + // onRefresh is called. To maintain consistency we do the same, otherwise the refresh + // indicator disappears whilst we wait for the presenter to send `refreshing = true` + isRefreshing = true + onRefresh?.invoke() + }, + ) + PullRefreshIndicator( + refreshing = isRefreshing, + state = refreshState, + // Should this be placed somewhere different when horizontal + modifier = Modifier.align(Alignment.TopCenter), + contentColor = Color(pullRefreshContentColor.toLong()), + ) + + // TODO Fix item count truncation + val state = rememberLazyListState() + val lastVisibleItemIndex by remember { + derivedStateOf { state.layoutInfo.visibleItemsInfo.lastOrNull()?.index } + } + LaunchedEffect(lastVisibleItemIndex) { + lastVisibleItemIndex?.let { lastVisibleItemIndex -> + onViewportChanged(state.firstVisibleItemIndex, lastVisibleItemIndex) + } + } + LaunchedEffect(scrollItemIndex) { + state.scrollToItem(scrollItemIndex.index) + } + + val modifier = Modifier + .run { if (width == Constraint.Fill) fillMaxWidth() else this } + .run { if (height == Constraint.Fill) fillMaxHeight() else this } + .padding( + start = margin.start.toPlatformDp().dp, + top = margin.top.toPlatformDp().dp, + end = margin.end.toPlatformDp().dp, + bottom = margin.bottom.toPlatformDp().dp, + ) + .pullRefresh(state = refreshState, enabled = onRefresh != null) + if (isVertical) { + val horizontalAlignment = when (crossAxisAlignment) { + CrossAxisAlignment.Start -> Alignment.Start + CrossAxisAlignment.Center -> Alignment.CenterHorizontally + CrossAxisAlignment.End -> Alignment.End + CrossAxisAlignment.Stretch -> Alignment.Start + else -> throw AssertionError() + } + LazyColumn( + modifier = modifier, + state = state, + horizontalAlignment = horizontalAlignment, + content = content, + ) + } else { + LazyRow( + modifier = modifier, + state = state, + verticalAlignment = when (crossAxisAlignment) { + CrossAxisAlignment.Start -> Alignment.Top + CrossAxisAlignment.Center -> Alignment.CenterVertically + CrossAxisAlignment.End -> Alignment.Bottom + CrossAxisAlignment.Stretch -> Alignment.Top + else -> throw AssertionError() + }, + content = content, + ) + } + } }