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.
Related
I'm following Android Studio's Documentation for learning Kotlin and Jetpacks Compose. For Unit 2, Pathway 3, we are supposed to practice writing both local tests and instrumented tests for the Tip Calculator App that was developed.
When I manually tested the App, it works but it fails the Instrumented Test as the entire app closes during UI Testing. As a result, I encounter an Assertion Error,
java.lang.AssertionError: Failed: assertExists.
Reason: Expected exactly '1' node but could not find any node that satisfies: (Text + EditableText contains 'Tip Amount: $2.00' (ignoreCase: false))
at androidx.compose.ui.test.SemanticsNodeInteraction.fetchOneOrDie(SemanticsNodeInteraction.kt:162)
at androidx.compose.ui.test.SemanticsNodeInteraction.assertExists(SemanticsNodeInteraction.kt:137)
at androidx.compose.ui.test.SemanticsNodeInteraction.assertExists$default(SemanticsNodeInteraction.kt:136)
at com.example.tipcalculator.TipCalculatorInstrumentedTestUI.calculate_20_percent_tip(TipCalculatorInstrumentedTestUI.kt:46)
Does anyone know why this maybe happening? I have attached both my Tip Calculator App Code and Instrumented Tests codes below:
Instrumented Tests
package com.example.tipcalculator
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performTextInput
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.example.tipcalculator.ui.theme.TipCalculatorTheme
import org.junit.Test
import org.junit.Rule
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
#RunWith(AndroidJUnit4::class)
class TipCalculatorInstrumentedTestUI {
#get: Rule
val composeTestRule = createComposeRule()
// Compiler knows #Test in androidTest refers to Instrumented Tests, while in Test Directory refers to Local Tests
#Test
fun calculate_20_percent_tip() {
// Set the UI Content, Code looks similar to Main Activity SetContent where we render the Screen and App
composeTestRule.setContent {
TipCalculatorTheme {
TipCalculatorScreen()
}
}
// Accessing the UI Component as a Node to access its particular text with onNodeWithText() method to access TextField Composable
composeTestRule.onNodeWithText("Bill Amount")
.performTextInput("10") // Pass in the Value of the Text that we wants to populate it with
// Apply Same Approach for Tip Percentage
composeTestRule.onNodeWithText("Tip (%)")
.performTextInput("20")
// Use Assertion to ensure that the Text Composable reflects the accurate Tip to be given
composeTestRule.onNodeWithText("Tip Amount: $2.00").assertExists()
}
}
Application Code
package com.example.tipcalculator
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.annotation.StringRes
import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.tipcalculator.ui.theme.TipCalculatorTheme
import java.text.NumberFormat
import kotlin.math.round
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
TipCalculatorTheme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
TipCalculatorScreen()
}
}
}
}
}
#Composable
fun TipCalculatorScreen() {
// Mutable State that receives 0 as a parameter wrapped in a State Object, making its value observable
var amountInput by remember {
// Importing remember setter and getter functions allows us to read and set amountInput
mutableStateOf("")
}
// Mutable State for Tip
var tipInput by remember {
mutableStateOf("")
}
// Variable to remember State of the Switch
var roundUp by remember {
mutableStateOf(false)
}
// Interface to Control Focus in Compose
val focusManager = LocalFocusManager.current
// Convert to Double or a Null. If Null, return 0 after the Elvis Operator
val amount = amountInput.toDoubleOrNull() ?: 0.0
val tipPercentage = tipInput.toDoubleOrNull() ?: 0.0
// Calculate Tip
val tip = CalculateTip(amount = amount, tipPercent = tipPercentage, roundUp = roundUp)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.padding(32.dp)
) {
// Screen Title
Text(
text = stringResource(id = R.string.calculate_tip),
fontSize = 24.sp,
modifier = Modifier
.align(Alignment.CenterHorizontally)
)
Spacer(modifier = Modifier.height(16.dp))
// Text Field for User
// Pass the Hoisted State back into the Child Function
EditNumberField(
value = amountInput,
onValueChange = { amountInput = it },
label = R.string.bill_amount,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next,
keyboardType = KeyboardType.Number
),
keyboardActions = KeyboardActions(
// Move Focus downwards to the next composable when the Next Button is clicked
onNext = {
focusManager.moveFocus(FocusDirection.Down)
}
)
)
// Input Field for Tip Percentage
EditNumberField(
label = R.string.how_was_the_service,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
// Closes the Keyboard when Done is pressed
onDone = { focusManager.clearFocus() }
),
value = tipInput,
onValueChange = { tipInput = it }
)
// Rounding Function
RoundTipRow(
// Setting Initial State
roundUp = roundUp,
// Updating the State when the Switch is clicked
onRoundUpChanged = { roundUp = it }
)
Spacer(modifier = Modifier.height(24.dp))
// Display the Tip Amount to be given
Text(
// Can use tip to sub into placeholder as the String has a %s placeholder
text = stringResource(id = R.string.tip_amount, tip),
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.align(Alignment.CenterHorizontally)
)
}
}
#Composable
fun EditNumberField(
// Hoist the State by introducing 2 Parameters
#StringRes label: Int, // To indicate that it is meant to be a String Resource
keyboardOptions: KeyboardOptions,
keyboardActions: KeyboardActions,
value: String,
onValueChange: (String) -> Unit, // Takes a string as input but has no output
modifier: Modifier = Modifier
) {
TextField(
value = value, // Set to Empty String; Since TextBox that displays the Value
onValueChange = onValueChange, // Set to Empty Lambda Function; Callback that is triggered when User enters text
label = { Text(text = stringResource(label))}, // Using Label instead of Hardcoding
modifier = Modifier
.fillMaxWidth(),
singleLine = true, // Ensures text box is a single horizontal textbox that is scrollable
keyboardOptions = keyboardOptions, // Changing the look of the keyboard
keyboardActions = keyboardActions // Functionality for the Action Buttons i.e. Next/Done
)
}
// Rounding Tip Switch Function
#Composable
private fun RoundTipRow(
modifier: Modifier = Modifier,
// Allowing us to hoist the state of the switch
roundUp: Boolean,
onRoundUpChanged: (Boolean) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.size(48.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Text for Rounding Tip
Text(text = stringResource(id = R.string.round_up_tip))
Switch(
// Determines whether the Switch is Checked, i.e. the Current State
checked = roundUp,
// Callback called when the Switch is clicked
onCheckedChange = onRoundUpChanged,
modifier = Modifier
.fillMaxWidth()
.wrapContentWidth(Alignment.End),
colors = SwitchDefaults.colors(
// Bad Practice since we are hardcoding the color and will be affected if Dark Mode is implemented for example
uncheckedThumbColor = Color.DarkGray
)
)
}
}
// Calculate Tip; Cannot be Private or the Local Tests will not have access to them
#VisibleForTesting // Makes the Function Public but only for Testing purposes
internal fun CalculateTip(
amount: Double,
tipPercent: Double = 15.0,
roundUp: Boolean
): String {
var tip = tipPercent / 100 * amount
if (roundUp == true) {
// Rounding Up
tip = kotlin.math.ceil(tip)
}
// After calculating the tip, format and display the tip with the Number Class
return NumberFormat.getCurrencyInstance().format(tip)
}
#Preview(showBackground = true)
#Composable
fun DefaultPreview() {
TipCalculatorTheme {
TipCalculatorScreen()
}
}
Thank you
I'm following the same course and my code crashed at the same point with a similar error:
java.lang.AssertionError: Failed: assertExists.
Reason: Expected exactly '1' node but could not find any node that satisfies: (Text + EditableText contains 'Tip Amount: $2.00' (ignoreCase: false))
I tracked it down to having a mismatch with the text value in the test and the text value on the UI. This is your test code:
composeTestRule.onNodeWithText("Bill Amount")
.performTextInput("10")
composeTestRule.onNodeWithText("Tip (%)")
.performTextInput("20")
composeTestRule.onNodeWithText("Tip Amount: $2.00").assertExists()
Check that the text values "Bill Amount", "Tip (%)" and "Tip Amount: $2.00" exactly match the same values on the UI. In this case, if the UI shows "Tip amount: $2.00" but the test text value is "Tip Amount: $2.00", the test will fail. Your original error message shows that ignoreCase is false. I hope this helps.
I am trying to animate the float value using animateFloatAsState but while I am updating the target value the animated value gives me random different value .
For eg:
This is the animatedValue :
val animatedOffsetValue by animateFloatAsState(
targetValue = titleBarOffsetHeightPx.value
)
This is target value which is being updated over time :
val titleBarOffsetHeightPx = remember { mutableStateOf(0f) }
So when I updated the value as titleBarOffsetHeightPx.value = "new value"
And I check the value of animatedOffsetValue , it gives different than titleBarOffsetHeightPx.
full code :
package com.example.productivitytrackergoalscheduler.features.history.presentation
import android.util.Log
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.example.productivitytrackergoalscheduler.business.util.DateConverter
import com.example.productivitytrackergoalscheduler.business.util.Extensions.isScrolledToTheEnd
import com.example.productivitytrackergoalscheduler.features.core.presentation.navigation.Screen
import com.example.productivitytrackergoalscheduler.features.core.util.SnackbarController
import com.example.productivitytrackergoalscheduler.features.history.presentation.components.DayGoalList
import com.example.productivitytrackergoalscheduler.features.history.presentation.components.HistoryTitleBar
import com.example.productivitytrackergoalscheduler.features.home.presentation.components.CustomNavBar
import com.example.productivitytrackergoalscheduler.features.home.presentation.components.ITEM_WIDTH
import com.example.productivitytrackergoalscheduler.features.home.presentation.util.NavItems
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
const val NAV_BAR_HEIGHT = ITEM_WIDTH + 110
#Composable
fun HistoryScreen(
...
) {
/** Height of title bar in dp */
val titleBarHeight = 66.dp
/** Height of title bar converted into float for offset */
val titleBarHeightPx = with(LocalDensity.current) { titleBarHeight.roundToPx().toFloat() }
/** Offset value of title bar. How much to move up or down */
val titleBarOffsetHeightPx = remember { mutableStateOf(0f) }
/** Is the scrolling is up or down */
val isUpScrolled = remember { mutableStateOf(false) }
val animatedOffsetValue by animateFloatAsState(
targetValue = titleBarOffsetHeightPx.value
)
val nestedScrollConnection = remember {
object: NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
isUpScrolled.value = available.y < 0
val delta = available.y
val newOffset = titleBarOffsetHeightPx.value + delta
titleBarOffsetHeightPx.value = newOffset.coerceIn(-titleBarHeightPx, 0f)
Log.d("TAG", "onPostFling: animated ${animatedOffsetValue}")
Log.d("TAG", "onPostFling: no animated ${titleBarOffsetHeightPx.value}")
return Offset.Zero
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
val scrolledTo = if(isUpScrolled.value) titleBarHeightPx/3 else titleBarHeightPx/2
if(-titleBarOffsetHeightPx.value < scrolledTo)
{
titleBarOffsetHeightPx.value = 0f
}else{
titleBarOffsetHeightPx.value = -titleBarHeightPx
}
return super.onPostFling(consumed, available)
}
}
}
val endOfScrollList by remember {
derivedStateOf {
scrollState.isScrolledToTheEnd()
}
}
Scaffold(
modifier = Modifier.nestedScroll(nestedScrollConnection),
scaffoldState = scaffoldState,
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(color = MaterialTheme.colors.background)
) {
Box(
modifier = Modifier.fillMaxHeight()
) {
Box(
modifier = Modifier
/** this offset id for while title bar hides or shows
* The lazyColumn content also should move towards the
* up and down as to fill empty space */
.offset { IntOffset(x = 0, y = animatedOffsetValue.roundToInt()) }
.padding(horizontal = 8.dp)
) {
LazyColumn(
state = scrollState,
/** This modifier offset gives the space for title bar */
modifier = Modifier.offset{ IntOffset(x = 0, y = titleBarHeightPx.toInt())}
){
...
}
}
}
/** Title Bar with z-index top */
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.offset { IntOffset(x = 0, y = titleBarOffsetHeightPx.value.roundToInt()) }
) {
HistoryTitleBar(navController = navController)
}
/** end title bar*/
...
}
}
}
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()
)
}
}
}
}
I want to use Icon button in Jetpack Compose, but I couldn't understand Jetpack Compose docs. Can someone share a sample code for toggle button similar to this one?
When user clicks on a button, I want to animate it like in Instagram with a bounce animation.
You can combine IconToggleButton with transitions. Sample code (using version 1.0.0-beta05):
import android.annotation.SuppressLint
import androidx.compose.animation.animateColor
import androidx.compose.animation.core.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
#SuppressLint("UnusedTransitionTargetStateParameter")
#Composable
fun FavoriteButton(
isChecked: Boolean,
onClick: () -> Unit
) {
IconToggleButton(
checked = isChecked,
onCheckedChange = { onClick() }
) {
val transition = updateTransition(isChecked, label = "Checked indicator")
val tint by transition.animateColor(
label = "Tint"
) { isChecked ->
if (isChecked) Color.Red else Color.Black
}
val size by transition.animateDp(
transitionSpec = {
if (false isTransitioningTo true) {
keyframes {
durationMillis = 250
30.dp at 0 with LinearOutSlowInEasing // for 0-15 ms
35.dp at 15 with FastOutLinearInEasing // for 15-75 ms
40.dp at 75 // ms
35.dp at 150 // ms
}
} else {
spring(stiffness = Spring.StiffnessVeryLow)
}
},
label = "Size"
) { 30.dp }
Icon(
imageVector = if (isChecked) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
contentDescription = null,
tint = tint,
modifier = Modifier.size(size)
)
}
}
#Preview("Favorite Button")
#Composable
fun FavoriteButtonPreview() {
val (isChecked, setChecked) = remember { mutableStateOf(false) }
MaterialTheme {
Surface {
FavoriteButton(
isChecked = isChecked,
onClick = { setChecked(!isChecked) }
)
}
}
}
These are the required dependencies for this sample:
dependencies {
implementation 'androidx.core:core-ktx:1.6.0-alpha01'
implementation "androidx.compose.ui:ui:1.0.0-beta05"
implementation "androidx.compose.material:material:1.0.0-beta05"
implementation "androidx.compose.ui:ui-tooling:1.0.0-beta05"
implementation 'androidx.activity:activity-compose:1.3.0-alpha06'
}
For more details about transition and keyframes and ways to customize them, see Compose's Animation documentation.
Maybe it's a good idea to use Chips instead.
Filter chips use tags or descriptive words to filter content. They can be a good alternative to toggle buttons or checkboxes.
Also it's possible to use Modifier.selectable.
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!