Jetpack compose animation with images weird issue - android

I am seeing a weird image flickering issue with Jetpack compose. It is a simple two card deck where the top image is animated off-screen to reveal the second card. The second card displays fine for a second and then the first image flashes on the screen. I have tried with Coil, Fresco and Glide and they all behave the same way.
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import coil.compose.rememberImagePainter
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
class MainViewModel : ViewModel() {
var images = MutableLiveData(listOf(
"https://images.pexels.com/photos/212286/pexels-photo-212286.jpeg?auto=compress&cs=tinysrgb&dpr=1&w=500",
"https://images.pexels.com/photos/163016/crash-test-collision-60-km-h-distraction-163016.jpeg?auto=compress&cs=tinysrgb&dpr=1&w=500",
"https://images.pexels.com/photos/1366944/pexels-photo-1366944.jpeg?auto=compress&cs=tinysrgb&dpr=1&w=500",
"https://images.pexels.com/photos/5878501/pexels-photo-5878501.jpeg?auto=compress&cs=tinysrgb&dpr=1&w=500",
"https://images.pexels.com/photos/3846022/pexels-photo-3846022.jpeg?auto=compress&cs=tinysrgb&dpr=1&w=500",
))
}
class MainActivity : ComponentActivity() {
private val model by viewModels<MainViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MainScreen(model)
}
}
}
#Composable
fun MainScreen(model: MainViewModel) {
val images: List<String> by model.images.observeAsState(listOf())
Box(
modifier = Modifier.fillMaxSize()
) {
images?.take(2).reversed().forEach {
Card(url = it) {
val d = model.images.value?.toMutableList()
d?.let {
it.removeFirst()
model.images.value = it
}
}
}
}
}
#Composable
fun Card(
url: String,
advance: ()-> Unit = {},
){
val coroutineScope = rememberCoroutineScope()
var offsetX = remember(url) { Animatable(0f) }
Box(
modifier = Modifier
.offset { IntOffset(offsetX.value.roundToInt(), 0) }
.fillMaxSize()
.background(color = Color.White)
.clickable {
coroutineScope.launch {
offsetX.animateTo(
targetValue = 3000F
)
}
coroutineScope.launch {
delay(400)
advance()
}
}
) {
Image(
painter = rememberImagePainter(
data = url,
),
contentDescription = null,
modifier = Modifier
.size(400.dp, 400.dp)
)
}
}
Also threw it on a github here in case anyone wants to try it out:
https://github.com/studentjet/learncompose

The problem is this: you have two card views. After deleting the top card, compose reuses them and updates them with the new data. And while the top card loads the second image, it still shows the first one.
You could disable caching, but in that case the image would still flash because it would show an empty space first. Instead, you need to have the second card's view reused for the first one.
Especially for such cases key Composable is made for: it'll reuse content for the same key between recompositions even if order changes.
val images = remember {
mutableStateListOf(
"https://images.pexels.com/photos/212286/pexels-photo-212286.jpeg?auto=compress&cs=tinysrgb&dpr=1&w=500",
"https://images.pexels.com/photos/163016/crash-test-collision-60-km-h-distraction-163016.jpeg?auto=compress&cs=tinysrgb&dpr=1&w=500",
"https://images.pexels.com/photos/1366944/pexels-photo-1366944.jpeg?auto=compress&cs=tinysrgb&dpr=1&w=500",
"https://images.pexels.com/photos/5878501/pexels-photo-5878501.jpeg?auto=compress&cs=tinysrgb&dpr=1&w=500",
"https://images.pexels.com/photos/3846022/pexels-photo-3846022.jpeg?auto=compress&cs=tinysrgb&dpr=1&w=500",
)
}
Box(
modifier = Modifier.fillMaxSize()
) {
images.take(2).reversed().forEach {
key(it) {
Card(url = it) {
images.add(
images.removeFirst()
)
}
}
}
}

Related

Resize View onClick Jetpack Compose

I'm pretty new to compose and having difficulty resizing views. I have a view where I am initially hardcoding it to some size. When clicking the view I want it to expand it to fill the width to certain extent. How is this possible with in compose?
#Composable
fun InputBar(
hint: String,
onClick: () -> Unit
) {
Card(
onClick = { /* update view to fillMaxWidth() */ },
modifier = Modifier
.width(150.dp)
.height(44.dp),
) {
Row(modifier = Modifier.fillMaxWidth()) {
TextField(
value = "",
onValueChange = {
},
placeholder = { Text(hint) }
)
}
}
}
you could achieve your by using something like this:
import android.util.Log
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.material.Card
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlin.math.log
private const val TAG = "InputBar"
#OptIn(ExperimentalMaterialApi::class)
#Composable
fun InputBar(
hint: String,
onClick: () -> Unit
) {
var isFillMaxWidth by remember { mutableStateOf(false) }
val isExpandModifier = remember(isFillMaxWidth){
Modifier
.height(44.dp)
.then(
if (isFillMaxWidth) {
Log.i(TAG, "InputBar: fillMaxWidth")
Modifier.fillMaxWidth()
} else {
Log.i(TAG, "InputBar: 150")
Modifier.width(150.dp)
}
)}
Card(
modifier = isExpandModifier,
) {
Row(modifier = Modifier.fillMaxWidth()) {
TextField(
value = "",
onValueChange = {
},
placeholder = { Text(hint) },
interactionSource = remember { MutableInteractionSource() }
.also { interactionSource ->
LaunchedEffect(interactionSource) {
interactionSource.interactions.collect {
if (it is PressInteraction.Release) {
// works like onClick
isFillMaxWidth = !isFillMaxWidth
Log.i(TAG, "InputBar: onClick")
}
}
}
}
)
}
}
}

jetpack compose exapmle No get method providing array access

I copied a piece of code from the example in jetpack compose.link
But in Android Studio a problem arises:
I wonder where is the problem? I'm still a beginner
The following is the complete code:
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.*
import androidx.compose.material.MaterialTheme.colors
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.freedom.android.ui.theme.MyApplicationTheme
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyApplicationTheme {
val scaffoldState = rememberScaffoldState()
val scope = rememberCoroutineScope()
Scaffold(
scaffoldState = scaffoldState,
drawerContent = { Text("Drawer content") },
topBar = {
TopAppBar(
title = { Text("Simple Scaffold Screen") },
navigationIcon = {
IconButton(
onClick = {
scope.launch { scaffoldState.drawerState.open() }
}
) {
Icon(Icons.Filled.Menu, contentDescription = "Localized description")
}
}
)
},
floatingActionButtonPosition = FabPosition.End,
floatingActionButton = {
ExtendedFloatingActionButton(
text = { Text("Inc") },
onClick = { /* fab click handler */ }
)
},
content = { innerPadding ->
LazyColumn(contentPadding = innerPadding) {
items(count = 100) {
Box(
Modifier
.fillMaxWidth()
.height(50.dp)
.background(colors[it % colors.size])
)
}
}
}
)
}
}
}
}
#Composable
fun Greeting(name: String) {
Text(text = "Hello $name!")
}
#Preview(showBackground = true)
#Composable
fun DefaultPreview() {
MyApplicationTheme {
Greeting("Android")
}
}
I think the problem is with the colors variable, it doesn't seem to be an array, but this was copied from the official documentation, I didn't change it.
package androidx.compose.material
object MaterialTheme {
/**
* Retrieves the current [Colors] at the call site's position in the hierarchy.
*
* #sample androidx.compose.material.samples.ThemeColorSample
*/
val colors: Colors
#Composable
#ReadOnlyComposable
get() = LocalColors.current
}
If you look at the full source code of the sample the docs use, it has a top level object:
private val colors = listOf(
Color(0xFFffd7d7.toInt()),
Color(0xFFffe9d6.toInt()),
Color(0xFFfffbd0.toInt()),
Color(0xFFe3ffd9.toInt()),
Color(0xFFd0fff8.toInt())
)
So yeah, the colors that sample is referring to is a list that you can index into. If you want to also have a semi-random set of colors for your backgrounds, you can copy that list into your code as well.

How do I use the UiState Variable to pass API Calls from 1 screen to the other?

I am currently trying to write an Android Application that makes API calls to retrieve the estimated arrival timings of the incoming buses. The ViewModel files shows me using my Bus Repository to make the API Call, where listResult contains the data I want.
How can I pass the API Call I made to one of my Android Application's screens? I'm supposed to use the UiState Variable to do so right? Thank you.
The Pastebin contains my code as well if it's easier to see there!!
AppViewModel.kt
https://pastebin.com/qPVrDF9i
package com.example.busexpress.ui.screens
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.example.busexpress.BusExpressApplication
import com.example.busexpress.data.SingaporeBusRepository
import com.example.busexpress.network.SingaporeBus
import kotlinx.coroutines.launch
import retrofit2.HttpException
import java.io.IOException
/**
* [AppViewModel] holds information about a cupcake order in terms of quantity, flavor, and
* pickup date. It also knows how to calculate the total price based on these order details.
*/
class AppViewModel(private val singaporeBusRepository: SingaporeBusRepository): ViewModel() {
/** The mutable State that stores the status of the most recent request */
var busUiState: BusUiState by mutableStateOf(BusUiState.Loading) // Loading as Default Value
// Setter is private to protect writes to the busUiState
private set
/**
* Call init so we can display status immediately.
*/
init {
getBusTimings(null)
}
fun getBusTimings(userInput: String?) {
// Determine if UserInput is a BusStopCode
var busStopCode: String?
var busServiceNumber: String?
val userInputLength = userInput?.length ?: 0
if (userInputLength == 5) {
// Bus Stop Code
busStopCode = userInput
busServiceNumber = null
}
else {
// Bus Service Number
busStopCode = null
busServiceNumber = userInput
}
// Launch the Coroutine using a ViewModelScope
viewModelScope.launch {
busUiState = BusUiState.Loading
// Might have Connectivity Issues
busUiState = try {
// Within this Scope, use the Repository, not the Object to access the Data, abstracting the data within the Data Layer
val listResult: SingaporeBus = singaporeBusRepository.getBusTimings(
busServiceNumber = busServiceNumber,
busStopCode = busStopCode
)
// Assign results from backend server to busUiState {A mutable state object that represents the status of the most recent web request}
BusUiState.Success(timings = listResult)
}
catch (e: IOException) {
BusUiState.Error
}
catch (e: HttpException) {
BusUiState.Error
}
}
}
// Factory Object to retrieve the singaporeBusRepository and pass it to the ViewModel
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = (this[APPLICATION_KEY] as BusExpressApplication)
val singaporeBusRepository = application.container.singaporeBusRepository
AppViewModel(singaporeBusRepository = singaporeBusRepository)
}
}
}
// private val _uiState = MutableStateFlow(AppUiState(65199))
// val uiState: StateFlow<AppUiState> = _uiState.asStateFlow()
}
// Simply saving the UiState as a Mutable State prevents us from saving the different status
// like Loading, Error, and Success
sealed interface BusUiState {
data class Success(val timings: SingaporeBus) : BusUiState
// The 2 States below need not set new data and create new objects, which is why an object is sufficient for the web response
object Error: BusUiState
object Loading: BusUiState
// Sealed Interface used instead of Interface to remove Else Branch
}
Example: DefaultScreen.kt
https://pastebin.com/UiZPwZHG
package com.example.busexpress.ui.screens
import androidx.annotation.StringRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.example.busexpress.BusExpressApp
import com.example.busexpress.BusExpressScreen
import com.example.busexpress.R
import com.example.busexpress.network.SingaporeBus
import com.example.busexpress.ui.component.BusStopComposable
#Composable
fun DefaultScreen(
busUiState: BusUiState,
modifier: Modifier = Modifier,
appViewModel: AppViewModel = viewModel(),
// navController: NavController
) {
// Mutable State for User Input
var userInput = remember {
mutableStateOf(TextFieldValue(""))
}
Column(
modifier = modifier
.fillMaxWidth()
.padding(16.dp),
) {
// Search Field for Bus Stop or Bus Numbers
SearchView(
label = R.string.search_field_instructions,
state = userInput,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Search
),
onKeyboardSearch = {
appViewModel.getBusTimings(userInput.value.text)
// navController.navigate(BusExpressScreen.Search.name)
}
)
val busArrivalsJson = appViewModel.getBusTimings(userInput.value.text)
when(busUiState) {
is BusUiState.Success -> ResultScreen(busUiState = busUiState, busArrivalsJSON = busArrivalsJson)
is BusUiState.Loading -> LoadingScreen()
is BusUiState.Error -> ErrorScreen()
else -> ErrorScreen()
}
}
}
#Composable
fun LoadingScreen(modifier: Modifier = Modifier) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.fillMaxSize()
) {
Image(
modifier = Modifier.size(200.dp),
painter = painterResource(id = R.drawable.loading_img),
contentDescription = stringResource(R.string.loading_flavor_text)
)
}
}
#Composable
fun ErrorScreen(modifier: Modifier = Modifier) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier.fillMaxSize()
) {
Text(text = stringResource(R.string.loading_failed_flavor_text))
}
}
/**
* The home screen displaying result of fetching photos.
*/
#Composable
fun ResultScreen(
busUiState: BusUiState,
busArrivalsJSON: SingaporeBus
modifier: Modifier = Modifier,
) {
// Results of Search
BusStopComposable(
busArrivalsJSON = busArrivalsJSON,
modifier = modifier
)
// Box(
// contentAlignment = Alignment.Center,
// modifier = modifier.fillMaxSize()
// ) {
// Text(busUiState.toString())
// }
}
#Composable
fun SearchView(
#StringRes label: Int,
state: MutableState<TextFieldValue>,
modifier: Modifier = Modifier,
keyboardOptions: KeyboardOptions,
onKeyboardSearch: () -> Unit,
) {
Column() {
TextField(
value = state.value,
onValueChange = {value ->
state.value = value
},
label = {
if (state.value == TextFieldValue("")) {
Text(
stringResource(id = label),
modifier = Modifier
.fillMaxWidth(),
style = MaterialTheme.typography.h6
)
}
},
modifier = modifier
.fillMaxWidth(),
singleLine = true,
// Search Icon at the Start for Aesthetics
leadingIcon = {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = null,
modifier = Modifier.padding(10.dp)
)
},
// Cancel Button to delete all Input
trailingIcon = {
// Icon appears iif the Search Field is not Empty
if (state.value != TextFieldValue("")) {
IconButton(onClick = {
// Clear the Search Field
state.value = TextFieldValue("")
}) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Delete all User Input",
modifier = Modifier.padding(10.dp)
)
}
}
},
keyboardActions = KeyboardActions(
onSearch = { onKeyboardSearch() }
),
keyboardOptions = keyboardOptions,
shape = RoundedCornerShape(25)
)
Row() {
Spacer(modifier = modifier.weight(3f))
// Button for User to Click to begin Search
Button(
onClick = {
// TODO Pass the User Query to the Search Function
onKeyboardSearch()
},
modifier = modifier
.align(Alignment.CenterVertically)
.padding(2.dp)
.weight(1f),
) {
Text(text = stringResource(R.string.search_button_flavor_text))
}
}
}
}
BusApi.kt (Contains the function to make the API Call)
https://pastebin.com/miAt8x8H
package com.example.busexpress.network
import com.example.busexpress.LTA_API_SECRET_KEY
import retrofit2.http.GET
import retrofit2.http.Headers
import retrofit2.http.Query
interface BusApiService {
/**
* Function to get JSON Objects from URI by specifying Type of Request and Endpoint like "/photos" a URL of sorts
*/
// 1. Returns Bus Timings for Bus Stop and/or Service Number
#Headers(
"accept: application/json",
"AccountKey: $LTA_API_SECRET_KEY"
)
#GET("BusArrivalv2")
suspend fun getTimingsOfBusStop(
#Query("BusStopCode") BusStopCode: String? = null,
#Query("ServiceNo") ServiceNo: String? = null
): SingaporeBus
// 2. Returns the details for all the Bus Stops in Singapore
#Headers(
"accept: application/json",
"AccountKey: $LTA_API_SECRET_KEY"
)
#GET("BusStops")
suspend fun getDetailsOfBusStop(): BusStop
}
You can use SharedViewModel to share the data between the Fragments hosted by a common parent activity or between parent activity and its child fragments as per your need.
For eg, You can declare a MutableStateFlow variable in the SharedViewModel, set its value when you receive response from API call (which you trigger from one Fragment or parent activity) and then collect the value of that flow via the same SharedViewModel inside child Fragment and update the UI accordingly.
Refer https://developer.android.com/codelabs/basic-android-kotlin-training-shared-viewmodel#0

Jetpack Compose Timing Issue with Stack of Cards

Actually I'm not even sure if this is a timing issue, but let's begin with the code first.
I start out in my MainActivity where I prepare a simple data structure containing letters from A to Z.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val model = mutableStateListOf<Char>()
model.addAll(('A'..'Z').toList())
val swipeComplete = {
model.removeFirst()
}
CardStack(elements = model, onSwipeComplete = { swipeComplete() })
}
}
}
Here I am calling CardStack, which looks like the following:
#Composable
fun CardStack(elements: List<Char>, onSwipeComplete: () -> Unit) {
elements.take(2).reversed().forEachIndexed { _, character ->
Box {
SwipeCard(
character.toString(),
onSwipeComplete = onSwipeComplete
)
}
}
}
When swiping a card, I want to view the card underneath it as well. Therefore I am only taking the two top-most cards and display them. Then comes the SwipeCard itself.
#OptIn(ExperimentalMaterialApi::class)
#Composable
fun SwipeCard(text: String, onSwipeComplete: () -> Unit) {
val color by remember {
val random = Random()
mutableStateOf(Color(random.nextInt(256), random.nextInt(256), random.nextInt(256)))
}
val screenWidth = LocalConfiguration.current.screenWidthDp.dp.value
val screenDensity = LocalConfiguration.current.densityDpi
var offsetXTarget by remember { mutableStateOf(0f) }
var offsetYTarget by remember { mutableStateOf(0f) }
val swipeThreshold = abs(screenWidth * screenDensity / 100)
var dragInProgress by remember {
mutableStateOf(false)
}
val offsetX by animateFloatAsState(
targetValue = offsetXTarget,
animationSpec = tween(
durationMillis = screenDensity / 3,
easing = LinearEasing
),
finishedListener = {
if (!dragInProgress) {
onSwipeComplete()
}
}
)
val offsetY by animateFloatAsState(
targetValue = offsetYTarget,
animationSpec = tween(
durationMillis = screenDensity / 3,
easing = LinearEasing
)
)
val rotationZ = (offsetX / 60).coerceIn(-40f, 40f) * -1
Card(
shape = RoundedCornerShape(20.dp),
elevation = 0.dp,
backgroundColor = color,
modifier = Modifier
.fillMaxSize()
.padding(50.dp)
.graphicsLayer(
translationX = offsetX,
translationY = offsetY,
rotationZ = rotationZ
)
.pointerInput(Unit) {
detectDragGestures(
onDrag = { change, dragAmount ->
dragInProgress = true
change.consumeAllChanges()
offsetXTarget += dragAmount.x
offsetYTarget += dragAmount.y
},
onDragEnd = {
if (abs(offsetX) < swipeThreshold / 20) {
offsetXTarget = 0f
offsetYTarget = 0f
} else {
offsetXTarget = swipeThreshold
offsetYTarget = swipeThreshold
if (offsetX < 0) {
offsetXTarget *= -1
}
}
dragInProgress = false
}
)
}
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = text,
style = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 52.sp
),
color = Color.White
)
}
}
}
This is how it looks in action:
A few key points, let's consider the initial state with all letters from A to Z:
When I start to drag the card with letter "A", I can see card with letter "B" underneath it.
When the drag motion ends the card for letter "A" shall be animated away to either the left or the right side, depending on what side the user has chosen.
When the animation has been finished, the onSwipeComplete shall be called in order to remove the top-most element, the letter "A", of my data model.
After the top-most element has been removed from the data model I expect the stack of cards to be recomposed with letters "B" and "C".
The problem is when the card "A" is animated away, then suddenly the letter "B" is drawn on this animated card and where "B" has been is now "C".
It seems the data model is already updated while the first card with letter "A" is still being animated away.
This leaves me with only one card for letter "C" left. Underneath "C" is no other card.
For me there seems something wrong with the timing, but I can't figure out what exactly.
Here are all the imports to add:
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Card
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.consumeAllChanges
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.swipecard.ui.theme.SwipeCardTheme
import java.util.*
import kotlin.math.abs
This also requires the following dependencies:
implementation "androidx.compose.runtime:runtime:1.0.1"
implementation "androidx.compose.runtime:runtime-livedata:1.0.1"
When you delete an item from the array, from the Compose point of view it looks as if you deleted the last view and changed the data of the other views. The view that was view A is reused for content B, and because that view has non-zero offset values, it is not visible on the screen, so you only see view C.
Using key, you can tell Compose which view is associated with which data, so that they are reused correctly:
#Composable
fun CardStack(elements: List<Char>, onSwipeComplete: () -> Unit) {
elements.take(2).reversed().forEach { character ->
key(character) {
SwipeCard(
character.toString(),
onSwipeComplete = onSwipeComplete
)
}
}
}
p.s. some comments about your code:
It's quite strange that you pass screenDensity to durationMillis. It's pretty small value in terms of millis, which makes your animation almost instant, and looks kind of strange it terms of logic.
If you don't need index from forEachIndexed, just use forEach instead of specifying _ placeholder.
Using Box as you did here, when you only have a single child and don't specify any modifiers for Box has no effect.

LazyColumn is not keeping the state of items when scrolling

Following the Pathway code labs from Google about Jetpack compose, I was trying out this code
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.codelab.basics.ui.BasicsCodelabTheme
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyApp {
MyScreenContent()
}
}
}
}
#Composable
fun MyApp(content: #Composable () -> Unit) {
BasicsCodelabTheme {
Surface(color = Color.Yellow) {
content()
}
}
}
#Composable
fun MyScreenContent(names: List<String> = List(1000) { "Hello Android #$it" }) {
val counterState = remember { mutableStateOf(0) }
Column(modifier = Modifier.fillMaxHeight()) {
NameList(names, Modifier.weight(1f))
Counter(
count = counterState.value,
updateCount = { newCount ->
counterState.value = newCount
}
)
}
}
#Composable
fun NameList(names: List<String>, modifier: Modifier = Modifier) {
LazyColumn(modifier = modifier) {
items(items = names) { name ->
Greeting(name = name)
Divider(color = Color.Black)
}
}
}
#Composable
fun Greeting(name: String) {
var isSelected by remember { mutableStateOf(false) }
val backgroundColor by animateColorAsState(if (isSelected) Color.Red else Color.Transparent)
Text(
text = "Hello $name!",
modifier = Modifier
.padding(24.dp)
.background(color = backgroundColor)
.clickable(onClick = { isSelected = !isSelected })
)
}
#Composable
fun Counter(count: Int, updateCount: (Int) -> Unit) {
Button(
onClick = { updateCount(count + 1) },
colors = ButtonDefaults.buttonColors(
backgroundColor = if (count > 5) Color.Green else Color.White
)
) {
Text("I've been clicked $count times")
}
}
#Preview("MyScreen preview")
#Composable
fun DefaultPreview() {
MyApp {
MyScreenContent()
}
}
I have noticed that LazyColumn will recompose the items whenever they become Visible on the screen (intended behaviour!) however, the Local state of Greeting widget is completely lost!
I believe this is a bug in Compose, Ideally the composer should consider the remember cached state. Is there an elegant way to fix this?
Thanks in advance!
[Update]
Using rememberSaveable {...} gives the power to survive the android change of configuration as well as the scrollability
Docs
Remember the value produced by init.
It behaves similarly to remember, but the stored value will survive the activity or process recreation using the saved instance state mechanism (for example it happens when the screen is rotated in the Android application).
The code now is more elegant and shorter, we don't even need to hoist the state, it can be kept internal. The only thing I am not so sure of now with using rememberSaveable is if there will be any performance penalties when the list grows bigger and bigger, say 1000 items.
#Composable
fun Greeting(name: String) {
val isSelected = rememberSaveable { mutableStateOf(false) }
val backgroundColor by animateColorAsState(if (isSelected.value) Color.Red else Color.Transparent)
Text(
text = "Hello $name! selected: ${isSelected.value}",
modifier = Modifier
.padding(24.dp)
.background(color = backgroundColor)
.clickable(onClick = {
isSelected.value = !isSelected.value
})
)
}
[Original Answer]
Based on #CommonWare's answer The LazyColumn will dispose the composables along with their states when they are off-screen this means when LazyColumn recomposes the Compsoables again it will have fresh start state. To fix this issue all that has to be done is to hoist the state to the consumer's scope, LazyColumn in this case.
Also we need to use mutableStateMapOf() instead of MutableMapOf inside the remember { ... } lambda or Compose-core engine will not be aware of this change.
So far here is the code:
#Composable
fun NameList(names: List<String>, modifier: Modifier = Modifier) {
val selectedStates = remember {
mutableStateMapOf<Int, Boolean>().apply {
names.mapIndexed { index, _ ->
index to false
}.toMap().also {
putAll(it)
}
}
}
LazyColumn(modifier = modifier) {
itemsIndexed(items = names) { index, name ->
Greeting(
name = name,
isSelected = selectedStates[index] == true,
onSelected = {
selectedStates[index] = !it
}
)
Divider(color = Color.Black)
}
}
}
Happy composing!

Categories

Resources