diff --git a/docs/Formula-Android.md b/docs/Formula-Android.md index 1b7ba32..3cbe2a3 100644 --- a/docs/Formula-Android.md +++ b/docs/Formula-Android.md @@ -45,14 +45,11 @@ class CounterFeatureFactory : FeatureFactory() { } } -// View factory which uses XML layout resource. -class CounterViewFactory : LayoutViewFactory(R.layout.counter) { - override fun ViewInstance.create(): FeatureView { - // We use [ViewInstance.view] to access the inflated view - val counterView = CounterRenderView(view) - - // We create a [FeatureView] by passing a [RenderView] - return featureView(counterView) +// View factory that renders the output with Jetpack Compose. +class CounterViewFactory : ComposeViewFactory() { + @Composable + override fun Content(model: CounterOutput) { + CounterScreen(model) } } ``` diff --git a/formula-android-compose/.gitignore b/formula-android-compose/.gitignore deleted file mode 100644 index 796b96d..0000000 --- a/formula-android-compose/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build diff --git a/formula-android-compose/build.gradle.kts b/formula-android-compose/build.gradle.kts deleted file mode 100644 index 8ca4fae..0000000 --- a/formula-android-compose/build.gradle.kts +++ /dev/null @@ -1,38 +0,0 @@ -plugins { - id("com.android.library") - id("kotlin-android") -} - -apply { - from("$rootDir/.buildscript/configure-signing.gradle") - from("$rootDir/.buildscript/jacoco-workaround.gradle") -} - -android { - namespace = "com.instacart.formula.android.compose" - - buildFeatures { - compose = true - } - - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() - } - - publishing { - singleVariant("release") - } -} - -dependencies { - api(project(":formula-android")) - api(libs.compose.ui) - - implementation(libs.compose.rxjava3) - - testImplementation(libs.androidx.test.rules) - testImplementation(libs.androidx.test.runner) - testImplementation(libs.espresso.core) - testImplementation(libs.truth) - testImplementation(libs.kotlin.reflect) -} diff --git a/formula-android-compose/gradle.properties b/formula-android-compose/gradle.properties deleted file mode 100644 index 19ea18d..0000000 --- a/formula-android-compose/gradle.properties +++ /dev/null @@ -1,5 +0,0 @@ -POM_ARTIFACT_ID=formula-android-compose -POM_NAME=Formula Android Compose -POM_PACKAGING=aar - -android.useAndroidX=true \ No newline at end of file diff --git a/formula-android-compose/src/main/AndroidManifest.xml b/formula-android-compose/src/main/AndroidManifest.xml deleted file mode 100644 index c4e6c98..0000000 --- a/formula-android-compose/src/main/AndroidManifest.xml +++ /dev/null @@ -1 +0,0 @@ - diff --git a/formula-android-compose/src/main/java/com/instacart/formula/android/compose/ComposeViewFactory.kt b/formula-android-compose/src/main/java/com/instacart/formula/android/compose/ComposeViewFactory.kt deleted file mode 100644 index ddc2eca..0000000 --- a/formula-android-compose/src/main/java/com/instacart/formula/android/compose/ComposeViewFactory.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.instacart.formula.android.compose - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import com.instacart.formula.android.FeatureView -import com.instacart.formula.android.ViewFactory - -abstract class ComposeViewFactory : ViewFactory { - - final override fun create(params: ViewFactory.Params): FeatureView { - val view = ComposeView(params.context) - // Based-on: https://developer.android.com/develop/ui/compose/migrate/interoperability-apis/compose-in-views#compose-in-fragments - view.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - - val outputState = mutableStateOf(initialModel()) - view.setContent { - val model = outputState.value - if (model != null) { - Content(model) - } - } - return FeatureView( - view = view, - setOutput = { outputState.value = it }, - ) - } - - open fun initialModel(): RenderModel? { - return null - } - - @Composable - abstract fun Content(model: RenderModel) -} \ No newline at end of file diff --git a/formula-android-tests/src/test/java/com/instacart/formula/FormulaFragmentTest.kt b/formula-android-tests/src/test/java/com/instacart/formula/FormulaFragmentTest.kt index 7d1a10f..c703101 100644 --- a/formula-android-tests/src/test/java/com/instacart/formula/FormulaFragmentTest.kt +++ b/formula-android-tests/src/test/java/com/instacart/formula/FormulaFragmentTest.kt @@ -324,14 +324,17 @@ class FormulaFragmentTest { assertVisibleContract(TestKey()) } - @Test fun `notify fragment environment if setOutput throws an error`() { + @Test fun `render exceptions are no longer routed to environment#onScreenError`() { val key = TestKeyWithId(1) navigateToTaskDetail(id = key.id) + // With the Compose-native FeatureView, render-time exceptions happen during + // recomposition and are no longer routed through FormulaFragment to environment.onScreenError. + // The host app is now responsible for handling Compose recomposer errors. sendStateUpdate(key, "crash") - assertThat(renderCalls).isNotEmpty() - assertThat(errors).hasSize(1) + assertThat(renderCalls).isNotEmpty() + assertThat(errors).isEmpty() } @Test @@ -380,6 +383,12 @@ class FormulaFragmentTest { private fun sendStateUpdate(contract: RouteKey, update: Any) { val flow = getOrCreateFlow(contract) flow.tryEmit(update) + // Drain the Compose recomposer so assertions observe a settled state. + // Skipped when called from a background thread; in that case the caller + // is responsible for idling the main looper after synchronization. + if (Looper.myLooper() == Looper.getMainLooper()) { + Shadows.shadowOf(Looper.getMainLooper()).idle() + } } private fun stateProvider(contract: RouteKey): (CoroutineScope) -> StateFlow = { diff --git a/formula-android-tests/src/test/java/com/instacart/formula/TestFeatureFactory.kt b/formula-android-tests/src/test/java/com/instacart/formula/TestFeatureFactory.kt index 922064a..b3eec08 100644 --- a/formula-android-tests/src/test/java/com/instacart/formula/TestFeatureFactory.kt +++ b/formula-android-tests/src/test/java/com/instacart/formula/TestFeatureFactory.kt @@ -13,7 +13,7 @@ class TestFeatureFactory( ) : FeatureFactory() { override fun Params.initialize(): Feature { return Feature( - viewFactory = TestViewFactory { _, value -> + viewFactory = TestViewFactory { value -> render(key, value) } ) { diff --git a/formula-android/build.gradle.kts b/formula-android/build.gradle.kts index e1d1e06..bc0a37b 100644 --- a/formula-android/build.gradle.kts +++ b/formula-android/build.gradle.kts @@ -1,5 +1,3 @@ -import org.jetbrains.dokka.gradle.DokkaTask - plugins { id("com.android.library") id("kotlin-android") @@ -14,6 +12,14 @@ apply { android { namespace = "com.instacart.formula.android" + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() + } + testOptions { unitTests.isReturnDefaultValues = true unitTests.isIncludeAndroidResources = true @@ -29,6 +35,7 @@ dependencies { implementation(libs.androidx.annotation) implementation(libs.androidx.appcompat) implementation(libs.lifecycle.runtime.ktx) + api(libs.compose.ui) testImplementation(libs.androidx.test.rules) testImplementation(libs.androidx.test.runner) diff --git a/formula-android/src/main/java/com/instacart/formula/android/ComposeViewFactory.kt b/formula-android/src/main/java/com/instacart/formula/android/ComposeViewFactory.kt new file mode 100644 index 0000000..2f943d6 --- /dev/null +++ b/formula-android/src/main/java/com/instacart/formula/android/ComposeViewFactory.kt @@ -0,0 +1,33 @@ +package com.instacart.formula.android + +import androidx.compose.runtime.Composable + +/** + * Convenience [ViewFactory] base class for Compose-rendered routes. + * + * ``` + * class MyViewFactory : ComposeViewFactory() { + * @Composable + * override fun Content(model: MyRenderModel) { + * MyScreen(model) + * } + * } + * ``` + */ +abstract class ComposeViewFactory : ViewFactory { + + final override fun create(params: ViewFactory.Params): FeatureView { + return FeatureView( + content = { model -> Content(model) }, + initialModel = initialModel(), + ) + } + + /** Optional initial model rendered before the first state emission. Defaults to null. */ + open fun initialModel(): RenderModel? { + return null + } + + @Composable + abstract fun Content(model: RenderModel) +} diff --git a/formula-android/src/main/java/com/instacart/formula/android/FeatureFactory.kt b/formula-android/src/main/java/com/instacart/formula/android/FeatureFactory.kt index abdb099..c3d5fec 100644 --- a/formula-android/src/main/java/com/instacart/formula/android/FeatureFactory.kt +++ b/formula-android/src/main/java/com/instacart/formula/android/FeatureFactory.kt @@ -16,10 +16,12 @@ package com.instacart.formula.android * // Note: we could create our own internal dagger component here using the dependencies. * val formula = TaskListFormula(dependencies.taskRepo()) * return Feature( - * viewFactory = ViewFactory.fromLayout(R.layout.task_list) { view -> - * val renderView = TaskListRenderView(view) - * featureView(renderView) - * } + * viewFactory = object : ComposeViewFactory() { + * @Composable + * override fun Content(model: TaskListRenderModel) { + * TaskListScreen(model) + * } + * }, * ) { * formula.runAsStateFlow(it, dependencies.taskListInput()) * } diff --git a/formula-android/src/main/java/com/instacart/formula/android/FeatureView.kt b/formula-android/src/main/java/com/instacart/formula/android/FeatureView.kt index 7ad0057..37f9341 100644 --- a/formula-android/src/main/java/com/instacart/formula/android/FeatureView.kt +++ b/formula-android/src/main/java/com/instacart/formula/android/FeatureView.kt @@ -1,19 +1,16 @@ package com.instacart.formula.android -import android.view.View +import androidx.compose.runtime.Composable /** - * Feature view provides [FormulaFragment] with the root Android view which will be returned as - * part of [FormulaFragment.onCreateView] and the logic to bind the state observable to the - * rendering. Formula fragment uses [ViewFactory.create] to instantiate [FeatureView]. + * Describes how a Formula feature renders. Returned by [ViewFactory.create] and consumed by + * [FormulaFragment] (which hosts the [content] in a `ComposeView`) or by Compose-native hosts + * (which invoke [content] directly). * - * Use [ViewFactory.fromLayout] and [LayoutViewFactory] to define a [ViewFactory] which can create - * [FeatureView]. - * - * @param view The root Android view. - * @param setOutput A function called to apply [RenderModel] to the view. + * @param content Composable that renders the latest [RenderModel]. + * @param initialModel Optional initial model rendered before the first state emission. */ class FeatureView( - val view: View, - val setOutput: (RenderModel) -> Unit, + val content: @Composable (RenderModel) -> Unit, + val initialModel: RenderModel? = null, ) diff --git a/formula-android/src/main/java/com/instacart/formula/android/FormulaFragment.kt b/formula-android/src/main/java/com/instacart/formula/android/FormulaFragment.kt index 0f7910d..863f2b8 100644 --- a/formula-android/src/main/java/com/instacart/formula/android/FormulaFragment.kt +++ b/formula-android/src/main/java/com/instacart/formula/android/FormulaFragment.kt @@ -4,6 +4,10 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment import com.instacart.formula.android.internal.getOrSetArguments import java.lang.Exception @@ -37,27 +41,26 @@ class FormulaFragment : Fragment() { private val routeDelegate: RouteEnvironment.RouteDelegate get() = environment.routeDelegate - private var featureView: FeatureView? = null + private var outputState: MutableState? = null private var output: Any? = null override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - val viewFactory = navigationStore.getViewFactory(formulaRouteId) ?: run { - // No view factory, no view - return null - } - val params = ViewFactory.Params( - context = requireContext(), - inflater = inflater, - container = container, - ) - + val viewFactory = navigationStore.getViewFactory(formulaRouteId) ?: return null + val params = ViewFactory.Params(context = requireContext()) val featureView = environment.routeDelegate.createView( routeId = formulaRouteId, viewFactory = viewFactory, params = params, ) - this.featureView = featureView - return featureView.view + val state = mutableStateOf(featureView.initialModel) + this.outputState = state + return ComposeView(requireContext()).apply { + // Based-on: https://developer.android.com/develop/ui/compose/migrate/interoperability-apis/compose-in-views#compose-in-fragments + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + state.value?.let { featureView.content(it) } + } + } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -67,7 +70,7 @@ class FormulaFragment : Fragment() { override fun onDestroyView() { super.onDestroyView() - featureView = null + outputState = null } fun setState(state: Any) { @@ -89,10 +92,9 @@ class FormulaFragment : Fragment() { private fun tryToSetState() { val output = output ?: return - val view = featureView ?: return - + val state = outputState ?: return try { - routeDelegate.setOutput(formulaRouteId, output, view.setOutput) + routeDelegate.setOutput(formulaRouteId, output) { state.value = it } } catch (exception: Exception) { environment.onScreenError(key, exception) } diff --git a/formula-android/src/main/java/com/instacart/formula/android/LayoutViewFactory.kt b/formula-android/src/main/java/com/instacart/formula/android/LayoutViewFactory.kt deleted file mode 100644 index e8332bf..0000000 --- a/formula-android/src/main/java/com/instacart/formula/android/LayoutViewFactory.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.instacart.formula.android - -import androidx.annotation.LayoutRes -import com.instacart.formula.android.views.InflatedViewInstance - -/** - * View factory which uses [layoutId] to inflate an Android view and then passes that view instance - * to [create] to instantiate [FeatureView]. - * - * ``` - * class TaskViewFactory : LayoutViewFactory(R.layout.task_screen) { - * - * override ViewInstance.create(): FeatureView { - * - * // We have access the inflated view directly by calling [ViewInstance.view] - * val taskNameTextView = view.findViewById(R.id.task_name_text_view) - * - * // Create [Renderer] or [RenderView] - * val renderer = Renderer { model: TaskRenderModel -> - * taskNameTextView.text = model.counterText - * } - * - * // Finish feature view creation - * return featureView(renderer = renderer) - * } - * } - * ``` - */ -abstract class LayoutViewFactory(@LayoutRes private val layoutId: Int): ViewFactory { - - abstract fun ViewInstance.create(): FeatureView - - override fun create(params: ViewFactory.Params): FeatureView { - val view = params.inflater.inflate(layoutId, params.container, false) - return InflatedViewInstance(view).create() - } -} \ No newline at end of file diff --git a/formula-android/src/main/java/com/instacart/formula/android/ViewFactory.kt b/formula-android/src/main/java/com/instacart/formula/android/ViewFactory.kt index 25b8654..7f5bdc6 100644 --- a/formula-android/src/main/java/com/instacart/formula/android/ViewFactory.kt +++ b/formula-android/src/main/java/com/instacart/formula/android/ViewFactory.kt @@ -1,62 +1,22 @@ package com.instacart.formula.android import android.content.Context -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.annotation.LayoutRes -import com.instacart.formula.android.views.DelegateLayoutViewFactory /** - * View factory is used by [FormulaFragment] to create a [FeatureView] which contains - * the root Android view and the logic to bind the state management observable to this - * view. + * View factory is used by [FormulaFragment] (and Compose-native hosts) to create a [FeatureView] + * describing how a route's render model is rendered. * - * To create a view factory, use static constructor [fromLayout] or extend [LayoutViewFactory]. + * The typical implementation extends [ComposeViewFactory]. */ fun interface ViewFactory { - companion object { - /** - * Creates a [ViewFactory] which uses [layoutId] to inflate an Android view. - * - * ``` - * val viewFactory = ViewFactory.fromLayout(R.layout.task_screen) { - * - * // We have access the inflated view directly by calling [ViewInstance.view] - * val taskNameTextView = view.findViewById(R.id.task_name_text_view) - * - * // Create [Renderer] or [RenderView] - * val renderer = Renderer { model: TaskRenderModel -> - * taskNameTextView.text = model.counterText - * } - * - * // Finish feature view creation - * featureView(renderer = renderer) - * } - * ``` - * - * @param layoutId Layout resource to be inflated. - * @param createView Called with a [ViewInstance] to finish [FeatureView] creation - */ - fun fromLayout( - @LayoutRes layoutId: Int, - createView: ViewInstance.() -> FeatureView - ): ViewFactory { - return DelegateLayoutViewFactory(layoutId, createView) - } - } - class Params( val context: Context, - val inflater: LayoutInflater, - val container: ViewGroup?, ) /** - * This method is called from [FormulaFragment.onCreateView] function. Use it to - * instantiate an Android view instance and return a [FeatureView] which knows how to - * bind the state management to view rendering. Usually, you should use [LayoutViewFactory] - * or [ViewFactory.fromLayout] instead of implementing this method directly. + * Returns a [FeatureView] describing how to render the route. May be invoked from + * [FormulaFragment.onCreateView] or directly by a Compose-native host. */ fun create(params: Params): FeatureView -} \ No newline at end of file +} diff --git a/formula-android/src/main/java/com/instacart/formula/android/ViewInstance.kt b/formula-android/src/main/java/com/instacart/formula/android/ViewInstance.kt deleted file mode 100644 index 287b082..0000000 --- a/formula-android/src/main/java/com/instacart/formula/android/ViewInstance.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.instacart.formula.android - -import android.view.View -import com.instacart.formula.RenderView -import com.instacart.formula.Renderer - -/** - * View instance contains an initialized Android [view] and provides factory - * [methods][featureView] to create a [FeatureView]. - */ -abstract class ViewInstance { - /** - * Android view which will be used to create [FeatureView]. This view will be returned - * as part of [FormulaFragment.onCreateView]. - */ - abstract val view: View - - /** - * Creates a [FeatureView] from a [render] function - */ - fun featureView( - render: (RenderModel) -> Unit - ): FeatureView { - return FeatureView( - view = view, - setOutput = Renderer.create(render), - ) - } - - /** - * Creates a [FeatureView] from a [RenderView]. - */ - fun featureView( - renderView: RenderView, - ): FeatureView { - return FeatureView( - view = view, - setOutput = renderView.render, - ) - } -} \ No newline at end of file diff --git a/formula-android/src/main/java/com/instacart/formula/android/views/DelegateLayoutViewFactory.kt b/formula-android/src/main/java/com/instacart/formula/android/views/DelegateLayoutViewFactory.kt deleted file mode 100644 index ac7fc3c..0000000 --- a/formula-android/src/main/java/com/instacart/formula/android/views/DelegateLayoutViewFactory.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.instacart.formula.android.views - -import androidx.annotation.LayoutRes -import com.instacart.formula.android.FeatureView -import com.instacart.formula.android.LayoutViewFactory -import com.instacart.formula.android.ViewInstance - -/** - * An implementation of [LayoutViewFactory] which delegates [FeatureView] creation to [createView]. - */ -internal class DelegateLayoutViewFactory( - @LayoutRes layoutId: Int, - private val createView: ViewInstance.() -> FeatureView -) : LayoutViewFactory(layoutId) { - - override fun ViewInstance.create(): FeatureView { - return createView() - } -} \ No newline at end of file diff --git a/formula-android/src/main/java/com/instacart/formula/android/views/InflatedViewInstance.kt b/formula-android/src/main/java/com/instacart/formula/android/views/InflatedViewInstance.kt deleted file mode 100644 index 5a45eea..0000000 --- a/formula-android/src/main/java/com/instacart/formula/android/views/InflatedViewInstance.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.instacart.formula.android.views - -import android.view.View -import com.instacart.formula.android.ViewInstance - -internal class InflatedViewInstance( - override val view: View -): ViewInstance() \ No newline at end of file diff --git a/formula-android/src/test/java/com/instacart/formula/android/ViewFactoryTest.kt b/formula-android/src/test/java/com/instacart/formula/android/ViewFactoryTest.kt deleted file mode 100644 index 1cb838b..0000000 --- a/formula-android/src/test/java/com/instacart/formula/android/ViewFactoryTest.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.instacart.formula.android - -import androidx.test.core.app.ActivityScenario -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.google.common.truth.Truth.assertThat -import com.instacart.testutils.android.NoOpFeatureFactory -import com.instacart.testutils.android.TestFormulaActivity -import com.instacart.testutils.android.TestKey -import com.instacart.testutils.android.showFragment -import com.instacart.testutils.android.withFormulaAndroid -import org.junit.Test -import org.junit.runner.RunWith -import com.instacart.testutils.android.R as TestR - -@RunWith(AndroidJUnit4::class) -class ViewFactoryTest { - - @Test fun fromLayout() { - withFormulaAndroid( - configure = { - activity { - ActivityStore( - navigationStore = NavigationStore.Builder().build { - val featureFactory = NoOpFeatureFactory( - viewFactory = ViewFactory.fromLayout(TestR.layout.test_fragment_layout) { - featureView { } - } - ) - bind(featureFactory) - } - ) - } - } - ) { - val fragmentKey = TestKey() - - val scenario = ActivityScenario.launch(TestFormulaActivity::class.java) - scenario.showFragment(fragmentKey) - scenario.onActivity { - val fragment = it.supportFragmentManager.findFragmentByTag(fragmentKey.tag) - assertThat(fragment).isNotNull() - } - } - } -} \ No newline at end of file diff --git a/formula-android/src/test/java/com/instacart/formula/android/views/ViewInstanceTest.kt b/formula-android/src/test/java/com/instacart/formula/android/views/ViewInstanceTest.kt deleted file mode 100644 index a317261..0000000 --- a/formula-android/src/test/java/com/instacart/formula/android/views/ViewInstanceTest.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.instacart.formula.android.views - -import android.view.View -import androidx.test.core.app.ActivityScenario -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.google.common.truth.Truth.assertThat -import com.instacart.formula.RenderView -import com.instacart.formula.Renderer -import com.instacart.formula.android.ViewInstance -import com.instacart.testutils.android.TestFragmentActivity -import com.instacart.testutils.android.activity -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class ViewInstanceTest { - - @Test - fun `feature view with render view`() { - val renderView = object : RenderView { - override val render: Renderer = Renderer.create {} - } - val viewInstance = viewInstance() - val featureView = viewInstance.featureView(renderView) - assertThat(featureView.view).isEqualTo(viewInstance.view) - assertThat(featureView.setOutput).isEqualTo(renderView.render) - } - - private fun viewInstance(): ViewInstance { - val scenario = ActivityScenario.launch(TestFragmentActivity::class.java) - val view = View(scenario.activity()) - return InflatedViewInstance(view) - } -} \ No newline at end of file diff --git a/samples/navigation-fragments/build.gradle.kts b/samples/navigation-fragments/build.gradle.kts index e1d7566..1c8c641 100644 --- a/samples/navigation-fragments/build.gradle.kts +++ b/samples/navigation-fragments/build.gradle.kts @@ -32,7 +32,6 @@ android { dependencies { implementation(project(":formula")) implementation(project(":formula-android")) - implementation(project(":formula-android-compose")) implementation(libs.kotlin) implementation(libs.androidx.appcompat) diff --git a/samples/navigation-fragments/src/main/java/com/instacart/formula/navigation/CounterFragmentViewFactory.kt b/samples/navigation-fragments/src/main/java/com/instacart/formula/navigation/CounterFragmentViewFactory.kt index 374e433..b704166 100644 --- a/samples/navigation-fragments/src/main/java/com/instacart/formula/navigation/CounterFragmentViewFactory.kt +++ b/samples/navigation-fragments/src/main/java/com/instacart/formula/navigation/CounterFragmentViewFactory.kt @@ -3,7 +3,7 @@ package com.instacart.formula.navigation import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.runtime.Composable -import com.instacart.formula.android.compose.ComposeViewFactory +import com.instacart.formula.android.ComposeViewFactory class CounterFragmentViewFactory : ComposeViewFactory() { diff --git a/samples/stopwatch-compose/build.gradle.kts b/samples/stopwatch-compose/build.gradle.kts index fca2d13..d8a6003 100644 --- a/samples/stopwatch-compose/build.gradle.kts +++ b/samples/stopwatch-compose/build.gradle.kts @@ -39,7 +39,6 @@ android { dependencies { implementation(project(":formula-rxjava3")) implementation(project(":formula-android")) - implementation(project(":formula-android-compose")) implementation(libs.kotlin) implementation(libs.androidx.appcompat) diff --git a/samples/stopwatch-compose/src/main/java/com/instacart/formula/compose/stopwatch/StopwatchFeatureFactory.kt b/samples/stopwatch-compose/src/main/java/com/instacart/formula/compose/stopwatch/StopwatchFeatureFactory.kt index ff7cd87..06b36d8 100644 --- a/samples/stopwatch-compose/src/main/java/com/instacart/formula/compose/stopwatch/StopwatchFeatureFactory.kt +++ b/samples/stopwatch-compose/src/main/java/com/instacart/formula/compose/stopwatch/StopwatchFeatureFactory.kt @@ -17,9 +17,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp +import com.instacart.formula.android.ComposeViewFactory import com.instacart.formula.android.Feature import com.instacart.formula.android.FeatureFactory -import com.instacart.formula.android.compose.ComposeViewFactory import com.instacart.formula.invoke import com.instacart.formula.runAsStateFlow diff --git a/samples/todoapp/build.gradle.kts b/samples/todoapp/build.gradle.kts index 941aa1f..f6507e5 100644 --- a/samples/todoapp/build.gradle.kts +++ b/samples/todoapp/build.gradle.kts @@ -14,6 +14,14 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() + } + buildTypes { release { isMinifyEnabled = false @@ -33,7 +41,6 @@ dependencies { implementation(project(":formula-android")) implementation(libs.androidx.appcompat) - implementation(libs.androidx.recyclerview) implementation(libs.androidx.constraintlayout) implementation(libs.kotlin) @@ -41,12 +48,16 @@ dependencies { implementation(libs.lifecycle.extensions) + implementation(libs.compose.ui) + implementation(libs.compose.foundation) + implementation(libs.compose.material) + implementation(libs.androidx.activity.compose) + testImplementation(libs.junit) testImplementation(libs.truth) testImplementation(libs.kotlin.reflect) testImplementation(project(":formula-test")) testImplementation(libs.androidx.test.runner) - testImplementation(libs.androidx.test.runner) testImplementation(libs.espresso.core) testImplementation(libs.robolectric) } diff --git a/samples/todoapp/src/main/java/com/examples/todoapp/tasks/TaskListFeatureFactory.kt b/samples/todoapp/src/main/java/com/examples/todoapp/tasks/TaskListFeatureFactory.kt index b26cc22..7a0b42d 100644 --- a/samples/todoapp/src/main/java/com/examples/todoapp/tasks/TaskListFeatureFactory.kt +++ b/samples/todoapp/src/main/java/com/examples/todoapp/tasks/TaskListFeatureFactory.kt @@ -1,12 +1,10 @@ package com.examples.todoapp.tasks -import com.examples.todoapp.R +import androidx.compose.runtime.Composable import com.examples.todoapp.data.TaskRepo +import com.instacart.formula.android.ComposeViewFactory import com.instacart.formula.android.Feature import com.instacart.formula.android.FeatureFactory -import com.instacart.formula.android.FeatureView -import com.instacart.formula.android.LayoutViewFactory -import com.instacart.formula.android.ViewInstance import com.instacart.formula.runAsStateFlow class TaskListFeatureFactory : FeatureFactory() { @@ -23,9 +21,10 @@ class TaskListFeatureFactory : FeatureFactory(R.layout.task_list) { - override fun ViewInstance.create(): FeatureView { - return featureView(TaskListRenderView(view)) + class TaskListViewFactory : ComposeViewFactory() { + @Composable + override fun Content(model: TaskListRenderModel) { + TaskListScreen(model) } } -} \ No newline at end of file +} diff --git a/samples/todoapp/src/main/java/com/examples/todoapp/tasks/TaskListRenderView.kt b/samples/todoapp/src/main/java/com/examples/todoapp/tasks/TaskListRenderView.kt deleted file mode 100644 index e42a824..0000000 --- a/samples/todoapp/src/main/java/com/examples/todoapp/tasks/TaskListRenderView.kt +++ /dev/null @@ -1,119 +0,0 @@ -package com.examples.todoapp.tasks - -import android.app.Activity -import android.view.LayoutInflater -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import android.widget.CheckBox -import android.widget.PopupMenu -import android.widget.TextView -import androidx.appcompat.widget.Toolbar -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.examples.todoapp.R -import com.instacart.formula.Renderer -import com.instacart.formula.RenderView -import com.instacart.formula.invoke - -class TaskListRenderView(private val root: View) : RenderView { - private val toolbar: Toolbar = root.findViewById(R.id.toolbar) - private val filterMenuItem: MenuItem - - private val recyclerView: RecyclerView = root.findViewById(R.id.task_list_recycler) - private val adapter: TaskListAdapter - - init { - adapter = TaskListAdapter() - recyclerView.layoutManager = LinearLayoutManager(root.context, RecyclerView.VERTICAL, false) - recyclerView.adapter = adapter - - toolbar.title = "Tasks" - toolbar.inflateMenu(R.menu.tasks_list_menu) - filterMenuItem = toolbar.menu.findItem(R.id.menu_filter) - } - - override val render: Renderer = Renderer { model -> - val diff = DiffUtil.calculateDiff(TaskDiffCallback(adapter.items, model.items)) - adapter.items = model.items - diff.dispatchUpdatesTo(adapter) - - filterMenuItem.setOnMenuItemClickListener { - showFilteringPopUpMenu(model.filterOptions) - true - } - } - - private fun showFilteringPopUpMenu(options: List) { - val activity = (root.context as Activity) - PopupMenu(activity, activity.findViewById(R.id.menu_filter)).apply { - options.forEach { filter -> - menu.add(filter.title).setOnMenuItemClickListener { - filter.onSelected() - true - } - } - show() - } - } - - class TaskDiffCallback( - private val oldList: List, - private val newList: List - ) : DiffUtil.Callback() { - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - return oldList[oldItemPosition].id == newList[newItemPosition].id - } - - override fun getOldListSize(): Int { - return oldList.size - } - - override fun getNewListSize(): Int { - return newList.size - } - - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - return oldList[oldItemPosition] == newList[newItemPosition] - } - - override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): Any? { - return Any() - } - } - - class TaskItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - private val title: TextView = itemView.findViewById(R.id.title) - private val completeCheckBox: CheckBox = itemView.findViewById(R.id.complete) - - fun bind(model: TaskItemRenderModel) { - title.text = model.text - completeCheckBox.isChecked = model.isSelected - completeCheckBox.setOnCheckedChangeListener { _, _ -> - model.onToggle() - } - - itemView.setOnClickListener { - model.onClick() - } - } - } - - class TaskListAdapter : RecyclerView.Adapter() { - var items: List = emptyList() - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TaskItemViewHolder { - val view = LayoutInflater.from(parent.context).inflate(R.layout.task_item, parent, false) - return TaskItemViewHolder(view) - } - - override fun getItemCount(): Int { - return items.size - } - - override fun onBindViewHolder(holder: TaskItemViewHolder, position: Int) { - holder.bind(items[position]) - } - } -} diff --git a/samples/todoapp/src/main/java/com/examples/todoapp/tasks/TaskListScreen.kt b/samples/todoapp/src/main/java/com/examples/todoapp/tasks/TaskListScreen.kt new file mode 100644 index 0000000..0bce9ff --- /dev/null +++ b/samples/todoapp/src/main/java/com/examples/todoapp/tasks/TaskListScreen.kt @@ -0,0 +1,94 @@ +package com.examples.todoapp.tasks + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Checkbox +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.runtime.Composable +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 androidx.compose.ui.unit.dp +import com.instacart.formula.invoke + +@Composable +fun TaskListScreen(model: TaskListRenderModel) { + MaterialTheme { + Surface { + Scaffold( + topBar = { TaskListAppBar(model.filterOptions) }, + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + items(items = model.items, key = { it.id }) { item -> + TaskItemRow(item) + } + } + } + } + } +} + +@Composable +private fun TaskListAppBar(filters: List) { + var menuExpanded by remember { mutableStateOf(false) } + TopAppBar( + title = { Text("Tasks") }, + actions = { + IconButton(onClick = { menuExpanded = true }) { + Text("Filter") + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + ) { + filters.forEach { filter -> + DropdownMenuItem(onClick = { + menuExpanded = false + filter.onSelected.invoke() + }) { + Text(filter.title) + } + } + } + }, + ) +} + +@Composable +private fun TaskItemRow(item: TaskItemRenderModel) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { item.onClick.invoke() } + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = item.isSelected, + onCheckedChange = { item.onToggle.invoke() }, + ) + Text( + text = item.text, + modifier = Modifier.padding(start = 8.dp), + ) + } +} diff --git a/samples/todoapp/src/main/res/layout/task_item.xml b/samples/todoapp/src/main/res/layout/task_item.xml deleted file mode 100644 index 7edd844..0000000 --- a/samples/todoapp/src/main/res/layout/task_item.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - diff --git a/samples/todoapp/src/main/res/layout/task_list.xml b/samples/todoapp/src/main/res/layout/task_list.xml deleted file mode 100644 index 1c5d187..0000000 --- a/samples/todoapp/src/main/res/layout/task_list.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - diff --git a/samples/todoapp/src/main/res/menu/tasks_list_menu.xml b/samples/todoapp/src/main/res/menu/tasks_list_menu.xml deleted file mode 100644 index 258c2b0..0000000 --- a/samples/todoapp/src/main/res/menu/tasks_list_menu.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - diff --git a/settings.gradle.kts b/settings.gradle.kts index 139911d..3d690c3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,7 +1,6 @@ include( ":formula", ":formula-android", - ":formula-android-compose", ":formula-android-tests", ":formula-r8-tests", ":formula-lint", diff --git a/test-utils/android/build.gradle.kts b/test-utils/android/build.gradle.kts index 8ca214d..f6ea631 100644 --- a/test-utils/android/build.gradle.kts +++ b/test-utils/android/build.gradle.kts @@ -6,6 +6,14 @@ plugins { android { namespace = "com.instacart.testutils.android" + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() + } } dependencies { diff --git a/test-utils/android/src/main/java/com/instacart/testutils/android/TestViewFactory.kt b/test-utils/android/src/main/java/com/instacart/testutils/android/TestViewFactory.kt index e42cfa9..6914318 100644 --- a/test-utils/android/src/main/java/com/instacart/testutils/android/TestViewFactory.kt +++ b/test-utils/android/src/main/java/com/instacart/testutils/android/TestViewFactory.kt @@ -1,16 +1,20 @@ package com.instacart.testutils.android -import android.view.View import com.instacart.formula.android.FeatureView -import com.instacart.formula.android.LayoutViewFactory -import com.instacart.formula.android.ViewInstance +import com.instacart.formula.android.ViewFactory +/** + * Test [ViewFactory] that records each render call without producing UI. + * The [onRender] callback is invoked from a `@Composable` body, so it fires + * on each non-skipped recomposition (Compose may skip recompositions when the + * input model is structurally equal to the previous one). + */ class TestViewFactory( - private val render: (View, RenderModel) -> Unit = { _, _ -> }, -) : LayoutViewFactory(R.layout.test_fragment_layout) { - override fun ViewInstance.create(): FeatureView { - return featureView { - render(view, it) - } + private val onRender: (RenderModel) -> Unit = {}, +) : ViewFactory { + override fun create(params: ViewFactory.Params): FeatureView { + return FeatureView( + content = { model -> onRender(model) }, + ) } -} \ No newline at end of file +} diff --git a/test-utils/android/src/main/res/layout/test_fragment_layout.xml b/test-utils/android/src/main/res/layout/test_fragment_layout.xml deleted file mode 100644 index 34167a9..0000000 --- a/test-utils/android/src/main/res/layout/test_fragment_layout.xml +++ /dev/null @@ -1,6 +0,0 @@ - -