Skip to content

Commit

Permalink
WIP Sketching Compose-based workflows.
Browse files Browse the repository at this point in the history
  • Loading branch information
zach-klippenstein committed Feb 26, 2025
1 parent 56e6ee0 commit c007b73
Show file tree
Hide file tree
Showing 7 changed files with 234 additions and 0 deletions.
1 change: 1 addition & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ pluginManagement {
google()
// For binary compatibility validator.
maven { url = uri("https://kotlin.bintray.com/kotlinx") }
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
}
includeBuild("build-logic")
}
Expand Down
2 changes: 2 additions & 0 deletions workflow-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import com.squareup.workflow1.buildsrc.iosWithSimulatorArm64
plugins {
id("kotlin-multiplatform")
id("published")
// id("org.jetbrains.compose") version "1.7.3"
}

kotlin {
Expand All @@ -23,6 +24,7 @@ dependencies {
commonMainApi(libs.kotlinx.coroutines.core)
// For Snapshot.
commonMainApi(libs.squareup.okio)
commonMainApi("org.jetbrains.compose.runtime:runtime:1.7.3")

commonTestImplementation(libs.kotlinx.atomicfu)
commonTestImplementation(libs.kotlinx.coroutines.test.common)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,17 @@

package com.squareup.workflow1

import androidx.compose.runtime.Composable
import androidx.compose.runtime.Composition
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.MonotonicFrameClock
import androidx.compose.runtime.Recomposer
import androidx.compose.runtime.mutableStateOf
import com.squareup.workflow1.WorkflowAction.Companion.noAction
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.launch
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.jvm.JvmMultifileClass
import kotlin.jvm.JvmName
import kotlin.reflect.KType
Expand Down Expand Up @@ -85,6 +94,43 @@ public interface BaseRenderContext<out PropsT, StateT, in OutputT> {
handler: (ChildOutputT) -> WorkflowAction<PropsT, StateT, OutputT>
): ChildRenderingT

/**
* Synchronously composes a [content] function and returns its rendering. Whenever [content] is
* invalidated, this workflow will be re-rendered and the [content] recomposed to return its new
* value.
*
* @see ComposeWorkflow
*/
public fun <ChildRenderingT> renderComposable(
key: String = "",
content: @WorkflowComposable @Composable () -> ChildRenderingT
): ChildRenderingT {
val renderer: WorkflowComposableRenderer = TODO()
val frameClock: MonotonicFrameClock = TODO()
val coroutineContext = EmptyCoroutineContext + frameClock
val recomposer = Recomposer(coroutineContext)
val composition = Composition(UnitApplier, recomposer)

// TODO I think we need more than a simple UNDISPATCHED start to make this work – we have to
// pump the dispatcher until the composition is finished.
CoroutineScope(coroutineContext).launch(start = CoroutineStart.UNDISPATCHED) {
try {
recomposer.runRecomposeAndApplyChanges()
} finally {
composition.dispose()
}
}

val rendering = mutableStateOf<ChildRenderingT?>(null)
composition.setContent {
CompositionLocalProvider(LocalWorkflowComposableRenderer provides renderer) {
rendering.value = content()
}
}
@Suppress("UNCHECKED_CAST")
return rendering.value as ChildRenderingT
}

/**
* Ensures [sideEffect] is running with the given [key].
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.squareup.workflow1

import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember

/**
* A [Workflow]-like interface that participates in a workflow tree via its [Rendering] composable.
*/
@Stable
public interface ComposeWorkflow<
in PropsT,
out OutputT,
out RenderingT
> {

/**
* The main composable of this workflow that consumes some [props] from its parent and may emit
* an output via [emitOutput].
*
* Equivalent to [StatefulWorkflow.render].
*/
@WorkflowComposable
@Composable
fun Rendering(
props: PropsT,
emitOutput: (OutputT) -> Unit
): RenderingT
}

fun <
PropsT, StateT, OutputT,
ChildPropsT, ChildOutputT, ChildRenderingT
> BaseRenderContext<PropsT, StateT, OutputT>.renderChild(
child: ComposeWorkflow<ChildPropsT, ChildOutputT, ChildRenderingT>,
props: ChildPropsT,
key: String = "",
handler: (ChildOutputT) -> WorkflowAction<PropsT, StateT, OutputT>
): ChildRenderingT = renderComposable(key = key) {
// Explicitly remember the output function since we know that actionSink is stable even though
// Compose might not know that.
val emitOutput: (ChildOutputT) -> Unit = remember(actionSink) {
{ output ->
val action = handler(output)
actionSink.send(action)
}
}
child.Rendering(
props = props,
emitOutput = emitOutput
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.squareup.workflow1

import androidx.compose.runtime.Applier

internal object UnitApplier : Applier<Unit> {
override val current: Unit
get() = Unit

override fun clear() {
}

override fun down(node: Unit) {
}

override fun insertBottomUp(
index: Int,
instance: Unit
) {
}

override fun insertTopDown(
index: Int,
instance: Unit
) {
}

override fun move(
from: Int,
to: Int,
count: Int
) {
}

override fun remove(
index: Int,
count: Int
) {
}

override fun up() {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.squareup.workflow1

import androidx.compose.runtime.ComposableTargetMarker
import kotlin.annotation.AnnotationRetention.BINARY
import kotlin.annotation.AnnotationTarget.FILE
import kotlin.annotation.AnnotationTarget.FUNCTION
import kotlin.annotation.AnnotationTarget.PROPERTY_GETTER
import kotlin.annotation.AnnotationTarget.TYPE
import kotlin.annotation.AnnotationTarget.TYPE_PARAMETER

/**
* An annotation that can be used to mark a composable function as being expected to be use in a
* composable function that is also marked or inferred to be marked as a [WorkflowComposable], i.e.
* that can be called from [BaseRenderContext.renderComposable].
*
* Using this annotation explicitly is rarely necessary as the Compose compiler plugin will infer
* the necessary equivalent annotations automatically. See
* [androidx.compose.runtime.ComposableTarget] for details.
*/
@ComposableTargetMarker(description = "Workflow Composable")
@Target(FILE, FUNCTION, PROPERTY_GETTER, TYPE, TYPE_PARAMETER)
@Retention(BINARY)
annotation class WorkflowComposable
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.squareup.workflow1

import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf

internal val LocalWorkflowComposableRenderer =
staticCompositionLocalOf<WorkflowComposableRenderer> { error("No renderer") }

internal interface WorkflowComposableRenderer {
@Composable
fun <ChildPropsT, ChildOutputT, ChildRenderingT> Child(
workflow: Workflow<ChildPropsT, ChildOutputT, ChildRenderingT>,
props: ChildPropsT,
onOutput: ((ChildOutputT) -> Unit)?
): ChildRenderingT
}

/**
* Renders a child [Workflow] from any [WorkflowComposable] (e.g. a [ComposeWorkflow.Rendering] or
* [BaseRenderContext.renderComposable]) and returns its rendering.
*
* @param handler An optional function that, if non-null, will be called when the child emits an
* output. If null, the child's outputs will be ignored.
*/
@WorkflowComposable
@Composable
fun <ChildPropsT, ChildOutputT, ChildRenderingT> Child(
workflow: Workflow<ChildPropsT, ChildOutputT, ChildRenderingT>,
props: ChildPropsT,
onOutput: ((ChildOutputT) -> Unit)?
): ChildRenderingT {
val renderer = LocalWorkflowComposableRenderer.current
return renderer.Child(workflow, props, onOutput)
}

@WorkflowComposable
@Composable
fun <ChildPropsT, ChildOutputT, ChildRenderingT> Child(
workflow: ComposeWorkflow<ChildPropsT, ChildOutputT, ChildRenderingT>,
props: ChildPropsT,
handler: ((ChildOutputT) -> Unit)?
): ChildRenderingT {
val childRendering = remember { mutableStateOf<ChildRenderingT?>(null) }
// Since this function returns a value, it can't restart without also restarting its parent.
// IsolateRecomposeScope allows the subtree to restart and only restarts us if the rendering value
// actually changed.
IsolateRecomposeScope(
child = workflow,
props = props,
handler = handler,
result = childRendering
)
@Suppress("UNCHECKED_CAST")
return childRendering.value as ChildRenderingT
}

@Composable
private fun <PropsT, OutputT, RenderingT> IsolateRecomposeScope(
child: ComposeWorkflow<PropsT, OutputT, RenderingT>,
props: PropsT,
handler: ((OutputT) -> Unit)?,
result: MutableState<RenderingT>,
) {
result.value = child.Rendering(props, handler ?: {})
}

0 comments on commit c007b73

Please sign in to comment.