I'm creating a catalog app for a design system I'm building using Compose. In this app I'm using LazyColumn to display the multitude of states a single component can be in and how it looks. However, a few of the states have within them an indefinite CircleProgressIndicator. It is meant to represent that state while loading.
private data class ButtonDisplays(
val name: String,
val buttonType: ButtonType = ButtonType.PRIMARY,
val destructive: Boolean = false,
val enabled: Boolean = true,
val icon: String? = null,
val iconSide: IconLocationButton = IconLocationButton.RIGHT,
val loading: Boolean = false
)
#Composable
fun Buttons() {
val buttons = listOf(
// region Primary Buttons
ButtonDisplays(
name ="Primary Button"
),
ButtonDisplays(
name = "Primary Button Loading Right",
loading = true
),
// etc...
)
LazyColumn(
contentPadding = ContentTokens.ContentPadding
) {
items(
items = buttons,
key = { it.hashCode() }
) {button ->
Column {
Spacer(modifier = Modifier.size(10.dp))
Label(text = button.name, style = PulseTheme.typography.tagDefault)
Divider()
Spacer(modifier = Modifier.size(10.dp))
DisplayButton(
buttonType = button.buttonType,
destructive = button.destructive,
enabled = button.enabled,
icon = button.icon,
iconSide = button.iconSide,
loading = button.loading
)
}
}
}
}
You can essentially assume DisplayButton is some form of Button { CircleProgressIndicator(...) } as it's not much different than that really.
As you can see I've tried a few different tricks to limit composition, but none of them seem to work. Giving a key and surrounding by a stable parent are common responses to recomposition. There is no remembering at these levels.
I do use functions like below to determine these values. Inside there are LaunchedEffects for the interactionSource to detect various states as well to be transparent.
val border =
borderStroke?.border(enabled = enabled, interactionSource = interactionSource)
val containerColor =
colors.containerColor(enabled = enabled, interactionSource = interactionSource)
val contentColor =
colors.contentColor(enabled = enabled, interactionSource = interactionSource)
val decoration = textDecoration?.textDecoration(interactionSource)
Am I missing a fundamentally basic idea? There have been comments that List<T> is itself unstable and any recomposition of a child of a List is recomposed to the highest parent, but I don't understand this idea it seems. Is there any way to keep all of the buttons from recomposing or am I just stuck?
Related
A "genius" designer wants a screen with 2 app-bars and different scroll behaviors. The top one should appear only when user scrolls till the begin of the screen, the second one should appear even when user scrolls back a little. I have attached a picture with illustration of 3 states I want to achieve using Jetpack-Compose.
I wanted smth like following code (here I've used androidx.compose.material3):
val topAppBarState = rememberTopAppBarState()
val scrollOnlyInTopBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(topAppBarState)
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(topAppBarState)
Scaffold(
modifier = Modifier
.nestedScroll(scrollBehavior.nestedScrollConnection)
.nestedScroll(scrollOnlyInTopBehavior.nestedScrollConnection),
topBar = {
Column {
SearchBar(...)
CenterAlignedTopAppBar(
title = { Text(text = "Slide-out only in top") },
scrollBehavior = scrollOnlyInTopBehavior,
)
CenterAlignedTopAppBar(
title = { Text(text = "Slide-out event when scrolling back a little") },
scrollBehavior = scrollBehavior,
)
}
}
) {
LazyVerticalGrid(columns = Fixed(2), ...)
}
But obviously it doesn't work because Modifier.nestedScroll of the Scaffold can have only one nestedScrollConnection. Please suggest me some workaround with Compose to implement this screen.
Update:
I was wrong, it's possible to correctly setup several `nestedScroll` modifiers for single compose container, here I just did it in wrong way.
I just found mistake in the code above. I used single TopAppBarState for different Behavior-s, that's why they act similarly, and I wasn't able to achieve desired scroll effect. The effect reaches by using different TopAppBarState for each individual Behavior. Example below should work correctly:
val scrollOnlyInTopBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState())
Scaffold(
modifier = Modifier
.nestedScroll(scrollBehavior.nestedScrollConnection)
.nestedScroll(scrollOnlyInTopBehavior.nestedScrollConnection),
topBar = {
Column {
SearchBar(...)
CenterAlignedTopAppBar(
title = { Text(text = "Slide-out only in top") },
scrollBehavior = scrollOnlyInTopBehavior,
)
CenterAlignedTopAppBar(
title = { Text(text = "Slide-out event when scrolling back a little") },
scrollBehavior = scrollBehavior,
)
}
}
) {
LazyVerticalGrid(columns = Fixed(2), ...)
}
My question is simple and not as complicated as it might look like, so I have the function CoinListItem that has the following parameters, the first two parameters are two different Json domains that I need to use to display some items from them into the UI using Jetpack compose.
#Composable
fun CoinListItem (
coin: Coin,
coinDetail: CoinDetail, //
onItemClick: (Coin) -> Unit
) {
Row(
modifier = androidx.compose.ui.Modifier
.fillMaxWidth()
.clickable { onItemClick(coin) }
.padding(20.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "${coin.rank}. ${coin.name} (${coin.symbol})",
style = MaterialTheme.typography.body1,
overflow = TextOverflow.Ellipsis
)
Text(
text = if(coin.isActive) "active" else "inactive",
color = if(coin.isActive) Color.Green else Color.Red,
fontStyle = FontStyle.Italic,
textAlign = TextAlign.End,
style = MaterialTheme.typography.body2,
modifier = Modifier.align(CenterVertically)
)
Image(
painter = rememberAsyncImagePainter("${coinDetail.logo}"),
contentDescription = null,
modifier = Modifier.size(128.dp)
)
}
}
now in my CoinListScreen function below, and generally,
specifically at items(state.coins) { coin -> ,
I want to do something like, items(state.coins) { coin -> , coinDetail ->
but I'm not sure if it's even possible, or how is it possible if so. Otherwise, I get an error that It cannot resolve the reference coinDetail
#Composable
fun CoinListScreen(
navController: NavController,
viewModel: CoinListViewModel = hiltViewModel()
) {
val state = viewModel.state.value
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(state.coins) { coin -> **// I want to define coinDetail here as well**
CoinListItem(
coin = coin,
// coinDetail = coinDetail**,//this doesn't work ofcourse if coinDetail -> is not initialized**
onItemClick = {
navController.navigate(Screen.CoinDetailScreen.route + "/${coin.id}")
}
)
}
}
}
}
Without knowing the relation between Coin and CoinDetail it will be difficult to provide an exact answer, but here is something that will hopefully at least provide a nudge.
We need to associated each coin with the corresponding detail because Coin doesn't contain CoinDetail (it really probably should, but if it can't for some reason we will ignore this). One way to accomplish that could be something like this
state.coins.zipWith(state.coinDetails)
Downside, is this assumes that the two are both in lists of the same size and the order of the two matches (that is, that the details for coins[i] is coinDetails[i]). If that is not the case, then in order to make the relation, either the Coin or CoinDetail has an ID for the relating data (at least one needs it, both could have it).
If we have this ID we can do something like this
val coinDetailsMap Map<Coin, CoinDetails?> = coins.associateWith { coin ->
details.firstOrNull { detail ->
detail.id == coin.id
}
}
The above way will search through the entire list of details every time, if you want a slightly more performant approach this will only go through the details one time
val detailsMap: Map<String, CoinDetails> = details.associateBy(CoinDetails::id)
val coinDetailsMap Map<Coin, CoinDetails?> = coins.associateWith { coin ->
detailsMap[coin.detailsId]
}
Once you have some form of pairing between a Coin and a CoinDetails object you can pass Pair<Coin,CoinDetail> to the items function and get them via destructuring items(pairs) { (coin, details) -> /* ... */ }
In order to share settings among of compose functions, I create a class AboutState() and a compose fun rememberAboutState() to persist settings.
I don't know if I can wrap Modifier with remember in the solution.
The Code A can work well, but I don't know if it maybe cause problem when I wrap Modifier with remember, I think Modifier is special class and it's polymorphic based invoked.
Code A
#Composable
fun ScreenAbout(
aboutState: AboutState = rememberAboutState()
) {
Column() {
Hello(aboutState)
World(aboutState)
}
}
#Composable
fun Hello(
aboutState: AboutState
) {
Text("Hello",aboutState.modifier)
}
#Composable
fun World(
aboutState: AboutState
) {
Text("World",aboutState.modifier)
}
class AboutState(
val textStyle: TextStyle,
val modifier: Modifier=Modifier
) {
val rowSpace: Dp = 20.dp
}
#Composable
fun rememberAboutState(): AboutState {
val aboutState = AboutState(
textStyle = MaterialTheme.typography.body1.copy(
color=Color.Red
),
modifier=Modifier.padding(start = 80.dp)
)
return remember {
aboutState
}
}
There wouldn't be a problem passing a Modifier to a class. What you actually defined above, even if named State, is not class that acts as a State, it would me more appropriate name it as HelloStyle, HelloDefaults.style(), etc.
It would be more appropriate to name a class XState when it should have internal or public MutableState that can trigger recomposition or you can get current State of Composable or Modifier due to changes. It shouldn't contain only styling but state mechanism either to change or observe state of the Composble such as ScrollState or PagerState.
When you have a State wrapper object common way of having a stateful Modifier or Modifier with memory or Modifiers with Compose scope is using Modifier.composed{} and passing State to Modifier, not the other way around.
When do you need Modifier.composed { ... }?
fun Modifier.composedModifier(aboutState: AboutState) = composed(
factory = {
val color = remember { getRandomColor() }
aboutState.color = color
Modifier.background(aboutState.color)
}
)
In this example even if it's not practical getRandomColor is created once in recomposition and same color is used.
A zoom modifier i use for zooming in this library is as
fun Modifier.zoom(
key: Any? = Unit,
consume: Boolean = true,
clip: Boolean = true,
zoomState: ZoomState,
onGestureStart: ((ZoomData) -> Unit)? = null,
onGesture: ((ZoomData) -> Unit)? = null,
onGestureEnd: ((ZoomData) -> Unit)? = null
) = composed(
factory = {
val coroutineScope = rememberCoroutineScope()
// Current Zoom level
var zoomLevel by remember { mutableStateOf(ZoomLevel.Min) }
// Rest of the code
},
inspectorInfo = {
name = "zoom"
properties["key"] = key
properties["clip"] = clip
properties["consume"] = consume
properties["zoomState"] = zoomState
properties["onGestureStart"] = onGestureStart
properties["onGesture"] = onGesture
properties["onGestureEnd"] = onGestureEnd
}
)
Another practical example for this is Modifier.scroll that uses rememberCoroutineScope(), you can also remember object too to not intantiate another object in recomposition
#OptIn(ExperimentalFoundationApi::class)
private fun Modifier.scroll(
state: ScrollState,
reverseScrolling: Boolean,
flingBehavior: FlingBehavior?,
isScrollable: Boolean,
isVertical: Boolean
) = composed(
factory = {
val overscrollEffect = ScrollableDefaults.overscrollEffect()
val coroutineScope = rememberCoroutineScope()
// Rest of the code
},
inspectorInfo = debugInspectorInfo {
name = "scroll"
properties["state"] = state
properties["reverseScrolling"] = reverseScrolling
properties["flingBehavior"] = flingBehavior
properties["isScrollable"] = isScrollable
properties["isVertical"] = isVertical
}
)
Is there any way to change slider thumb size? I think for now we can only manipulate colors
var sliderPosition by remember { mutableStateOf(0f) }
Text(text = sliderPosition.toString())
Slider(
value = sliderPosition,
onValueChange = { sliderPosition = it },
valueRange = 0f..100f,
onValueChangeFinished = {
// launch some business logic update with the state you hold
// viewModel.updateSelectedSliderValue(sliderPosition)
},
steps = 5,
colors = SliderDefaults.colors(
thumbColor = MaterialTheme.colors.secondary,
activeTrackColor = MaterialTheme.colors.secondary
)
)
No, this size cannot be modified. The only thing you can do is copy the entire Slider.kt file into your project and modify it.
It is a good idea to give the new view a different name to avoid misunderstandings in the future.
You should change ThumbRadiusconstant, or make it a variable if you need different sizes in your application.
With M3 androidx.compose.material3.Slider you can use the thumb attribute to customize the size.
Something like:
var sliderPosition by remember { mutableStateOf(0f) }
Column {
Text(text = sliderPosition.toString())
Slider(
modifier = Modifier.semantics { contentDescription = "Localized Description" },
value = sliderPosition,
onValueChange = { sliderPosition = it },
valueRange = 0f..5f,
steps = 4,
interactionSource = interactionSource,
onValueChangeFinished = {
// launch some business logic update with the state you hold
},
thumb = {
SliderDefaults.Thumb( //androidx.compose.material3.SliderDefaults
interactionSource = interactionSource,
thumbSize = DpSize(40.dp,40.dp)
)
},
)
}
Note: it requires for material3 at least the version 1.0.0-beta03
I've created a library for easy customization of Slider, since Slider from Material package is not flexible.
https://github.com/krottv/compose-sliders. Below is the code example of how to use it to make thumb size smaller:
var stateSlider by remember { mutableStateOf(0.5f) }
SliderValueHorizontal(
stateSlider, { stateSlider = it },
modifier = Modifier
.fillMaxWidth(),
// desired size of Slider's thumb
thumbSize = DpSize(8.dp, 8.dp)
)
Also you can specify custom composables for thumb and track.
Yes, but only wrapping it with AndroidView and wait for the better future, when Google team release another update in Material lib.
Here is an example
AndroidView(
modifier = Modifier...//,
factory = { context ->
Slider(
ContextThemeWrapper(context, context.theme)
).apply {
// set listeners
it.addOnSliderTouchListener(object : SliderView.OnSliderTouchListener {
#SuppressLint("RestrictedApi")
override fun onStartTrackingTouch(slider: Slider) = Unit
#SuppressLint("RestrictedApi")
override fun onStopTrackingTouch(slider: Slider) {
onValueChangeFinished.invoke()
}
})
it.addOnChangeListener { _, value, _ ->
onValueChanged.invoke(value)
}
// your thumb customization
// your track customization
}
}, update = {
// set value
it.value = currentValue
})
Should be placed inside #Composable
AndroidView in Compose
Slider in Material
In this app, I have a screen where you can enter a title and content for a Note.
The screen has two composables DetailScreen() and DetailScreenContent.
Detailscreen has the scaffold and appbars and calls DetailScreenContents() which has two TextFields and a button.
I'm expecting the user to enter text in these fields and then press the button which will package the text into a NOTE object. My question is, how to pass the NOTE to the upper composable which is DETAILSCREEN() with a callback like=
onclick: -> Note or any other efficient way?
#Composable
fun DetailScreen(navCtl : NavController, mviewmodel: NoteViewModel){
Scaffold(bottomBar = { TidyBottomBar()},
topBar = { TidyAppBarnavIcon(
mtitle = "",
onBackPressed = {navCtl.popBackStack()},
)
}) {
DetailScreenContent()
}
}
#Composable
fun DetailScreenContent() {
val titleValue = remember { mutableStateOf("")}
val contentValue = remember { mutableStateOf("")}
val endnote by remember{ mutableStateOf(Note(
Title = titleValue.value,
Content = contentValue.value))}
Column(modifier = Modifier.fillMaxSize()) {
OutlinedTextField(value = titleValue.value,
onValueChange = {titleValue.value = it},
singleLine = true,
label = {Text("")}
,modifier = Modifier
.fillMaxWidth()
.padding(start = 3.dp, end = 3.dp),
shape = cardShapes.small
)
OutlinedTextField(value = contentValue.value, onValueChange = {
contentValue.value = it
},
label = {Text("Content")}
,modifier = Modifier
.fillMaxWidth()
.padding(start = 3.dp, end = 3.dp, top = 3.dp)
.height(200.dp),
shape = cardShapes.small,
)
Row(horizontalArrangement = Arrangement.End,
modifier = Modifier.fillMaxWidth()){
Button(onClick = {
/**return the object to the upper composable**/
}, shape = cardShapes.small) {
Text(text = stringResource(R.string.Finish))
}
}
}
You could use state hoisting. Using lambdas is the most common way of hoisting state here.
Ok so here's DetailScreenContent(), say
fun DetailScreenContent(
processNote: (Note) -> Unit
){
Button( onClick = { processNote(/*Object to be "returned"*/) }
}
We are not literally returning anything, but we are hoisting the state up the hierarchy. Now, in DetailsScreen
fun DetailScreen(navCtl : NavController, mviewmodel: NoteViewModel){
Scaffold(bottomBar = { TidyBottomBar()},
topBar = { TidyAppBarnavIcon(
mtitle = "",
onBackPressed = {navCtl.popBackStack()},
)
}) {
DetailScreenContent(
processNote = {note -> //This is the passed object
/*Perform operations*/
}
)
//You could also extract the processNote as a variable, like so
/*
val processNote = (Note) {
Reference the note as "it" here
}
*/
}
}
This assumes that there is a type Note (something like a data class or so, the object of which type is being passed up, get it?)
That's how we hoist our state and hoist it up to the viewmodel. Remember, compose renders state based on variables here, making it crucial to preserve the variables, making sure they are not modified willy nilly and read from random places. There should be, at a time, only one instance of the variables, which should be modified as and when necessary, and should be read from a common place. This is where viewmodels are helpful. You store all the variables (state) inside the viewmodel, and hoist the reads and modifications to there. It must act as a single source of truth for the app.