Building a State Management Wrapper for Android Using Koin, and Jetpack Compose [2024]
In this article, I describe StateWrapper — a solution for state management in Android applications. StateWrapper allows handling different states such as data loading, successful operation completion, and errors, with screen updates.
1. Defining Library Versions
To manage dependencies effectively, we use a centralized approach for versioning and grouping libraries:
[versions]
lifecycleRuntimeKtx = "2.8.7"
koin = "4.0.0"
[libraries]
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
koin-core = { group = "io.insert-koin", name = "koin-core", version.ref = "koin" }
koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" }
[bundles]
koin-bundle = [
"koin-core",
"koin-androidx-compose",
]
2. Adding Dependencies to the Application Module
In the build.gradle.kts
file for the app module, add the necessary dependencies:
dependencies {
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.bundles.koin.bundle)
}
3. Introducing the StateWrapper
Sealed Interface
The StateWrapper
interface is used to encapsulate different states of data, such as loading, success, or failure:
sealed interface StateWrapper<out T, out E> {
data object Loading : StateWrapper<Nothing, Nothing>
data class Success<T>(val data: T) : StateWrapper<T, Nothing>
data class Failure<E>(val error: E) : StateWrapper<Nothing, E>
}
fun <T, E> StateWrapper<T, E>.getData(): T = (this as StateWrapper.Success).data
fun <E> StateWrapper<*, E>.getErrorOrNull(): E? =
(this as? StateWrapper.Failure<E>)?.error
4. Repository Interface and Implementation
Define the repository and its implementation to provide data flow:
interface IRepository {
fun loadData(): Flow<StateWrapper<String, Any>>
}
class RepositoryImpl : IRepository {
override fun loadData(): Flow<StateWrapper<String, Any>> = flow {
emit(StateWrapper.Loading)
try {
emit(StateWrapper.Success("Data"))
} catch (e: Exception) {
emit(StateWrapper.Failure())
}
}
}
5. State Class to Represent the App’s State
The AppState
class models the app's state, including loading, success, and error scenarios:
data class AppState(
val isSuccess: Boolean = false,
val isLoading: Boolean = false,
val error: Pair<Boolean, String?> = false to null,
val data: String = ""
)
6. Event Sealed Interface
The AppEvent
sealed interface defines app events:
sealed interface AppEvent {
data object LoadData : AppEvent
}
7. ViewModel Implementation
The AppViewModel
processes events and updates state based on repository responses:
class AppViewModel(
private val repository: IRepository
) : ViewModel() {
private val _state = MutableStateFlow(AppState())
val state = _state.asStateFlow()
init {
onEvent(AppEvent.LoadData)
}
private fun onEvent(event: AppEvent) {
when (event) {
AppEvent.LoadData -> {
viewModelScope.launch {
repository.loadData().collect { stateWrapper ->
when (stateWrapper) {
StateWrapper.Loading -> {
_state.update {
it.copy(isLoading = true)
}
}
is StateWrapper.Failure -> {
_state.update {
it.copy(
error = Pair(
true,
stateWrapper.getErrorOrNull().toString()
)
)
}
}
is StateWrapper.Success -> {
_state.update {
it.copy(
isLoading = false,
isSuccess = true,
data = stateWrapper.getData()
)
}
/**
* Simulate showing an animation
* */
delay(2000)
/**
* Reset the success state after the animation
* */
_state.update {
it.copy(isSuccess = false)
}
}
}
}
}
}
}
}
}
8. Koin Module Configuration
Define a Koin module for dependency injection:
val appModule = module {
singleOf(::RepositoryImpl) { bind<IRepository>() }
viewModelOf(::AppViewModel)
}
9. Application Class Setup
Integrate Koin into the application lifecycle:
class App : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@App)
androidLogger(Level.DEBUG)
modules(appModule)
}
}
}
10. Register the Application Class in AndroidManifest.xml
<application
android:name=".app.App"
... />
11. Enum for Screen States
Create an enum for managing screen transitions:
enum class CrossFade {
SUCCESS,
ERROR,
LOADING,
CONTENT
}
12. Composable Functions for UI States
Define reusable composable functions for each state:
//Loading
@Composable
internal fun LoadingScreen() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
CircularProgressIndicator()
Text(text = "Loading...")
}
}
}
//Error
@Composable
internal fun ErrorScreen(
isError: Pair<Boolean, String?>
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = isError.second.orEmpty())
}
}
}
//Success
@Composable
internal fun SuccessScreen() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = "Data loaded successfully")
}
}
}
//Content
@Composable
internal fun ContentScreen(
data: String
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = data)
}
}
}
13. MainActivity Implementation
Set up the UI with Crossfade
for state transitions:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate()
enableEdgeToEdge()
setContent {
val appViewModel = koinViewModel<AppViewModel>()
KoinAndroidContext {
StateWrapperComposeTheme {
val state by appViewModel.state.collectAsStateWithLifecycle()
val isLoading = state.isLoading
val isError = state.error
val isSuccess = state.isSuccess
val data = state.data
Crossfade(
targetState = when {
isError.first -> CrossFade.ERROR
isLoading -> CrossFade.LOADING
isSuccess -> CrossFade.SUCCESS
else -> CrossFade.CONTENT
},
label = ""
) { screenState ->
when (screenState) {
CrossFade.LOADING -> LoadingScreen()
CrossFade.CONTENT -> ContentScreen(data)
CrossFade.SUCCESS -> SuccessScreen()
CrossFade.ERROR -> ErrorScreen(isError = isError)
}
}
}
}
}
}
}
Next steps
We add a delay(3000)
to simulate a long loading process,
class RepositoryImpl : IRepository {
override fun loadData(): Flow<StateWrapper<String, Any>> = flow {
emit(StateWrapper.Loading)
delay(3000) // add delay to simulate loading
try {
emit(StateWrapper.Success("Data"))
} catch (e: Exception) {
emit(StateWrapper.Failure())
}
}
}
allowing us to display the Loading
state on the screen. The result will look like this:
Adding Error Simulation in the Repository
In this step, we introduce an artificial error to simulate a failure during data loading. To do this, we throw an exception in the loadData
method using throw IllegalStateException("Error")
. When the error occurs, we catch it in a catch
block and pass the corresponding state to the data flow, allowing the UI to react to the error:
class RepositoryImpl : IRepository {
override fun loadData(): Flow<StateWrapper<String, Any>> = flow {
emit(StateWrapper.Loading)
try {
throw IllegalStateException("Error")// Simulate an error
emit(StateWrapper.Success("Data"))
} catch (e: Exception) {
emit(StateWrapper.Failure(e))
}
}
}
The result will look like this:
On success, we will see the success screen:
And finally, the screen with our content:
Now you have an understanding of how to use StateWrapper
for handling different states in your application, such as loading, error, and success. This architecture helps you manage state centrally and provide a better user experience. I hope you can apply these approaches in your own projects to improve data handling and create more stable applications. Don't forget to experiment and adapt this approach to your specific needs!
Thank you for reading! You can find the full code on GitHub. 😊