Ivy FRP
Ivy FRP is a Functional Reactive Programming framework for declarative-style programming for Android.
Minimalistic and light-weight implementation of the Ivy FRP Architecture.
Recommendation: Use it alongside Jetpack Compose.
Demo
☁️
☀️
☔
Imaginary Weather app TL;DR: You'll find the code of the entire weather app below. If you're already sold to use Ivy FRP => skip to Installation.
Data (boring)
data class Temperature(
val value: Float,
val unit: TemperatureUnit
)
enum class TemperatureUnit {
CELSIUS, FAHRENHEIT
}
data class UserPreference(
val temperatureUnit: TemperatureUnit
)
IO (boring)
data class WeatherResponse(
val temperature: Temperature
)
interface WeatherService {
@GET("/api/weather")
suspend fun getWeather(): WeatherResponse
}
interface UserPreferenceService {
@GET("/preference/temp-unit")
suspend fun getTempUnit(): UserPreference
@POST("/preference/temp-unit")
suspend fun updateTempUnit(
@Query("unit") temperatureUnit: TemperatureUnit
)
}
Actions
ConvertTempAct.kt
class ConvertTempAct @Inject constructor() : FPAction() {
override suspend fun Input.compose(): suspend () -> Temperature =
this asParamTo ::convertValue then { convertedValue ->
Temperature(convertedValue, toUnit)
}
private fun convertValue(input: Input): Float = with(input.temperature) {
if (unit == input.toUnit) value else {
when (input.toUnit) {
TemperatureUnit.CELSIUS -> fahrenheitToCelsius(value)
TemperatureUnit.FAHRENHEIT -> celsiusToFahrenheit(value)
}
}
}
//X°F = (Y°C × 9/5) + 32
private fun celsiusToFahrenheit(celsius: Float): Float = (celsius * 9 / 5) + 32
//X°C = (Y°F - 32) / (9/5)
private fun fahrenheitToCelsius(fahrenheit: Float): Float = (fahrenheit - 32) / (9 / 5)
data class Input(
val temperature: Temperature,
val toUnit: TemperatureUnit
)
}
CurrentTempAct.kt
class CurrentTempAct @Inject constructor(
private val weatherService: WeatherService,
private val convertTempAct: ConvertTempAct
) : FPAction>() {
override suspend fun TemperatureUnit.compose(): suspend () -> Res<String, Temperature> = tryOp(
operation = weatherService::getWeather
) mapSuccess { response ->
ConvertTempAct.Input(
temperature = response.temperature,
toUnit = this //TemperatureUnit
)
} mapSuccess convertTempAct mapError {
"Failed to fetch weather: ${it.message}"
}
}
UserPreferencesAct.kt
class UserPreferenceAct @Inject constructor(
private val userPreferenceService: UserPreferenceService
) : FPAction>() {
override suspend fun Unit.compose(): suspend () -> Res<String, UserPreference> = tryOp(
operation = userPreferenceService::getTempUnit
) mapError {
"Failed to fetch user's preference: ${it.message}"
}
}
UpdateUserPreferencesAct.kt
class UpdateUserPreferenceAct @Inject constructor(
private val userPreferenceService: UserPreferenceService
) : FPAction>() {
override suspend fun TemperatureUnit.compose(): suspend () -> Res<String, Unit> = tryOp(
operation = this asParamTo userPreferenceService::updateTempUnit
) mapError {
"Failed to update user preference: ${it.message}"
}
}
ViewModel
WeatherState.kt
sealed class WeatherState {
object Loading : WeatherState()
data class Error(val errReason: String) : WeatherState()
data class Success(
val tempUnit: TemperatureUnit,
val temp: Float
) : WeatherState()
}
WeatherEvent.kt
sealed class WeatherEvent {
object LoadWeather : WeatherEvent()
data class UpdateTempUnit(val unit: TemperatureUnit) : WeatherEvent()
}
WeatherViewModel.kt
@HiltViewModel
class WeatherViewModel @Inject constructor(
private val userPreferenceAct: UserPreferenceAct,
private val updateUserPreferenceAct: UpdateUserPreferenceAct,
private val currentTempAct: CurrentTempAct,
) : FRPViewModel() {
override val _state: MutableStateFlow<WeatherState> = MutableStateFlow(WeatherState.Loading)
override suspend fun handleEvent(event: WeatherEvent): suspend () -> WeatherState =
when (event) {
is WeatherEvent.LoadWeather -> loadWeather()
is WeatherEvent.UpdateTempUnit -> updateTempUnit(event)
}
private suspend fun loadWeather(): suspend () -> WeatherState = suspend {
updateState { WeatherState.Loading }
Unit
} then userPreferenceAct mapSuccess {
it.temperatureUnit //loadTempFor() expects TemperatureUnit
} then ::loadTempFor
private suspend fun updateTempUnit(
event: WeatherEvent.UpdateTempUnit
): suspend () -> WeatherState = suspend {
updateState { WeatherState.Loading }
event.unit
} then updateUserPreferenceAct mapSuccess {
event.unit
} then ::loadTempFor
private suspend fun loadTempFor(tempRes: Res<String, TemperatureUnit>) =
tempRes.lambda() thenIfSuccess currentTempAct thenInvokeAfter {
when (it) {
is Res.Ok -> WeatherState.Success(
temp = it.data.value,
tempUnit = it.data.unit
)
is Res.Err -> WeatherState.Error(errReason = it.error)
}
}
}
UI
WeatherScreen.kt
data class WeatherScreen(
val title: String
) : Screen
@Composable
fun BoxWithConstraintsScope.WeatherScreen(screen: WeatherScreen) {
FRP<WeatherState, WeatherEvent, WeatherViewModel>(
initialEvent = WeatherEvent.LoadWeather
) { state, onEvent ->
UI(title = screen.title, state = state, onEvent = onEvent)
}
}
@Composable
private fun UI(
title: String,
state: WeatherState,
onEvent: (WeatherEvent) -> Unit
) {
//UI goes here.....
//When Celsius is clicked:
// onEvent(WeatherEvent.UpdateTempUnit(TemperatureUnit.CELSIUS))
}
Find the full sample here.
Key Features
-
Function composition using
then
. (supporting suspend functions, `Action & FPAction) -
FPAction
: declaritive-style "use-case" which can be composed. -
FRPViewModel
: functional-reactive ViewModel implementation, see Ivy FRP Architecture . -
@Composable FRP
: functional-reactive UI implementation in Jetpack Compose.(){ UI() } -
Res.Ok / Res.Err
result type: monadic result type supporting success and error composition.thenIfSuccess
: calls the next function only on success (OK)mapSuccess
: maps only the success type if the result is OKmapError
: maps only the error type if the result is Err
-
(optional)
NavigationRoot
+Navigation
: navigation component for Jetpack Compose-
⚠️ to use it with proper back handling you must override onBackPressed()
-
if you use Navigation
:
//required only for "NavigationRoot" and "Navigation"
override fun onBackPressed() {
if (!navigation.onBackPressed()) {
super.onBackPressed()
}
}
Installation
Gradle KTS
maven(url = "https://jitpack.io")
to repositories.
1. Add settings.gradle.kts (at the top)
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
maven(url = "https://jitpack.io")
}
}
//...
build.gradle.kts (buildSrc) (right below "plugins")
plugins {
`kotlin-dsl`
}
repositories {
google()
mavenCentral()
maven(url = "https://jitpack.io")
}
//...
com.github.ILIYANGERMANOV:ivy-frp:0:0:0
dependency.
2. Add
build.gradle.kts (app)
implementation("com.githubILIYANGERMANOV:ivy-frp:0.0.0")
Replace 0.0.0 with the latest version from Jitpack.
Gradle
maven(url = "https://jitpack.io")
to repositories.
1. Add com.github.ILIYANGERMANOV:ivy-frp:0:0:0
dependency.
2. Add
build.gradle.kts (app)
implementation 'com.githubILIYANGERMANOV:ivy-frp:0.0.0'
Docs
🚧
WIP...
🚧
For samples and usage in a real-world scenario please refer to the Ivy Wallet's GitHub repo.