Formula Android
Formula Android
The Android module provides a declarative API to connect reactive state management to Android Fragments. This module has been designed for gradual adoption. You can use as much or as little of it as you like.
Some of the goals for this module are:
- Use a single reactive stream to drive the UI.
- Separate state management from Android UI lifecycle.
- Ability to group multiple fragments into a flow and share state between them.
- Type-safe and scoped fragment event handling. (Avoid casting activity to a listener)
Using Fragments
This module provides an API to connect state management and view rendering logic to Android
fragments. For this example, we will connect CounterRenderView and CounterFormula from the
main getting started guide.
Define a fragment key
Fragment key is used to instantiate FormulaFragment and to identify which FeatureFactory to
use. You can also use it to add arguments that the fragment instance needs.
/**
* Fragment key has to provide Parcelable implementation because it is passed
* to the fragment as an argument.
*
* Read more about Parcelize: https://kotlinlang.org/docs/tutorials/android-plugin.html
*/
@Parcelize
data class CounterKey(
override val tag: String = "counter"
) : RouteKey
Define a feature factory
A feature factory creates the state observable and a view factory for a fragment. To continue our
example, we define a CounterFeatureFactory which will handle CounterKey fragments.
class CounterFeatureFactory : FeatureFactory<Any, CounterKey>() {
override fun Params.initialize(): Feature {
val counterFormula = CounterFormula()
return Feature(CounterViewFactory()) { scope ->
counterFormula.runAsStateFlow(scope, input = Unit)
}
}
}
// 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)
}
}
We now need to register our feature factory with the activity in which the counter will be shown.
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
FormulaAndroid.init(this) {
activity(MyActivity::class) {
store {
bind(CounterFeatureFactory())
}
}
}
}
}
Use formula fragment
The only thing left is navigating to this screen. We create FormulaFragment instance using
our CounterKey and use fragment transactions to add it.
class MyActivity : FormulaAppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.my_activity)
if (savedInstanceState == null) {
val key = CounterKey()
val fragment = FormulaFragment.newInstance(key)
// Add the fragment using the fragment transaction API.
supportFragmentManager.beginTransaction()
.add(R.id.activity_content, fragment, key.tag)
.commit()
}
}
}
If your Activity has another base class, you can just copy logic from FormulaAppCompatActivity into your Activity.
And that's it
Formula takes care of the rest. This is how the state observable works:
- When
FormulaFragmentis added, we instantiate and subscribe to the state observable. - When
FormulaFragmentis removed, we destroy the state observable.
The state management observable continues to run during configuration changes or if you navigate to another fragment.
Passing arguments to a fragment
Arguments can be passed using fragment key class. For example, we want to pass initial count
value to the CounterFormula used in the previous examples. To accomplish that, let's update the
CounterKey.
@Parcelize
data class CounterKey(
val initialCount: Int = 0,
override val tag: String = "counter"
) : RouteKey
You can access the CounterKey within CounterFeatureFactory
class CounterFeatureFactory : FeatureFactory<Any, CounterKey>() {
override fun Params.initialize(): Feature {
val initialCount = key.initialCount
val counterFormula = CounterFormula(initialCount)
return Feature(CounterViewFactory()) { scope ->
counterFormula.runAsStateFlow(scope, input = Unit)
}
}
}
Fragment Event Handling
Very frequently we need to pass events from a fragment to the parent/activity which trigger things like navigation.
Let's say we want to add the following behaviors to the previous counter example:
- show a toast notification when user increments to 10
- navigate to a new "victory" screen when user increments to 100.
First, let's define a class that defines our events.
data class CounterEventRouter(
val onToastNotification: (String) -> Unit,
val onVictoryReached: () -> Unit
)
We can now request this dependency within our feature factory
class CounterFeatureFactory : FeatureFactory<Dependencies, CounterKey>() {
// We can ask for dependencies from the parent using an interface.
interface Dependencies {
fun counterEventRouter(): CounterEventRouter
}
override fun Params.initialize(): Feature {
val counterEventRouter = dependencies.counterEventRouter()
// We can pass the event router to the counter formula.
val counterFormula = CounterFormula(counterEventRouter)
return Feature(CounterViewFactory()) { scope ->
counterFormula.runAsStateFlow(scope, input = Unit)
}
}
}
To provide dependencies, the parent component needs to extend CounterFeatureFactory.Dependencies
class MyActivityComponent(
private val store: ActivityStoreContext<MyActivity>
) : CounterFeatureFactory.Dependencies {
override fun counterEventRouter(): CounterEventRouter {
return CounterEventRouter(
onToastNotification = this::showToast,
onVictoryReached = {
// VictoryRouteKey implementation is left to readers imagination.
val key = VictoryRouteKey()
navigateTo(key)
}
)
}
private fun showToast(message: String) {
store.send {
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
}
}
private fun navigateTo(key: RouteKey) {
store.send {
// Sample fragment transaction
val fragment = FormulaFragment.newInstance(key)
supportFragmentManager.beginTransaction()
.add(R.id.activity_content, fragment, key.tag)
.addToBackStack(null)
.commit()
}
}
}
To pass this component to feature factories, we need to update the configuration that lives
within our Application.
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
FormulaAndroid.init(this) {
activity(MyActivity::class) {
ActivityStore(
fragmentStore = FragmentStore.init(MyActivityComponent(this)) {
bind(CounterFeatureFactory())
}
)
}
}
}
}
Handling back button events
To override how the back button works for a particular navigation destination, your output needs to implement
BackCallback interface.
data class FormOutput(
private val confirmBeforeExiting: Boolean,
private val confirmUserWantsToExit: () -> Unit
): BackCallback {
fun onBackPressed(): Boolean {
// Check if we need to override back handling
if (confirmBeforeExiting) {
confirmUserWantsToExit()
return true
}
// Use default behavior (which closes the screen)
return false
}
}
Your Activity needs to call FormulaAndroid.onBackPressed(). It will check if your current screen
implements BackCallback and will invoke it.
class MyActivity : FragmentActivity() {
override fun onBackPressed() {
if (!FormulaAndroid.onBackPressed(this)) {
super.onBackPressed()
}
}
}
FormulaAppCompatActivity.
Managing dependencies
Managing dependencies in Formula is very easy. In the function that instantiates the ActivityStore for your activity,
you can create your activity specific dependencies or Dagger components. These objects will survive configuration changes.
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
val appComponent: AppComponent = ...
FormulaAndroid.init(this) {
activity(MyActivity::class) {
// This component will survive configuration changes.
val activityComponent = appComponent.createMyActivityComponent()
store { }
}
}
}
}
To inject the activity or create activity dependencies that don't survive configuration changes such as ones that need direct
activity reference, you can use configureActivity callback.
val appComponent: AppComponent = ...
FormulaAndroid.init(this) {
activity(MyActivity::class) {
// This component will survive configuration changes.
val activityComponent = appComponent.createMyActivityComponent()
ActivityStore(
configureActivity = {
// in this callback `this` is the instance of MyActivity
// so we can use it to inject dependencies
activityComponent.inject(this)
// Or you can use setters to provide dependencies to your activity.
// This dependency object won't survive configuration changes.
val dependency = MyActivityDependency(activity = this)
this.setDependency(dependency)
}
)
}
}