Composable state is lost after scroll and rotation - android-jetpack-compose

I have a LazyColumn containing ToggleButtonGroups that I have created myself. My issue can be reproduced by these three steps:
Select "YES" on every ToggleButtonGroup
Rotate the screen and then scroll to the bottom
Rotate the screen back. Now, the topmost three to four ToggleButtonGroups are reset.
The problem does not appear if I don't scroll after rotating. So by rotation alone, the state is saved properly, as I would have expected it by using rememberSaveable.
The code is provided below:
LazyColumn() {
items(characteristics) {characteristic: Characteristic ->
ToggleButtonGroup(
defaultIndex = 2,
values = listOf(Pair("YES", -1), Pair("NO", 1), Pair("TBD", 0)),
onToggleChange = { newValue: Int ->
characteristic.value = newValue
}
)
}
}
The Composable named ToggleButtonGroup is seen below:
#Composable
fun ToggleButtonGroup(defaultIndex: Int, values: List<Pair<String, Int>>, onToggleChange: (newValue: Int) -> Unit) {
var selectedIndex by rememberSaveable {
mutableStateOf( defaultIndex.coerceIn(0, values.size - 1) )
}
Row(
modifier = Modifier.wrapContentHeight()
) {
values.forEachIndexed { index: Int, value: Pair<String, Int> ->
FilledTonalButton(
colors = if (selectedIndex == index) ButtonDefaults.buttonColors() else ButtonDefaults.filledTonalButtonColors(),
onClick = { selectedIndex = index; onToggleChange(value.second) },
shape = RectangleShape
) {
Text(text = value.first)
}
}
}
}
And the characteristics data is coming from my ViewModel:
data class Characteristic(val title: String, val weight: Int, var value: Int)
var characteristics by mutableStateOf(listOf<Characteristic>())
Thank you for any efforts!

It seems like this is an intended behaviour. There is an issue open on the Google Issue Tracker which describes a similar problem. The issue was marked as "intended behaviour", as this mechanism of releasing the state of non-visible items at rotation was introduced with this commit:
Save the states only for the currently visible items of lazy layouts
When we save the state of the screen in cases like navigation to other screen or activity rotation we were saving the state for all items which were ever visible in the lazy layouts like LazyColumn. We want to save state while user is scrolling so they not lose it when they scroll back, however when this screen is not active anymore keeping the states for all items is not efficient as we easily can fill up the whole available limit provided by the Bundle. Instead it could be reasonable to only save states for the items which were visible when the screen state save happens.

Related

Column does not vertically scroll although it was configured to

I am trying to add scrolling behaviour to a Column by setting verticalScroll(state = rememberScrollState()) modifier.
I checked out some examples in the official compose-jb repository, and it seems that that is the right way to do it, yet in my case the content is not scrollable.
Here the full code:
#Composable
#Preview
fun App() {
MaterialTheme {
// add scroll behaviour
val stateVertical = rememberScrollState(0)
Column(modifier = Modifier.verticalScroll(state = stateVertical)) {
repeat(100){
Text("item: $it")
}
}
}
}
fun main() = application {
Window(onCloseRequest = ::exitApplication) {
App()
}
}
Any ideas why it does not work in my case?
The Column is populated with 100 Text items, more than enough to exceed the default window height.
It actually works!
For some reason I was trying to use click and drag... which lead me to confusion.

How to know if the first item in the list that is inside of a ForEach loop is visible in SwiftUI?

I have a list with a ForEach loop for items. I would like to place a button on the side of the list that would pull the list to the top if the list is not currently at the top.
The key is that the button should only be visible if the list is currently not at the top position, therefore, I need some way to get the position of the list or track the top element in ForEach loop to know when it is in view to trigger the show button event.
I believe the following gives the behaviour you are looking for. Note that I took the liberty of disabling the "Scroll to Top" button, rather than hiding it, because the hiding causes layout changes that are troublesome.
The gist of it is to use GeometryReaders to compare the top of the list (in the global coordinate system) to the bottom of the first list entry (in the same coordinate system) and enable the button when the list entry is entirely above the top of the list.
The line of the form let _ = { ... }() allows us to inject some code into the declarative code structure SwiftUI uses.
The listProxy.scrollTo(1) call will cause the list to scroll to the position that makes the list item marked with .id(1).
struct ScrollButtonTest: View {
#State private var listTop: CGFloat = 0.0
#State private var firstRowBottom: CGFloat = 0.0
var body: some View {
VStack {
ScrollViewReader { listProxy in
Text("listTop: \(listTop)")
Text("firstRowBottom: \(firstRowBottom)")
Button("Scroll to Top") {
listProxy.scrollTo(1)
}.disabled(listTop < firstRowBottom )
GeometryReader { outerGP in
List {
// We set up the first item manually so that
// we can wrap it in a GeometryReader
GeometryReader { innerGP in
let _ = {
listTop = outerGP.frame(in: .global).origin.y
firstRowBottom = innerGP.frame(in: .global).origin.y + innerGP.frame(in: .global).height
}()
Text("Item 1")
}.id(1)
ForEach(2..<99) { index in
Text("Item \(index)").id(index)
}
}
}
}
}
}
}

jetpack compose lazy custom layout

I tried to develop a simple custom layout just like the documentation
#Composable
fun MyBasicColumn(
modifier: Modifier = Modifier,
content: #Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
// Don't constrain child views further, measure them with given constraints
// List of measured children
val placeables = measurables.map { measurable ->
// Measure each children
measurable.measure(constraints)
}
// Set the size of the layout as big as it can
layout(constraints.maxWidth, constraints.maxHeight) {
// Track the y co-ord we have placed children up to
var yPosition = 0
// Place children in the parent layout
placeables.forEach { placeable ->
// Position item on the screen
placeable.placeRelative(x = 0, y = yPosition)
// Record the y co-ord placed up to
yPosition += placeable.height
}
}
}
}
it works fine when I know exact number of items
but what about lazy items?
there is nothing in documentation about how can I develop a LazyCustomLayout
You don't exactly have to know how many items are in the Layout, since even for dynamic lists, there's always a 'current number of items' which can be computed. Let's say you download a list of texts from a server, and then intend to use this Layout to render those. Even in that case, while the server may vary the length of the list, i.e., the list is dynamic in size, you would presumably have a LiveData object keeping track of the list items. From there, you can easily use the collectAsState() method inside a Composable, or the observeAsState() method tied to a LifecycleOwner to convert it into the Compose-compatible MutableState<T> variable. Hence, whenever the LiveData notifies a new value (addition, or deletion), the MutableState<T> variable will also be updated to reflect those values. This, you can use inside the said Layout, which is also a Composable and hence, will update along-side the server-values, in real-time.
The thing is, no matter how you get your list, in order to show it on-screen, or use it anywhere in your app, you would always have a list object, which can be exploited using Compose's declarative and reactive nature.

AnimatedVisibility and BringIntoViewRequester not working together

I'm building an expandable Composable which would be expanded when clicked.
This would be implemented by using the AnimatedVisibility which works perfectly.
Code for the visibility animation:
AnimatedVisibility(
visible = isExpanded,
) {
// Content removed.
}
The problem I'm currently facing is that this is located in a vertical scrollable column and it should scroll to the expanded content when clicked next to expanding it.
As I read this would be done by using the BringIntoViewRequester as in the code snippet below:
var isExpanded by remember { mutableStateOf(false) }
val intoViewRequester = remember { BringIntoViewRequester() }
ClickableComposable(modifier = Modifier.clickable {
isExpanded = !isExpanded
if(isExpanded) {
coroutineScope.launch {
// delay(200)
intoViewRequester.bringIntoView(rect = null)
}
}
})
AnimatedVisibility(
modifier = Modifier.bringIntoViewRequester(intoViewRequester),
visible = isExpanded,
) {
// Content removed.
}
The code above works with the delay but that's not a perfect interaction for the user. To first see the content expanding and afterwards see the page scroll. The ideal situation would be that it would happen at the same time, however the content is not yet measured in any way. By removing the delay it does not work as the content is not yet visible.
Is there anything in Compose to do the expanding and scrolling to at the same time?

How to add border to UITabBar Badge?

How to add a custom border to the badge in the tab bar item? By default it shows a red dot in the top right corner of the tab bar item. I want to add a border to it. There is an option to change the color of the badge but I could not find anything to add a border to the badge.
I don't think there is an official way of adding a border to the badge, but I also wanted to do this and I ended up developing my own way. My solution is more like a hack, but it works on Xcode 10.1 and iOS 12.1 and will not get your app rejected, so maybe it's good enough for you.
The hardest part is to actually get a hold of the badge, this is the view hierarchy for the tab bar with 5 tabs:
You can see that in my project the last tab holds the badge. But actually it's the 4th tab (the one with index = 3) that has the badge, this is because I bring the 4th tab to top in my UITabBarController class so the badge is not clipped in case it displays bigger values. Here is how I get the badge view (this is in the UITabBar context, so self is UITabBar):
func badge(forIndex index: Int) -> UIView? {
let sorted = self.subviews.sorted { (view1, view2) -> Bool in // I need to sort it by the frame.origin.x because the order in the subviews array cannot be trusted, I got random order there during my tests
return view1.frame.origin.x < view2.frame.origin.x
}
let idx = index + 1 // the first view is actually a _UIBarBackground
guard let barItemView = sorted[safe: idx] else {
print("\(#file):\(#function):\(#line): Could not find a subview with index \(idx) in \(self)")
return nil
}
if barItemView.subviews.count == 3 { // this is a hack, but I could not find a better way to get the badge without using private interfaces
let view = barItemView.subviews.last
return view
}
return nil
}
This gives you the badge view, and with the hardest (and ugliest) part behind, we can now add the border (or change the position as I do in my project):
if let badgeView = badge(forIndex: index) {
// I found that it's easier to transform the layer here instead of the view, because it's instantaneous
let badgeViewLayer = badgeView.layer
badgeViewLayer.transform = CATransform3DMakeTranslation(BadgeTranslationX, BadgeTranslationY, 1) // you can change the position of the badge with this
guard let badgeViewImageView = badgeView.subviews.first else {
print("\(#file):\(#function):\(#line): No image view inside the badge view")
return
}
badgeViewImageView.clipsToBounds = true // this is important, otherwise you may get artifacts when working with rounded corners etc.
let layer = badgeViewImageView.layer
layer.borderColor = UIColor.white.cgColor
layer.borderWidth = 2
layer.cornerRadius = 8
}
And here is what you get with the above code:
Of course, the color can be anything:

Resources