Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 5 additions & 8 deletions docs/Formula-Android.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,11 @@ class CounterFeatureFactory : FeatureFactory<Any, CounterKey>() {
}
}

// View factory which uses XML layout resource.
class CounterViewFactory : LayoutViewFactory<CounterOutput>(R.layout.counter) {
override fun ViewInstance.create(): FeatureView<CounterOutput> {
// 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<CounterOutput>() {
@Composable
override fun Content(model: CounterOutput) {
CounterScreen(model)
}
}
```
Expand Down
1 change: 0 additions & 1 deletion formula-android-compose/.gitignore

This file was deleted.

38 changes: 0 additions & 38 deletions formula-android-compose/build.gradle.kts

This file was deleted.

5 changes: 0 additions & 5 deletions formula-android-compose/gradle.properties

This file was deleted.

1 change: 0 additions & 1 deletion formula-android-compose/src/main/AndroidManifest.xml

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Any> = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class TestFeatureFactory<Key : RouteKey>(
) : FeatureFactory<Unit, Key>() {
override fun Params.initialize(): Feature {
return Feature(
viewFactory = TestViewFactory { _, value ->
viewFactory = TestViewFactory { value ->
render(key, value)
}
) {
Expand Down
11 changes: 9 additions & 2 deletions formula-android/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import org.jetbrains.dokka.gradle.DokkaTask

plugins {
id("com.android.library")
id("kotlin-android")
Expand All @@ -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
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<MyRenderModel>() {
* @Composable
* override fun Content(model: MyRenderModel) {
* MyScreen(model)
* }
* }
* ```
*/
abstract class ComposeViewFactory<RenderModel : Any> : ViewFactory<RenderModel> {

final override fun create(params: ViewFactory.Params): FeatureView<RenderModel> {
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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<TaskListRenderModel>() {
* @Composable
* override fun Content(model: TaskListRenderModel) {
* TaskListScreen(model)
* }
* },
* ) {
* formula.runAsStateFlow(it, dependencies.taskListInput())
* }
Expand Down
Original file line number Diff line number Diff line change
@@ -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<RenderModel>(
val view: View,
val setOutput: (RenderModel) -> Unit,
val content: @Composable (RenderModel) -> Unit,
val initialModel: RenderModel? = null,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -37,27 +41,26 @@ class FormulaFragment : Fragment() {
private val routeDelegate: RouteEnvironment.RouteDelegate
get() = environment.routeDelegate

private var featureView: FeatureView<Any>? = null
private var outputState: MutableState<Any?>? = 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?) {
Expand All @@ -67,7 +70,7 @@ class FormulaFragment : Fragment() {

override fun onDestroyView() {
super.onDestroyView()
featureView = null
outputState = null
}

fun setState(state: Any) {
Expand All @@ -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)
}
Expand Down

This file was deleted.

Loading