The Code A is from the offical sample project here.
The Code B is from Android Studio source code.
I have searched the article about the function key by Google, but I can't find more details about it.
How can Android Studio launch the inline fun <T> key()? Why can't the author use Code C to launch directly?
Code A
key(detailPost.id) {
LazyColumn(
state = detailLazyListState,
contentPadding = contentPadding,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxSize()
.notifyInput {
onInteractWithDetail(detailPost.id)
}
) {
stickyHeader {
val context = LocalContext.current
PostTopBar(
isFavorite = hasPostsUiState.favorites.contains(detailPost.id),
onToggleFavorite = { onToggleFavorite(detailPost.id) },
onSharePost = { sharePost(detailPost, context) },
modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End)
)
}
postContentItems(detailPost)
}
}
Code B
#Composable
inline fun <T> key(
#Suppress("UNUSED_PARAMETER")
vararg keys: Any?,
block: #Composable () -> T
) = block()
Code C
LazyColumn(
state = detailLazyListState,
contentPadding = contentPadding,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxSize()
.notifyInput {
onInteractWithDetail(detailPost.id)
}
) {
stickyHeader {
val context = LocalContext.current
PostTopBar(
isFavorite = hasPostsUiState.favorites.contains(detailPost.id),
onToggleFavorite = { onToggleFavorite(detailPost.id) },
onSharePost = { sharePost(detailPost, context) },
modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End)
)
}
postContentItems(detailPost)
}
From key documentation:
key is a utility composable that is used to "group" or "key" a block of execution inside of a composition. This is sometimes needed for correctness inside of control-flow that may cause a given composable invocation to execute more than once during composition.
It also contains several examples, so check it out.
Here is a basic example of the usefulness of it. Suppose you have the following Composable. I added DisposableEffect to track its lifecycle.
#Composable
fun SomeComposable(text: String) {
DisposableEffect(text) {
println("appear $text")
onDispose {
println("onDispose $text")
}
}
Text(text)
}
And here's usage:
val items = remember { List(10) { it } }
var offset by remember {
mutableStateOf(0)
}
Button(onClick = {
println("click")
offset += 1
}) {
}
Column {
items.subList(offset, offset + 3).forEach { item ->
key(item) {
SomeComposable(item.toString())
}
}
}
I only display two list items, and move the window each time the button is clicked.
Without key, each click will remove all previous views and create new ones.
But with key(item), only the disappeared item disappears, and the items that are still on the screen are reused without recomposition.
Here are the logs:
appear 0
appear 1
appear 2
click
onDispose 0
appear 3
click
onDispose 1
appear 4
click
onDispose 2
appear 5
Related
The below code works as desired: the canvas gets recomposed each time the user either clicks the canvas itself or clicks the topBar icon, no matter how many times or in what order. In addition, the state variable value reveals something I want to know: where the user clicked. (Values 0 and 1 mean the icon was clicked and values 2 and 3 mean the canvas).
However, if the canvasState and iconState variables are set to their respective V1 functions instead of the V2 functions, then clicking the canvas or icon multiple times in a row is not detected. Apparently this is because the V1 functions can re-assign the same value to the state variable, unlike the V2 functions.
Since I'm using the neverEqualPolicy(), I thought I didn't have to assign a different value to the state variable to trigger a recompose. As a noob to Kotlin and Compose, what am I misunderstanding?
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyApp()
}
}
}
#Composable
fun MyApp() {
var state by remember { mutableStateOf(value = 0, policy = neverEqualPolicy()) }
val canvasStateV1 = { state = 0 }
val iconStateV1 = { state = 2 }
val canvasStateV2 = { state = if (state == 0) { 1 } else { 0 } }
val iconStateV2 = { state = if (state == 2) { 3 } else { 2 } }
val iconState = iconStateV2
val canvasState = canvasStateV2
Scaffold(
topBar = { TopBar(canvasState) },
content = { padding ->
Column(Modifier.padding(padding)) {
Screen(state, iconState)
}
}
)
}
#Composable
fun TopBar(iconState: () -> Unit) {
TopAppBar(
title = { Text("This is a test") },
actions = {
IconButton(onClick = { iconState() }) {
Icon(Icons.Filled.AddCircle, null)
}
}
)
}
#Composable
fun Screen(state: Int, canvasState: () -> Unit) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.aspectRatio(ratio = 1f)
.background(color = MaterialTheme.colors.onSurface)
.pointerInput(Unit) {
detectTapGestures(
onTap = { canvasState() },
)
}
) {
Canvas(
modifier = Modifier.fillMaxSize().clipToBounds()
) {
Log.d("Debug", "Canvas: state = $state")
}
}
}
}
I didn't know other things to try to get the neverEqualPolicy() to work as expected.
I think the main reason for this is because the function Screen() is skippable. If you add the state as a MutableState instead of the Int itself, you will see that the Log.d gets called each time the state value gets updated. Same goes for merging the Screen() function into Column in MyApp
Compose analyses each function during build time. The screen functions receives an integer value, this is an immutable value, so the function itself becomes skippable.
To analyse which function is skippable/stable (and which is not), you can run a report during the build phase
This repo shows how
EDIT:
In this example you have two buttons, one changes the value, one just sets the same value. When setting the same value, you only see the Log.d of the local recomposition. When changing the state value, you see two log lines. the local and external both go through the recomposition.
#Composable
fun StackOverflowApp() {
var state by remember { mutableStateOf(value = 0, policy = neverEqualPolicy()) }
Column() {
Button(onClick = { state = state }) {
Text(text = "State same value")
}
Button(onClick = { state += 1 }) {
Text(text = "State up")
}
Text(text = "[local] current State = $state")
Log.d("TAG","Recomposition local")
ExternalText(state)
}
}
/**
* A skippable function
*
* restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun ExternalText(
stable state: Int
)
*/
#Composable
fun ExternalText(state: Int){
Text(text = "[external] current State = $state")
Log.d("TAG","Recomposition external")
}
You can also pass the MutableState instead of the int value itself, when you pass the mutableState, the neverEqualPolicy is still in play. Each interaction fires both log lines
#Composable
fun StackOverflowApp() {
var state = remember { mutableStateOf(value = 0, policy = neverEqualPolicy()) }
Column() {
Button(onClick = { state.value = state.value }) {
Text(text = "State same value")
}
Button(onClick = { state.value += 1 }) {
Text(text = "State up")
}
Text(text = "[local] current State = ${state.value}")
Log.d("TAG","Recomposition internal")
ExternalText(state)
}
}
#Composable
fun ExternalText(state: MutableState<Int>){
Text(text = "[external] current State = ${state.value}")
Log.d("TAG","Recomposition external")
}
I'm new to Jetpack Compose and I'm not quite sure how to do what I need. In the screen below, I want to scroll the whole screen and not just the list at the bottom and when the scroll reaches the end of the list below, it still applies the paging library and goes to get more elements. I managed to get the Paging Library to work and the scroll in the list below too, but I can't make the rest of the page elements scroll as well - this is because only the list has scroll and not the rest of the page. Whenever I'm trying to do that, I get the following crash:
Vertically scrollable component was measured with an infinity maximum height constraints, which is disallowed. One of the common reasons is nesting layouts like LazyColumn and Column(Modifier.verticalScroll()). If you want to add a header before the list of items please add a header as a separate item() before the main items() inside the LazyColumn scope. There are could be other reasons for this to happen: your ComposeView was added into a LinearLayout with some weight, you applied Modifier.wrapContentSize(unbounded = true) or wrote a custom layout. Please try to remove the source of infinite constraints in the hierarchy above the scrolling container.
and I don't really know why.
I leave you the code below and two screenshots: the first is the current state, where I can only scroll through the list. The second is what I intend, which is to scroll the entire page.
#Edit: I was able to implement all screen scroll with fixed height on the children lazy column, but that is not what I want.
#Composable
#ExperimentalFoundationApi
private fun MainActivityLayout(navController: NavHostController) {
LazyColumn(
modifier = Modifier
.paint(
painter = painterResource(id = R.drawable.main_background),
contentScale = ContentScale.FillBounds
)
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
HeightSpacer(Dimen40)
Image(
painter = painterResource(id = R.drawable.ic_clearjobs_logo_2x),
contentDescription = null
)
HeightSpacer(Dimen47)
Navigation(navController = navController)
}
}
}
#Composable
#ExperimentalFoundationApi
fun JobOpeningsScreen(viewModel: JobOpeningsViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsState()
Column {
ClearJobsScreenTitle(
lightTitle = stringResource(id = R.string.job_openings_light_title),
boldTitle = stringResource(id = R.string.job_openings_bold_title)
)
HeightSpacer(Dimen60)
Row {
CategoryButton()
WidthSpacer(Dimen2)
OrderByButton()
}
HeightSpacer(Dimen30)
SearchTextField()
HeightSpacer(Dimen60)
when (uiState) {
is BaseViewState.Data -> JobOpeningsContent(
viewState = uiState.cast<BaseViewState.Data<JobOpeningsViewState>>().value
)
is BaseViewState.Loading -> {
LoadingView()
}
else -> {}
}
LaunchedEffect(key1 = viewModel, block = {
viewModel.onTriggerEvent(JobOpeningsEvent.LoadJobOffers)
})
}
}
#Composable
fun JobOpeningsContent(viewState: JobOpeningsViewState) {
val pagingItems = rememberFlowWithLifecycle(viewState.pagedData).collectAsLazyPagingItems()
SwipeRefresh(
state = rememberSwipeRefreshState(
isRefreshing = pagingItems.loadState.refresh == LoadState.Loading
),
onRefresh = { pagingItems.refresh() },
indicator = { state, trigger ->
SwipeRefreshIndicator(
state = state,
refreshTriggerDistance = trigger,
scale = true
)
},
content = {
LazyColumn(
modifier = Modifier.width(Dimen320),
verticalArrangement = Arrangement.spacedBy(Dimen30)
) {
items(pagingItems.itemCount) { index ->
pagingItems[index]?.let {
JobOpeningsRow(dto = it)
}
}
if (pagingItems.loadState.append == LoadState.Loading) {
item {
Box(
Modifier
.padding(24.dp)
) {
CircularProgressIndicator(Modifier.align(Alignment.Center))
}
}
}
}
}
)
}
I found the solution to this problem, although it is not 100% and in terms of code it is not as good as I would like.
The error speaks for itself, we can't have infinite vertical scroll, Jetpack Compose doesn't allow it. I had the option of putting a fixed height on the Lazy Column of my list, but it wasn't what I wanted and it didn't work properly. The solution was to put everything inside a single LazyColumn and remove the Column from MainActivity, using a Box element and contentAlignment. I leave you below the final code that I used to solve the problem.
MainScreen function that before was MainActivityLayout function:
#Preview
#Composable
#ExperimentalFoundationApi
fun MainScreen() {
val navController = rememberNavController()
val topLevelDestinations = listOf(
NavigationItem.JobOpenings,
NavigationItem.Profile,
NavigationItem.About
)
val isTopLevelDestination =
navController
.currentBackStackEntryAsState()
.value
?.destination
?.route in topLevelDestinations.map { it.route }
val backStackEntryState = navController.currentBackStackEntryAsState()
Scaffold(
bottomBar = {
if (isTopLevelDestination) {
BottomNavBar(
navController = navController,
backStackEntryState = backStackEntryState,
bottomNavItems = topLevelDestinations
)
}
}
) {
Box(
modifier = Modifier
.paint(
painter = painterResource(id = R.drawable.main_background),
contentScale = ContentScale.FillBounds
)
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Navigation(navController = navController)
}
}
}
New JobOpenings fun that is mixed with old JobOpeningsContent function:
#Composable
#ExperimentalFoundationApi
fun JobOpeningsScreen(viewModel: JobOpeningsViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsState()
when (uiState) {
is BaseViewState.Data -> {
val pagedData = uiState.cast<BaseViewState.Data<JobOpeningsViewState>>().value.pagedData
val pagingItems = rememberFlowWithLifecycle(pagedData).collectAsLazyPagingItems()
SwipeRefresh(
state = rememberSwipeRefreshState(
isRefreshing = pagingItems.loadState.refresh == LoadState.Loading
),
onRefresh = { pagingItems.refresh() },
indicator = { state, trigger ->
SwipeRefreshIndicator(
state = state,
refreshTriggerDistance = trigger,
scale = true
)
},
content = {
LazyColumn(
modifier = Modifier
.width(Dimen320),
verticalArrangement = Arrangement.spacedBy(Dimen30)
) {
item {
ScreenHeader(
lightTitle = stringResource(id = R.string.job_openings_light_title),
boldTitle = stringResource(id = R.string.job_openings_bold_title)
)
HeightSpacer(Dimen60)
Row {
CategoryButton()
WidthSpacer(Dimen2)
OrderByButton()
}
HeightSpacer(Dimen30)
SearchTextField()
HeightSpacer(Dimen60)
}
items(pagingItems.itemCount) { index ->
pagingItems[index]?.let {
JobOpeningsRow(dto = it)
}
}
if (pagingItems.loadState.append == LoadState.Loading) {
item {
Box(Modifier.padding(Dimen24)) {
CircularProgressIndicator(Modifier.align(Alignment.Center))
}
}
}
}
}
)
}
is BaseViewState.Loading -> LoadingView()
else -> {}
}
LaunchedEffect(key1 = viewModel, block = {
viewModel.onTriggerEvent(JobOpeningsEvent.LoadJobOffers)
})
}
#ExperimentalFoundationApi
#Preview
#Composable
fun JobOpenings() {
JobOpeningsScreen()
}
Problems that I found with this solution:
LoadingView appears at the top of the screen instead at the top of the list.
If anyone has any suggestion to improve this, I am open to it. This works perfectly with Paging Library + Swipe Refresh (Accompanist) and full page scroll.
I'm building a composable screen, say PostScreen where multiple posts are shown in GridView and when user click on any of them, I'll navigate to DetailScreen where posts are shown in larger box with multiple buttons associated (like, comment).
My logic is, when user click on any post in PostScreen, use an index from PostScreen to scroll to that index in DetailScreen. Issue is, when user click on any post (and arrive to DetailScreen), then move up (or down) wards and then click on action (for example, like a post), a coroutine operation is launched but index is getting reset and DetailScreen scroll to original index instead of staying at liked post. How would i resolve this? (I know about rememberLazyListState())
#Composable
fun DetailScreen(
viewModel: MyViewModel,
index: Int? // index is coming from navGraph
) {
val postIndex by remember { mutableStateOf(index) }
val scope = rememberCoroutineScope()
val posts = remember(viewModel) { viewModel.posts }.collectAsLazyPagingItems()
Scaffold(
topBar = { MyTopBar() }
) { innerPadding ->
JustNestedScreen(
modifier = Modifier.padding(innerPadding),
posts = posts,
onLike = { post ->
// This is causing index to reset, maybe due to re-composition
scope.launch {
viewModel.toggleLike(
postId = post.postId,
isLiked = post.isLiked
)
}
},
indexToScroll = postIndex
)
}
}
#Composable
fun JustNestedScreen(
modifier: Modifier = Modifier,
posts: LazyPagingItems<ExplorePost>,
onLike: (Post) -> Unit,
indexToScroll: Int? = null
) {
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
LazyColumn(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colors.background),
state = listState
) {
items(posts) { post ->
post?.let {
// Display image in box and some buttons
FeedPostItem(
post = it,
onLike = onLike,
)
}
}
indexToScroll?.let { index ->
scope.launch {
listState.scrollToItem(index = index)
}
}
}
}
Use LaunchedEffect. LaunchedEffect's block is only run the first time and then every time keys are changed. If you only want to run it once, use Unit or listState as a key:
LaunchedEffect(listState) {
indexToScroll?.let { index ->
listState.scrollToItem(index = index)
}
}
#Composable functions are recomposed
if one the parameters is changed or
if one of the parameters is not #Stable/#Immutable
When passing items: List<Int> as parameter, compose always recomposes, regardless of List is immutable and cannot be changed. (List is interface without #Stable annotation). So any Composable function which accepts List<T> as parameter always gets recomposed, no intelligent recomposition.
How to mark List<T> as stable, so compiler knows that List is immutable and function never needs recomposition because of it?
Only way i found is wrapping like #Immutable data class ImmutableList<T>(val items: List<T>). Demo (when Child1 recomposes Parent, Child2 with same List gets recomposed too):
class TestActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ComposeBasicsTheme {
Parent()
}
}
}
}
#Composable
fun Parent() {
Log.d("Test", "Parent Draw")
val state = remember { mutableStateOf(false) }
val items = remember { listOf(1, 2, 3) }
Column {
// click forces recomposition of Parent
Child1(value = state.value,
onClick = { state.value = !state.value })
//
Child2(items)
}
}
#Composable
fun Child1(
value: Boolean,
onClick: () -> Unit
) {
Log.d("Test", "Child1 Draw")
Text(
"Child1 ($value): Click to recompose Parent",
modifier = Modifier
.clickable { onClick() }
.padding(8.dp)
)
}
#Composable
fun Child2(items: List<Int>) {
Log.d("Test", "Child2 Draw")
Text(
"Child 2 (${items.size})",
modifier = Modifier
.padding(8.dp)
)
}
You mainly have 2 options:
Use a wrapper class annotated with either #Immutable or #Stable (as you already did).
Compose compiler v1.2 added support for the Kotlinx Immutable Collections library.
With Option 2 you just replace List with ImmutableList.
Compose treats the collection types from the library as truly immutable and thus will not trigger unnecessary recompositions.
Please note: At the time of writing this, the library is still in alpha.
I strongly recommend reading this article to get a good grasp on how compose handles stability (plus how to debug stability issues).
Another workaround is to pass around a SnapshotStateList.
Specifically, if you use backing values in your ViewModel as suggested in the Android codelabs, you have the same problem.
private val _myList = mutableStateListOf(1, 2, 3)
val myList: List<Int> = _myList
Composables that use myList are recomposed even if _myList is unchanged. Opt instead to pass the mutable list directly (of course, you should treat the list as read-only still, except now the compiler won't help you).
Example with also the wrapper immutable list:
#Immutable
data class ImmutableList<T>(
val items: List<T>
)
var itemsList = listOf(1, 2, 3)
var itemsImmutable = ImmutableList(itemsList)
#Composable
fun Parent() {
Log.d("Test", "Parent Draw")
val state = remember { mutableStateOf(false) }
val itemsMutableState = remember { mutableStateListOf(1, 2, 3) }
Column {
// click forces recomposition of Parent
Child1(state.value, onClick = { state.value = !state.value })
ChildList(itemsListState) // Recomposes every time
ChildImmutableList(itemsImmutableListState) // Does not recompose
ChildSnapshotStateList(itemsMutableState) // Does not recompose
}
}
#Composable
fun Child1(
value: Boolean,
onClick: () -> Unit
) {
Text(
"Child1 ($value): Click to recompose Parent",
modifier = Modifier
.clickable { onClick() }
.padding(8.dp)
)
}
#Composable
fun ChildList(items: List<Int>) {
Log.d("Test", "List Draw")
Text(
"List (${items.size})",
modifier = Modifier
.padding(8.dp)
)
}
#Composable
fun ChildImmutableList(items: ImmutableList<Int>) {
Log.d("Test", "ImmutableList Draw")
Text(
"ImmutableList (${items.items.size})",
modifier = Modifier
.padding(8.dp)
)
}
#Composable
fun ChildSnapshotStateList(items: SnapshotStateList<Int>) {
Log.d("Test", "SnapshotStateList Draw")
Text(
"SnapshotStateList (${items.size})",
modifier = Modifier
.padding(8.dp)
)
}
Using lambda, you can do this
#Composable
fun Parent() {
Log.d("Test", "Parent Draw")
val state = remember { mutableStateOf(false) }
val items = remember { listOf(1, 2, 3) }
val getItems = remember(items) {
{
items
}
}
Column {
// click forces recomposition of Parent
Child1(value = state.value,
onClick = { state.value = !state.value })
//
Child2(items)
Child3(getItems)
}
}
#Composable
fun Child3(items: () -> List<Int>) {
Log.d("Test", "Child3 Draw")
Text(
"Child 3 (${items().size})",
modifier = Modifier
.padding(8.dp)
)
}
when I use CompositionLocal, I have got the data from the parent and modify it, but I found it would not trigger the child recomposition.
I have successfully change the data, which can be proved through that when I add an extra state in the child composable then change it to trigger recomposition I can get the new data.
Is anybody can give me help?
Append
code like below
data class GlobalState(var count: Int = 0)
val LocalAppState = compositionLocalOf { GlobalState() }
#Composable
fun App() {
CompositionLocalProvider(LocalAppState provides GlobalState()) {
CountPage(globalState = LocalAppState.current)
}
}
#Composable
fun CountPage(globalState: GlobalState) {
// use it request recomposition worked
// val recomposeScope = currentRecomposeScope
BoxWithConstraints(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxSize()
.clickable {
globalState.count++
// recomposeScope.invalidate()
}) {
Text("count ${globalState.count}")
}
}
I found a workaround is using currentRecomposable to force recomposition, maybe there is a better way and pls tell me.
The composition local is a red herring here. Since GlobalScope is not observable composition is not notified that it changed. The easiest change is to modify the definition of GlobalState to,
class GlobalState(count: Int) {
var count by mutableStateOf(count)
}
This will automatically notify compose that the value of count has changed.
I am not sure why you are using compositionLocalOf in this way.
Using the State hoisting pattern you can use two parameters in to the composable:
value: T: the current value to display.
onValueChange: (T) -> Unit: an event that requests the value to change where T is the proposed new value.
In your case:
data class GlobalState(var count: Int = 0)
#Composable
fun App() {
var counter by remember { mutableStateOf(GlobalState(0)) }
CountPage(
globalState = counter,
onUpdateCount = {
counter = counter.copy(count = counter.count +1)
}
)
}
#Composable
fun CountPage(globalState: GlobalState, onUpdateCount: () -> Unit) {
BoxWithConstraints(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxSize()
.clickable (
onClick = onUpdateCount
)) {
Text("count ${globalState.count}")
}
}
You can declare your data as a MutableState and either provide separately the getter and the setter or just provide the MutableState object directly.
internal val LocalTest = compositionLocalOf<Boolean> { error("lalalalalala") }
internal val LocalSetTest = compositionLocalOf<(Boolean) -> Unit> { error("lalalalalala") }
#Composable
fun TestProvider(content: #Composable() () -> Unit) {
val (test, setTest) = remember { mutableStateOf(false) }
CompositionLocalProvider(
LocalTest provides test,
LocalSetTest provides setTest,
) {
content()
}
}
Inside a child component you can do:
#Composable
fun Child() {
val test = LocalTest.current
val setTest = LocalSetTest.current
Column {
Button(onClick = { setTest(!test) }) {
Text(test.toString())
}
}
}