Skip to content

Commit

Permalink
Add Jetpack Compose state management sample (#12)
Browse files Browse the repository at this point in the history
* Create compose state app

* Create pokemon data repository + Unit tests

* Create pokemon ViewModel + Unit tests

* Set up app's UI

* Add art to sample

* Create README.md

* Remove unused dependencies + example android test
  • Loading branch information
husaynhakeem authored Nov 9, 2020
1 parent 1cd8f1f commit 804fc7e
Show file tree
Hide file tree
Showing 39 changed files with 1,609 additions and 0 deletions.
10 changes: 10 additions & 0 deletions ComposeStateSample/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
*.iml
.gradle
/local.properties
/.idea
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
16 changes: 16 additions & 0 deletions ComposeStateSample/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Jetpack Compose State

Android sample app to learn about managing state when using Jetpack Compose. The app is a Pokedex that locally loads a list of pokemons and displays it on the UI, the data is paged, and the UI state is controlled by a [ViewModel](https://developer.android.com/topic/libraries/architecture/viewmodel).

![Jetpack compose state - Sample](https://github.com/husaynhakeem/android-playground/blob/master/ComposeStateSample/art/android-jetpack-compose-state-sample.png)

The sample mainly showcases:
- Using a unidirectional data flow with ViewModel and Jetpack Compose
- Using stateless Composables
- Using stateful Composables via [`remember`](https://developer.android.com/reference/kotlin/androidx/compose/runtime/package-summary#remember) to internally store immutable values and handle state in a composable
- Creating custom layouts/composables. E.g. Creating a custom [lazy grid layout](https://github.com/husaynhakeem/android-playground/blob/master/ComposeStateSample/app/src/main/java/com/husaynhakeem/composestatesample/widget/LazyGridForIndexed.kt), the equivalent of [RecyclerView](https://developer.android.com/reference/kotlin/androidx/recyclerview/widget/RecyclerView) + [GridLayoutManager](https://developer.android.com/reference/kotlin/androidx/recyclerview/widget/GridLayoutManager).
- Loading paged data in a Composable and triggering recomposition.

### Resources used in this sample
- [Jetpack Compose - Managing state](https://developer.android.com/jetpack/compose/state)
- [List pagination with Jetpack Compose](https://medium.com/schibsted-tech-polska/list-pagination-with-jetpack-compose-6c25da053858)
1 change: 1 addition & 0 deletions ComposeStateSample/app/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/build
49 changes: 49 additions & 0 deletions ComposeStateSample/app/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
plugins {
id "com.android.application"
id "kotlin-android"
}

android {
compileSdkVersion 30

defaultConfig {
applicationId "com.husaynhakeem.composestatesample"
minSdkVersion 21
targetSdkVersion 30
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
useIR = true
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion compose_version
kotlinCompilerVersion "1.4.10"
}
}

dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation "androidx.core:core-ktx:1.3.2"
implementation "androidx.appcompat:appcompat:1.2.0"
implementation "com.google.android.material:material:1.2.1"
implementation "androidx.compose.ui:ui:$compose_version"
implementation "androidx.compose.runtime:runtime-livedata:$compose_version"
implementation "androidx.compose.material:material:$compose_version"
implementation "androidx.ui:ui-tooling:$compose_version"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.3.0-beta01"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.0-beta01"
implementation "dev.chrisbanes.accompanist:accompanist-coil:0.3.2"
implementation "androidx.palette:palette-ktx:1.0.0"
testImplementation "junit:junit:4.13.1"
testImplementation "com.google.truth:truth:1.1"
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.4.1'
testImplementation "androidx.arch.core:core-testing:2.1.0"
}
24 changes: 24 additions & 0 deletions ComposeStateSample/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.husaynhakeem.composestatesample">

<uses-permission android:name="android.permission.INTERNET" />

<application
android:allowBackup="true"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.ComposeStateSample">
<activity
android:name=".MainActivity"
android:label="@string/app_name"
android:theme="@style/Theme.ComposeStateSample.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.husaynhakeem.composestatesample

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.Text
import androidx.compose.material.Scaffold
import androidx.compose.material.TopAppBar
import androidx.compose.ui.platform.setContent
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModelProvider
import coil.Coil
import coil.ImageLoader
import coil.util.DebugLogger
import com.husaynhakeem.composestatesample.ui.ComposeStateSampleTheme

class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

// Set debug logger to monitor image loading logs
Coil.setImageLoader(
ImageLoader.Builder(this)
.logger(DebugLogger())
.build()
)

// Set up viewModel and UI
val factory = MainViewModel.Factory()
val viewModel = ViewModelProvider(this, factory).get(MainViewModel::class.java)
setContent {
ComposeStateSampleTheme {
Scaffold(
topBar = {
TopAppBar(
title = {
Text(text = "Pokedex - Jetpack Compose")
},
elevation = 12.dp
)
}
) {
PokemonsLayout(viewModel)
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.husaynhakeem.composestatesample

import androidx.lifecycle.*
import com.husaynhakeem.composestatesample.data.Pokemon
import com.husaynhakeem.composestatesample.data.PokemonRepository
import com.husaynhakeem.composestatesample.data.impl.InMemoryPokemonContainer
import com.husaynhakeem.composestatesample.data.impl.InternalPokemonRepository
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger

class MainViewModel(private val pokemonRepository: PokemonRepository) : ViewModel() {

private val _state = MutableLiveData(State())
val state: LiveData<State> = _state

// Todo: Group inside pagination state
private val currentPage = AtomicInteger(INITIAL_PAGE)
private val totalPages = AtomicInteger(INITIAL_TOTAL_PAGES)
private val isQueryInProgress = AtomicBoolean(false)

fun loadPokemons() {
// Do not load more pokemons if they have already all been loaded, or if a query is in
// progress.
if (currentPage.get() > totalPages.get() || isQueryInProgress.get()) {
return
}

viewModelScope.launch {
_state.postValue(State(getPokemonsFromState(), true))
isQueryInProgress.set(true)

val pokemonResult = pokemonRepository.getPokemonResultFor(currentPage.get(), PAGE_SIZE)

// Update current page and total pages from the response
currentPage.set(pokemonResult.currentPage + 1)
totalPages.set(pokemonResult.totalPages)

// Add list of pokemons from response to current list of pokemons, then update state
_state.postValue(State(getPokemonsFromState() + pokemonResult.items, false))

isQueryInProgress.set(false)
}
}

private fun getPokemonsFromState() = _state.value!!.pokemons.toList()

data class State(val pokemons: List<Pokemon> = emptyList(), val isLoading: Boolean = false) {

fun isInitialState() = pokemons.isEmpty() && !isLoading
}

class Factory : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
val container = InMemoryPokemonContainer()
val repository = InternalPokemonRepository(container)
return MainViewModel(repository) as T
}
}

companion object {
private const val INITIAL_PAGE = 1
private const val INITIAL_TOTAL_PAGES = Int.MAX_VALUE
private const val PAGE_SIZE = 10
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.husaynhakeem.composestatesample

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.ui.tooling.preview.Preview
import com.husaynhakeem.composestatesample.ui.ComposeStateSampleTheme
import com.husaynhakeem.composestatesample.widget.PokemonsGrid

@Composable
fun PokemonsLayout(viewModel: MainViewModel) {
val observedState by viewModel.state.observeAsState()
val state = observedState ?: return
if (state.isInitialState()) {
viewModel.loadPokemons()
}

Box(modifier = Modifier.fillMaxSize()) {

// Pokemons
PokemonsGrid(pokemons = state.pokemons, onLoadMorePokemons = { viewModel.loadPokemons() })

// Loading
if (state.isLoading) {
CircularProgressIndicator(
Modifier
.align(alignment = Alignment.Center)
.padding(80.dp)
)
}
}
}


@Preview
@Composable
fun PokemonListPreview() {
ComposeStateSampleTheme {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.husaynhakeem.composestatesample.data

data class PokemonResult(
val currentPage: Int,
val totalPages: Int,
val items: List<Pokemon>
)

data class Pokemon(
val id: Int,
val name: String,
val spriteUrl: String
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.husaynhakeem.composestatesample.data

interface PokemonContainer {

fun get(): List<Pokemon>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.husaynhakeem.composestatesample.data

interface PokemonRepository {

suspend fun getPokemonResultFor(page: Int, pageSize: Int): PokemonResult
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.husaynhakeem.composestatesample.data.fake

import com.husaynhakeem.composestatesample.data.Pokemon
import com.husaynhakeem.composestatesample.data.PokemonContainer

class FakePokemonContainer : PokemonContainer {

private val allPokemon: MutableList<Pokemon> = mutableListOf()

override fun get(): List<Pokemon> {
return allPokemon.toList()
}

fun setAllPokemon(allPokemon: List<Pokemon>) {
this.allPokemon.clear()
this.allPokemon.addAll(allPokemon)
}
}
Loading

0 comments on commit 804fc7e

Please sign in to comment.