├── .gitignore ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── klitsie │ │ └── dataloading │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── klitsie │ │ │ └── dataloading │ │ │ ├── DataLoader.kt │ │ │ ├── LoadingResult.kt │ │ │ ├── MainActivity.kt │ │ │ ├── example │ │ │ ├── ExampleDataLoader.kt │ │ │ ├── ExampleDataMapper.kt │ │ │ ├── ExampleScreen.kt │ │ │ └── ExampleViewModel.kt │ │ │ ├── ui │ │ │ └── theme │ │ │ │ ├── Color.kt │ │ │ │ ├── Theme.kt │ │ │ │ └── Type.kt │ │ │ └── usecase │ │ │ ├── FetchIntFromMemoryUseCase.kt │ │ │ ├── FetchIntUseCase.kt │ │ │ └── ObserveIntUseCase.kt │ └── res │ │ ├── drawable │ │ ├── ic_launcher_background.xml │ │ └── ic_launcher_foreground.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ └── xml │ │ ├── backup_rules.xml │ │ └── data_extraction_rules.xml │ └── test │ └── java │ └── com │ └── klitsie │ └── dataloading │ └── ExampleUnitTest.kt ├── build.gradle.kts ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle.kts /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is example code to showcase a reactive aproach to load data into a View Model. 2 | 3 | The full article can be found on [Medium](https://medium.com/@joostklitsie/the-best-way-to-load-data-in-viewmodels-a112ced54e07) 4 | 5 | If you run the project, you will see a screen that can: 6 | 7 | 1. Load data from memory directly if it is there 8 | 2. Load data from another source if data is not available in memory 9 | 3. Offer retry mechanism if data fails to load 10 | 4. After data successfully is loaded, updates to the data will populate the screen 11 | 5. You can refresh data by pushing a button 12 | 6. If refreshing data fails, you are informed. 13 | 14 | In the example project loading of data is randomized, giving you a 50/50 chance of successfully loading data. 15 | In the following GIF's you will see the behavior described above: 16 | 17 | ![loaded_at_start-ezgif com-video-to-gif-converter](https://github.com/user-attachments/assets/1c738784-915e-4a6c-8a59-3b3b6e82c39c) ![loading_success-ezgif com-video-to-gif-converter](https://github.com/user-attachments/assets/c59df5a1-82cf-404d-ab9d-4d4703e68b7f) ![loading_with_failure-ezgif com-video-to-gif-converter](https://github.com/user-attachments/assets/04669360-2a79-4097-b8a8-d241549c22af) 18 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.android.application) 3 | alias(libs.plugins.jetbrains.kotlin.android) 4 | alias(libs.plugins.compose.compiler) 5 | } 6 | 7 | android { 8 | namespace = "com.klitsie.dataloading" 9 | compileSdk = 34 10 | 11 | defaultConfig { 12 | applicationId = "com.klitsie.dataloading" 13 | minSdk = 24 14 | targetSdk = 34 15 | versionCode = 1 16 | versionName = "1.0" 17 | 18 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 19 | vectorDrawables { 20 | useSupportLibrary = true 21 | } 22 | } 23 | 24 | buildTypes { 25 | release { 26 | isMinifyEnabled = false 27 | proguardFiles( 28 | getDefaultProguardFile("proguard-android-optimize.txt"), 29 | "proguard-rules.pro" 30 | ) 31 | } 32 | } 33 | compileOptions { 34 | sourceCompatibility = JavaVersion.VERSION_21 35 | targetCompatibility = JavaVersion.VERSION_21 36 | } 37 | kotlinOptions { 38 | freeCompilerArgs += "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi" 39 | jvmTarget = "21" 40 | } 41 | buildFeatures { 42 | compose = true 43 | } 44 | packaging { 45 | resources { 46 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 47 | } 48 | } 49 | } 50 | 51 | dependencies { 52 | 53 | implementation(libs.androidx.core.ktx) 54 | implementation(libs.androidx.lifecycle.runtime.ktx) 55 | implementation(libs.androidx.lifecycle.viewmodel.compose) 56 | implementation(libs.androidx.activity.compose) 57 | implementation(platform(libs.androidx.compose.bom)) 58 | implementation(libs.androidx.ui) 59 | implementation(libs.androidx.ui.graphics) 60 | implementation(libs.androidx.ui.tooling.preview) 61 | implementation(libs.androidx.material3) 62 | testImplementation(libs.junit) 63 | androidTestImplementation(libs.androidx.junit) 64 | androidTestImplementation(libs.androidx.espresso.core) 65 | androidTestImplementation(platform(libs.androidx.compose.bom)) 66 | androidTestImplementation(libs.androidx.ui.test.junit4) 67 | debugImplementation(libs.androidx.ui.tooling) 68 | debugImplementation(libs.androidx.ui.test.manifest) 69 | 70 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/androidTest/java/com/klitsie/dataloading/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.klitsie.dataloading 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("com.klitsie.dataloading", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 15 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /app/src/main/java/com/klitsie/dataloading/DataLoader.kt: -------------------------------------------------------------------------------- 1 | package com.klitsie.dataloading 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.flow.Flow 5 | import kotlinx.coroutines.flow.MutableSharedFlow 6 | import kotlinx.coroutines.flow.SharingStarted 7 | import kotlinx.coroutines.flow.StateFlow 8 | import kotlinx.coroutines.flow.asSharedFlow 9 | import kotlinx.coroutines.flow.distinctUntilChanged 10 | import kotlinx.coroutines.flow.emitAll 11 | import kotlinx.coroutines.flow.emptyFlow 12 | import kotlinx.coroutines.flow.flatMapLatest 13 | import kotlinx.coroutines.flow.flow 14 | import kotlinx.coroutines.flow.map 15 | import kotlinx.coroutines.flow.onEach 16 | import kotlinx.coroutines.flow.stateIn 17 | import kotlin.Result.Companion.failure 18 | import kotlin.Result.Companion.success 19 | 20 | /** 21 | * A data loader that can be used to load and observe data. 22 | */ 23 | sealed interface DataLoader { 24 | 25 | /** 26 | * Returns a StateFlow containing a LoadingResult object. Every invocation to this function will return a new StateFlow. 27 | * The data loader will automatically refresh the data at the start if initialData.isLoading() returns true, or when 28 | * the [refreshTrigger] is triggered. 29 | * 30 | * This function will also handle the case where the refresh of data fails. If the previous data was successfully loaded, 31 | * that data will be kept and the onRefreshFailure callback will be called instead. Note that the onRefreshFailure callback 32 | * is not called when the previous state was not successful, as in that case the result state will contain the failure. 33 | * 34 | * @param coroutineScope The coroutine scope to use for the data loading and observing. 35 | * @param refreshTrigger The trigger that can be used to refresh the data. 36 | * @param initialData The initial data to use. If initialData.isLoading() returns true, fetchData will be called. 37 | * @param observeData The result of this will be observed when the data is successfully loaded. 38 | * @param onRefreshFailure This will be called when a refresh of data fails, only when the previous data was successfully loaded. 39 | * @param fetchData The function to fetch the data. 40 | * 41 | * @return The state flow that will emit the data. 42 | */ 43 | fun loadAndObserveDataAsState( 44 | coroutineScope: CoroutineScope, 45 | refreshTrigger: RefreshTrigger? = null, 46 | initialData: LoadingResult = loading(), 47 | observeData: (T) -> Flow = { emptyFlow() }, 48 | fetchData: suspend (LoadingResult) -> Result, 49 | onRefreshFailure: (Throwable) -> Unit, 50 | ): StateFlow> = loadAndObserveData( 51 | refreshTrigger = refreshTrigger, 52 | initialData = initialData, 53 | observeData = observeData, 54 | fetchData = fetchData, 55 | onRefreshFailure = onRefreshFailure, 56 | ).stateIn( 57 | scope = coroutineScope, 58 | started = SharingStarted.WhileSubscribed(), 59 | initialValue = initialData, 60 | ) 61 | 62 | /** 63 | * Returns a StateFlow containing a LoadingResult object. Every invocation to this function will return a new StateFlow. 64 | * The data loader will automatically refresh the data at the start if initialData.isLoading() returns true, or when 65 | * the [refreshTrigger] is triggered. 66 | * 67 | * This function will also handle the case where the refresh of data fails. If the previous data was successfully loaded, 68 | * that data will be kept and the onRefreshFailure callback will be called instead. Note that the onRefreshFailure callback 69 | * is not called when the previous state was not successful, as in that case the result state will contain the failure. 70 | * 71 | * @param refreshTrigger The trigger that can be used to refresh the data. 72 | * @param initialData The initial data to use. If initialData.isLoading() returns true, fetchData will be called. 73 | * @param observeData The result of this will be observed when the data is successfully loaded. 74 | * @param onRefreshFailure This will be called when a refresh of data fails, only when the previous data was successfully loaded. 75 | * @param fetchData The function to fetch the data. 76 | * 77 | * @return The flow that will emit the data. 78 | */ 79 | fun loadAndObserveData( 80 | refreshTrigger: RefreshTrigger? = null, 81 | initialData: LoadingResult = loading(), 82 | observeData: (T) -> Flow = { emptyFlow() }, 83 | fetchData: suspend (LoadingResult) -> Result, 84 | onRefreshFailure: (Throwable) -> Unit, 85 | ): Flow> = loadAndObserveData( 86 | refreshTrigger = refreshTrigger, 87 | initialData = initialData, 88 | observeData = observeData, 89 | fetchData = { oldValue: LoadingResult -> 90 | // Try to reuse old value if the new value is a failure. 91 | fetchData(oldValue).fold( 92 | onSuccess = { success(it) }, 93 | onFailure = { exception -> 94 | if (oldValue is LoadingResult.Success) { 95 | // If we successfully recover the old value, we call the onRefreshFailure callback 96 | onRefreshFailure(exception) 97 | success(oldValue.value) 98 | } else { 99 | failure(exception) 100 | } 101 | } 102 | ) 103 | }, 104 | ) 105 | 106 | /** 107 | * Returns a StateFlow containing a LoadingResult object. Every invocation to this function will return a new StateFlow. 108 | * The data loader will automatically refresh the data at the start if initialData.isLoading() returns true, or when 109 | * the [refreshTrigger] is triggered. 110 | * 111 | * @param coroutineScope The coroutine scope to use for the data loading and observing. 112 | * @param refreshTrigger The trigger that can be used to refresh the data. 113 | * @param initialData The initial data to use. If initialData.isLoading() returns true, fetchData will be called. 114 | * @param observeData The result of this will be observed when the data is successfully loaded. 115 | * @param fetchData The function to fetch the data. 116 | * 117 | * @return The state flow that will emit the data. 118 | */ 119 | fun loadAndObserveDataAsState( 120 | coroutineScope: CoroutineScope, 121 | refreshTrigger: RefreshTrigger? = null, 122 | initialData: LoadingResult = loading(), 123 | observeData: (T) -> Flow = { emptyFlow() }, 124 | fetchData: suspend (LoadingResult) -> Result, 125 | ): StateFlow> = loadAndObserveData( 126 | refreshTrigger = refreshTrigger, 127 | initialData = initialData, 128 | observeData = observeData, 129 | fetchData = fetchData, 130 | ).stateIn( 131 | scope = coroutineScope, 132 | started = SharingStarted.WhileSubscribed(), 133 | initialValue = initialData, 134 | ) 135 | 136 | /** 137 | * Returns a StateFlow containing a LoadingResult object. Every invocation to this function will return a new StateFlow. 138 | * The data loader will automatically refresh the data at the start if initialData.isLoading() returns true, or when 139 | * the [refreshTrigger] is triggered. 140 | * 141 | * @param refreshTrigger The trigger that can be used to refresh the data. 142 | * @param initialData The initial data to use. If initialData.isLoading() returns true, fetchData will be called. 143 | * @param observeData The result of this will be observed when the data is successfully loaded. 144 | * @param fetchData The function to fetch the data. 145 | * 146 | * @return The flow that will emit the data. 147 | */ 148 | fun loadAndObserveData( 149 | refreshTrigger: RefreshTrigger? = null, 150 | initialData: LoadingResult = loading(), 151 | observeData: (T) -> Flow = { emptyFlow() }, 152 | fetchData: suspend (LoadingResult) -> Result, 153 | ): Flow> 154 | 155 | } 156 | 157 | fun DataLoader(): DataLoader = DefaultDataLoader() 158 | 159 | sealed interface RefreshTrigger { 160 | 161 | suspend fun refresh() 162 | 163 | } 164 | 165 | fun RefreshTrigger(): RefreshTrigger = DefaultRefreshTrigger() 166 | 167 | private class DefaultDataLoader : DataLoader { 168 | 169 | override fun loadAndObserveData( 170 | refreshTrigger: RefreshTrigger?, 171 | initialData: LoadingResult, 172 | observeData: (T) -> Flow, 173 | fetchData: suspend (LoadingResult) -> Result, 174 | ): Flow> { 175 | val refreshEventFlow = 176 | (refreshTrigger as? DefaultRefreshTrigger)?.refreshEvent ?: emptyFlow() 177 | 178 | // We store the latest emitted value in the lastValue 179 | var lastValue = initialData 180 | 181 | // Emit the initial data and every time the refresh event triggers, we map the last value to a loading state 182 | return flow { 183 | emit(lastValue) 184 | refreshEventFlow.collect { 185 | // Make sure we do not emit if we are already in a loading state 186 | if (!lastValue.isLoading) { 187 | emit(lastValue.toLoading()) 188 | } 189 | } 190 | } 191 | .flatMapLatest { currentResult -> 192 | loadAndObserveData(currentResult, observeData, fetchData) 193 | } 194 | .distinctUntilChanged() 195 | .onEach { 196 | lastValue = it 197 | } 198 | } 199 | 200 | private fun loadAndObserveData( 201 | currentResult: LoadingResult, 202 | observeData: (T) -> Flow, 203 | fetchData: suspend (LoadingResult) -> Result, 204 | ) = flow { 205 | // Little helper method to observe the data and map it to a LoadingResult 206 | val observe: (T) -> Flow> = 207 | { value -> observeData(value).map(::loadingSuccess) } 208 | // Whatever happens, emit the current result 209 | emit(currentResult) 210 | when { 211 | // If the current result is loading, we fetch the data and emit the result 212 | currentResult.isLoading -> { 213 | val newResult = fetchData(currentResult) 214 | emit(newResult.toLoadingResult()) 215 | // If the fetching is successful, we observe the data and emit it 216 | newResult.onSuccess { value -> emitAll(observe(value)) } 217 | } 218 | 219 | // If the current result is successful, we simply observe and emit the data changes 220 | currentResult is LoadingResult.Success -> emitAll(observe(currentResult.value)) 221 | else -> { 222 | // Nothing to do in case of failure and not loading 223 | } 224 | } 225 | } 226 | 227 | } 228 | 229 | private class DefaultRefreshTrigger : RefreshTrigger { 230 | 231 | private val _refreshEvent = MutableSharedFlow() 232 | val refreshEvent = _refreshEvent.asSharedFlow() 233 | 234 | override suspend fun refresh() { 235 | _refreshEvent.emit(Unit) 236 | } 237 | 238 | } 239 | -------------------------------------------------------------------------------- /app/src/main/java/com/klitsie/dataloading/LoadingResult.kt: -------------------------------------------------------------------------------- 1 | package com.klitsie.dataloading 2 | 3 | import androidx.compose.runtime.Immutable 4 | 5 | @Immutable 6 | sealed interface LoadingResult { 7 | val isLoading: Boolean 8 | 9 | data class Success( 10 | val value: T, 11 | override val isLoading: Boolean = false, 12 | ) : LoadingResult 13 | 14 | data class Failure( 15 | val throwable: Throwable, 16 | override val isLoading: Boolean = false, 17 | ) : LoadingResult 18 | 19 | data object Loading: LoadingResult { 20 | override val isLoading: Boolean = true 21 | } 22 | } 23 | 24 | fun loading(): LoadingResult = LoadingResult.Loading 25 | 26 | fun loadingSuccess( 27 | value: T, 28 | ): LoadingResult = LoadingResult.Success(value) 29 | 30 | fun loadingFailure( 31 | throwable: Throwable, 32 | ): LoadingResult = LoadingResult.Failure(throwable) 33 | 34 | fun Result.toLoadingResult() = fold( 35 | onSuccess = { loadingSuccess(it) }, 36 | onFailure = { loadingFailure(it) }, 37 | ) 38 | 39 | fun LoadingResult.map( 40 | block: (T) -> R, 41 | ): LoadingResult = when(this) { 42 | is LoadingResult.Success -> LoadingResult.Success(block(value), isLoading) 43 | is LoadingResult.Failure -> LoadingResult.Failure(throwable, isLoading) 44 | is LoadingResult.Loading -> LoadingResult.Loading 45 | } 46 | 47 | fun LoadingResult.toLoading(): LoadingResult = when(this) { 48 | is LoadingResult.Success -> copy(isLoading = true) 49 | is LoadingResult.Failure -> copy(isLoading = true) 50 | is LoadingResult.Loading -> LoadingResult.Loading 51 | } 52 | -------------------------------------------------------------------------------- /app/src/main/java/com/klitsie/dataloading/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.klitsie.dataloading 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import androidx.activity.enableEdgeToEdge 7 | import androidx.compose.animation.AnimatedContent 8 | import androidx.compose.animation.AnimatedVisibility 9 | import androidx.compose.foundation.layout.Arrangement 10 | import androidx.compose.foundation.layout.Box 11 | import androidx.compose.foundation.layout.Column 12 | import androidx.compose.foundation.layout.fillMaxSize 13 | import androidx.compose.foundation.layout.padding 14 | import androidx.compose.foundation.layout.size 15 | import androidx.compose.material3.Button 16 | import androidx.compose.material3.CircularProgressIndicator 17 | import androidx.compose.material3.LocalContentColor 18 | import androidx.compose.material3.MaterialTheme 19 | import androidx.compose.material3.Scaffold 20 | import androidx.compose.material3.SnackbarHost 21 | import androidx.compose.material3.SnackbarHostState 22 | import androidx.compose.material3.Text 23 | import androidx.compose.runtime.Composable 24 | import androidx.compose.runtime.remember 25 | import androidx.compose.runtime.rememberCoroutineScope 26 | import androidx.compose.ui.Alignment 27 | import androidx.compose.ui.Modifier 28 | import androidx.compose.ui.unit.dp 29 | import com.klitsie.dataloading.example.ExampleScreen 30 | import com.klitsie.dataloading.ui.theme.DataLoadingTheme 31 | import kotlinx.coroutines.launch 32 | 33 | class MainActivity : ComponentActivity() { 34 | override fun onCreate(savedInstanceState: Bundle?) { 35 | super.onCreate(savedInstanceState) 36 | 37 | enableEdgeToEdge() 38 | setContent { 39 | DataLoadingTheme { 40 | val snackbarHostState = remember { SnackbarHostState() } 41 | val coroutineScope = rememberCoroutineScope() 42 | Scaffold( 43 | modifier = Modifier.fillMaxSize(), 44 | snackbarHost = { SnackbarHost(snackbarHostState) }, 45 | ) { innerPadding -> 46 | ExampleScreen( 47 | hideMessage = { snackbarHostState.currentSnackbarData?.dismiss() }, 48 | showMessage = { message -> 49 | snackbarHostState.currentSnackbarData?.dismiss() 50 | coroutineScope.launch { 51 | snackbarHostState.showSnackbar(message) 52 | } 53 | }, 54 | modifier = Modifier 55 | .padding(innerPadding), 56 | ) 57 | } 58 | } 59 | } 60 | } 61 | } 62 | 63 | @Composable 64 | fun LoadingResultScreen( 65 | modifier: Modifier = Modifier, 66 | loadingResult: LoadingResult, 67 | onRefresh: () -> Unit, 68 | loadingScreen: @Composable () -> Unit = { LoadingScreen() }, 69 | failureScreen: @Composable (Throwable, Boolean) -> Unit = { _, isLoading -> 70 | FailureScreen(isLoading, onRefresh) 71 | }, 72 | content: @Composable (T, Boolean) -> Unit, 73 | ) { 74 | AnimatedContent( 75 | modifier = modifier, 76 | targetState = loadingResult, 77 | contentKey = { it::class.simpleName }, 78 | label = "LoadingComposable", 79 | ) { result -> 80 | when (result) { 81 | LoadingResult.Loading -> loadingScreen() 82 | is LoadingResult.Failure -> failureScreen(result.throwable, result.isLoading) 83 | is LoadingResult.Success -> content(result.value, result.isLoading) 84 | } 85 | } 86 | } 87 | 88 | @Composable 89 | fun FailureScreen( 90 | isLoading: Boolean, 91 | onRetry: () -> Unit, 92 | ) { 93 | Column( 94 | modifier = Modifier.fillMaxSize(), 95 | verticalArrangement = Arrangement.Center, 96 | horizontalAlignment = Alignment.CenterHorizontally, 97 | ) { 98 | Text(text = "Oh no, something went wrong!") 99 | Button(onClick = onRetry) { 100 | AnimatedVisibility(visible = isLoading) { 101 | CircularProgressIndicator( 102 | color = LocalContentColor.current, 103 | modifier = Modifier 104 | .padding(end = 8.dp) 105 | .size(24.dp) 106 | ) 107 | } 108 | Text("Try again") 109 | } 110 | } 111 | } 112 | 113 | @Composable 114 | fun LoadingScreen() { 115 | Box( 116 | modifier = Modifier.fillMaxSize(), 117 | contentAlignment = Alignment.Center, 118 | ) { 119 | CircularProgressIndicator( 120 | modifier = Modifier.size(48.dp) 121 | ) 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /app/src/main/java/com/klitsie/dataloading/example/ExampleDataLoader.kt: -------------------------------------------------------------------------------- 1 | package com.klitsie.dataloading.example 2 | 3 | import com.klitsie.dataloading.DataLoader 4 | import com.klitsie.dataloading.usecase.FetchIntFromMemoryUseCase 5 | import com.klitsie.dataloading.usecase.FetchIntUseCase 6 | import com.klitsie.dataloading.LoadingResult 7 | import com.klitsie.dataloading.usecase.ObserveIntUseCase 8 | import com.klitsie.dataloading.RefreshTrigger 9 | import com.klitsie.dataloading.loading 10 | import com.klitsie.dataloading.loadingSuccess 11 | import kotlinx.coroutines.CoroutineScope 12 | import kotlinx.coroutines.flow.StateFlow 13 | 14 | interface ExampleDataLoader { 15 | 16 | fun loadAndObserveData( 17 | coroutineScope: CoroutineScope, 18 | refreshTrigger: RefreshTrigger, 19 | onRefreshFailure: (Throwable) -> Unit, 20 | ): StateFlow> 21 | 22 | } 23 | 24 | internal class DefaultExampleDataLoader( 25 | private val fetchIntFromMemoryUseCase: FetchIntFromMemoryUseCase = FetchIntFromMemoryUseCase, 26 | private val fetchIntUseCase: FetchIntUseCase = FetchIntUseCase, 27 | private val observeIntUseCase: ObserveIntUseCase = ObserveIntUseCase, 28 | private val dataLoader: DataLoader = DataLoader(), 29 | ) : ExampleDataLoader { 30 | 31 | override fun loadAndObserveData( 32 | coroutineScope: CoroutineScope, 33 | refreshTrigger: RefreshTrigger, 34 | onRefreshFailure: (Throwable) -> Unit, 35 | ) = dataLoader.loadAndObserveDataAsState( 36 | coroutineScope = coroutineScope, 37 | refreshTrigger = refreshTrigger, 38 | initialData = fetchIntFromMemoryUseCase.fetchInt().fold( 39 | onSuccess = { loadingSuccess(it) }, 40 | onFailure = { loading() }, 41 | ), 42 | observeData = { observeIntUseCase.observeInt() }, 43 | fetchData = { fetchIntUseCase.fetchInt() }, 44 | onRefreshFailure = onRefreshFailure, 45 | ) 46 | 47 | } -------------------------------------------------------------------------------- /app/src/main/java/com/klitsie/dataloading/example/ExampleDataMapper.kt: -------------------------------------------------------------------------------- 1 | package com.klitsie.dataloading.example 2 | 3 | import com.klitsie.dataloading.LoadingResult 4 | import com.klitsie.dataloading.map 5 | 6 | interface ExampleDataMapper { 7 | 8 | fun map(data: LoadingResult): LoadingResult 9 | 10 | companion object : ExampleDataMapper { 11 | 12 | override fun map(data: LoadingResult) = data.map { 13 | "The current number is $it!" 14 | } 15 | 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/java/com/klitsie/dataloading/example/ExampleScreen.kt: -------------------------------------------------------------------------------- 1 | package com.klitsie.dataloading.example 2 | 3 | import androidx.compose.animation.AnimatedVisibility 4 | import androidx.compose.foundation.layout.Arrangement.spacedBy 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.layout.size 9 | import androidx.compose.material3.Button 10 | import androidx.compose.material3.CircularProgressIndicator 11 | import androidx.compose.material3.LocalContentColor 12 | import androidx.compose.material3.Text 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.runtime.LaunchedEffect 15 | import androidx.compose.runtime.collectAsState 16 | import androidx.compose.runtime.getValue 17 | import androidx.compose.ui.Alignment 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.unit.dp 20 | import androidx.lifecycle.viewmodel.compose.viewModel 21 | import com.klitsie.dataloading.LoadingResultScreen 22 | 23 | @Composable 24 | fun ExampleScreen( 25 | hideMessage: () -> Unit, 26 | showMessage: (String) -> Unit, 27 | modifier: Modifier = Modifier, 28 | viewModel: ExampleViewModel = viewModel(), 29 | ) { 30 | val screenState by viewModel.screenState.collectAsState() 31 | val event by viewModel.event.collectAsState() 32 | LaunchedEffect(event) { 33 | when (event) { 34 | null -> return@LaunchedEffect 35 | ExampleEvent.ShowRefreshFailure -> showMessage("Refresh went wrong!") 36 | } 37 | viewModel.consumeEvent() 38 | } 39 | LoadingResultScreen( 40 | modifier = modifier, 41 | onRefresh = viewModel::refresh, 42 | loadingResult = screenState, 43 | content = { data, isLoading -> 44 | Column( 45 | modifier = Modifier 46 | .fillMaxSize(), 47 | horizontalAlignment = Alignment.CenterHorizontally, 48 | verticalArrangement = spacedBy(8.dp, Alignment.CenterVertically), 49 | ) { 50 | Text(text = data) 51 | Button(onClick = { 52 | hideMessage() 53 | viewModel.refresh() 54 | }) { 55 | AnimatedVisibility(visible = isLoading) { 56 | CircularProgressIndicator( 57 | color = LocalContentColor.current, 58 | modifier = Modifier 59 | .padding(end = 8.dp) 60 | .size(24.dp) 61 | ) 62 | } 63 | Text("Refresh") 64 | } 65 | } 66 | }, 67 | ) 68 | } 69 | 70 | -------------------------------------------------------------------------------- /app/src/main/java/com/klitsie/dataloading/example/ExampleViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.klitsie.dataloading.example 2 | 3 | import androidx.compose.runtime.Immutable 4 | import androidx.compose.runtime.Stable 5 | import androidx.lifecycle.ViewModel 6 | import androidx.lifecycle.viewModelScope 7 | import com.klitsie.dataloading.RefreshTrigger 8 | import kotlinx.coroutines.flow.MutableStateFlow 9 | import kotlinx.coroutines.flow.SharingStarted 10 | import kotlinx.coroutines.flow.asStateFlow 11 | import kotlinx.coroutines.flow.map 12 | import kotlinx.coroutines.flow.stateIn 13 | import kotlinx.coroutines.flow.update 14 | import kotlinx.coroutines.launch 15 | 16 | @Immutable 17 | sealed interface ExampleEvent { 18 | 19 | data object ShowRefreshFailure : ExampleEvent 20 | 21 | } 22 | 23 | @Stable 24 | class ExampleViewModel( 25 | exampleDataLoader: ExampleDataLoader = DefaultExampleDataLoader(), 26 | private val exampleDataMapper: ExampleDataMapper = ExampleDataMapper, 27 | private val refreshTrigger: RefreshTrigger = RefreshTrigger(), 28 | ) : ViewModel() { 29 | 30 | private val _event = MutableStateFlow(null) 31 | val event = _event.asStateFlow() 32 | 33 | private val data = exampleDataLoader.loadAndObserveData( 34 | coroutineScope = viewModelScope, 35 | refreshTrigger = refreshTrigger, 36 | onRefreshFailure = { throwable -> 37 | println(throwable) 38 | _event.update { ExampleEvent.ShowRefreshFailure } 39 | }, 40 | ) 41 | 42 | val screenState = data.map { exampleDataMapper.map(it) }.stateIn( 43 | scope = viewModelScope, 44 | started = SharingStarted.WhileSubscribed(), 45 | initialValue = exampleDataMapper.map(data.value), 46 | ) 47 | 48 | fun refresh() { 49 | viewModelScope.launch { 50 | refreshTrigger.refresh() 51 | } 52 | } 53 | 54 | fun consumeEvent() { 55 | _event.update { null } 56 | } 57 | 58 | } -------------------------------------------------------------------------------- /app/src/main/java/com/klitsie/dataloading/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package com.klitsie.dataloading.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val Purple80 = Color(0xFFD0BCFF) 6 | val PurpleGrey80 = Color(0xFFCCC2DC) 7 | val Pink80 = Color(0xFFEFB8C8) 8 | 9 | val Purple40 = Color(0xFF6650a4) 10 | val PurpleGrey40 = Color(0xFF625b71) 11 | val Pink40 = Color(0xFF7D5260) -------------------------------------------------------------------------------- /app/src/main/java/com/klitsie/dataloading/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.klitsie.dataloading.ui.theme 2 | 3 | import android.app.Activity 4 | import android.os.Build 5 | import androidx.compose.foundation.isSystemInDarkTheme 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.darkColorScheme 8 | import androidx.compose.material3.dynamicDarkColorScheme 9 | import androidx.compose.material3.dynamicLightColorScheme 10 | import androidx.compose.material3.lightColorScheme 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.platform.LocalContext 13 | 14 | private val DarkColorScheme = darkColorScheme( 15 | primary = Purple80, 16 | secondary = PurpleGrey80, 17 | tertiary = Pink80 18 | ) 19 | 20 | private val LightColorScheme = lightColorScheme( 21 | primary = Purple40, 22 | secondary = PurpleGrey40, 23 | tertiary = Pink40 24 | 25 | /* Other default colors to override 26 | background = Color(0xFFFFFBFE), 27 | surface = Color(0xFFFFFBFE), 28 | onPrimary = Color.White, 29 | onSecondary = Color.White, 30 | onTertiary = Color.White, 31 | onBackground = Color(0xFF1C1B1F), 32 | onSurface = Color(0xFF1C1B1F), 33 | */ 34 | ) 35 | 36 | @Composable 37 | fun DataLoadingTheme( 38 | darkTheme: Boolean = isSystemInDarkTheme(), 39 | // Dynamic color is available on Android 12+ 40 | dynamicColor: Boolean = true, 41 | content: @Composable () -> Unit 42 | ) { 43 | val colorScheme = when { 44 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { 45 | val context = LocalContext.current 46 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) 47 | } 48 | 49 | darkTheme -> DarkColorScheme 50 | else -> LightColorScheme 51 | } 52 | 53 | MaterialTheme( 54 | colorScheme = colorScheme, 55 | typography = Typography, 56 | content = content 57 | ) 58 | } -------------------------------------------------------------------------------- /app/src/main/java/com/klitsie/dataloading/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.klitsie.dataloading.ui.theme 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | // Set of Material typography styles to start with 10 | val Typography = Typography( 11 | bodyLarge = TextStyle( 12 | fontFamily = FontFamily.Default, 13 | fontWeight = FontWeight.Normal, 14 | fontSize = 16.sp, 15 | lineHeight = 24.sp, 16 | letterSpacing = 0.5.sp 17 | ) 18 | /* Other default text styles to override 19 | titleLarge = TextStyle( 20 | fontFamily = FontFamily.Default, 21 | fontWeight = FontWeight.Normal, 22 | fontSize = 22.sp, 23 | lineHeight = 28.sp, 24 | letterSpacing = 0.sp 25 | ), 26 | labelSmall = TextStyle( 27 | fontFamily = FontFamily.Default, 28 | fontWeight = FontWeight.Medium, 29 | fontSize = 11.sp, 30 | lineHeight = 16.sp, 31 | letterSpacing = 0.5.sp 32 | ) 33 | */ 34 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/klitsie/dataloading/usecase/FetchIntFromMemoryUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.klitsie.dataloading.usecase 2 | 3 | import kotlin.Result.Companion.failure 4 | import kotlin.Result.Companion.success 5 | import kotlin.random.Random 6 | import kotlin.random.nextInt 7 | 8 | fun interface FetchIntFromMemoryUseCase { 9 | 10 | fun fetchInt(): Result 11 | 12 | companion object : FetchIntFromMemoryUseCase { 13 | 14 | override fun fetchInt(): Result { 15 | return if (Random.nextBoolean()) { 16 | success(Random.nextInt(0..1000)) 17 | } else { 18 | failure(Exception("Failed to fetch int")) 19 | } 20 | } 21 | } 22 | 23 | } -------------------------------------------------------------------------------- /app/src/main/java/com/klitsie/dataloading/usecase/FetchIntUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.klitsie.dataloading.usecase 2 | 3 | import kotlinx.coroutines.delay 4 | import kotlin.Result.Companion.failure 5 | import kotlin.Result.Companion.success 6 | import kotlin.random.Random 7 | import kotlin.random.nextInt 8 | import kotlin.time.Duration.Companion.milliseconds 9 | import kotlin.time.Duration.Companion.seconds 10 | 11 | fun interface FetchIntUseCase { 12 | 13 | suspend fun fetchInt(): Result 14 | 15 | companion object : FetchIntUseCase { 16 | 17 | override suspend fun fetchInt(): Result { 18 | delay(Random.nextInt(200..2000).milliseconds) 19 | return if (Random.nextBoolean()) { 20 | success(Random.nextInt(0..1000)) 21 | } else { 22 | failure(Exception("Failed to fetch int")) 23 | } 24 | } 25 | } 26 | 27 | } -------------------------------------------------------------------------------- /app/src/main/java/com/klitsie/dataloading/usecase/ObserveIntUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.klitsie.dataloading.usecase 2 | 3 | import kotlinx.coroutines.currentCoroutineContext 4 | import kotlinx.coroutines.delay 5 | import kotlinx.coroutines.flow.Flow 6 | import kotlinx.coroutines.flow.flow 7 | import kotlinx.coroutines.isActive 8 | import kotlin.random.Random 9 | import kotlin.random.nextInt 10 | import kotlin.time.Duration.Companion.seconds 11 | 12 | fun interface ObserveIntUseCase { 13 | 14 | fun observeInt(): Flow 15 | 16 | companion object : ObserveIntUseCase { 17 | 18 | override fun observeInt() = flow { 19 | while(currentCoroutineContext().isActive) { 20 | delay(Random.nextInt(5..20).seconds) 21 | emit(Random.nextInt(0..1000)) 22 | } 23 | } 24 | } 25 | 26 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joost-klitsie/DataLoadingExample/8230aa9764de6c3970d2aaba9d066371b09e541d/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joost-klitsie/DataLoadingExample/8230aa9764de6c3970d2aaba9d066371b09e541d/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joost-klitsie/DataLoadingExample/8230aa9764de6c3970d2aaba9d066371b09e541d/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joost-klitsie/DataLoadingExample/8230aa9764de6c3970d2aaba9d066371b09e541d/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joost-klitsie/DataLoadingExample/8230aa9764de6c3970d2aaba9d066371b09e541d/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joost-klitsie/DataLoadingExample/8230aa9764de6c3970d2aaba9d066371b09e541d/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joost-klitsie/DataLoadingExample/8230aa9764de6c3970d2aaba9d066371b09e541d/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joost-klitsie/DataLoadingExample/8230aa9764de6c3970d2aaba9d066371b09e541d/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joost-klitsie/DataLoadingExample/8230aa9764de6c3970d2aaba9d066371b09e541d/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joost-klitsie/DataLoadingExample/8230aa9764de6c3970d2aaba9d066371b09e541d/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | DataLoading 3 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |