I'm new with Compose and declarative programming, and I'm trying to understand it. For learning, after reading tutorials and watching courses, now I'm creating my first app.
I'm creating a compose desktop application with compose multiplatform which will give you the possibility to select a folder from the computer and display all the files inside that folder. I'm launching a JFileChooser
for selecting a folder. When it's selected, a state var is changed and a Box
is filled with Texts representing the names of the files inside that folder. These names are obtained by a function which uses the path returned by JFileChooser
.
The app has a two strange behaviours. First because that screen has a TextField
and if I write inside it, the Box
filled with texts seems to be repainted calling again the function which search for the files (and those can be thousands slowing the app).
The second strange behaviour is that if I open again the JFileChooser
to change the folder, it repaints correctly the Box
getting the file names of that folder, but if I select the same folder selected previously, the Box
is not repainted, and if a file is changed in that folder, it is a problem.
I think both issues are related with declarative compose logic - what might be wrong in each case?
This is the button that displays the JFileChooser:
var listRomsState by remember { mutableStateOf(false) }
Button(onClick = {
folderChosenPath = folderChooser()
if (folderChosenPath != "")
listRomsState = true
}) {
Text(text = "List roms")
}
This is the function that shows the JFileChooser
fun folderChooser(): String {
UIManager.setLookAndFeel("com.sun.java.swing.plaf.windows.WindowsLookAndFeel");
val f = JFileChooser()
f.fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
val result: Int = f.showSaveDialog(null)
if (result == JFileChooser.APPROVE_OPTION) {
return f.selectedFile.path
} else {
return ""
}
}
Below the button that displays the file chooser is the list with the file names:
if (listRomsState) {
RomsList(File(folderChosenPath))
}
This is the RomsList function:
@Composable
fun RomsList(folder: File) {
Box (
modifier = Modifier.fillMaxSize().border(1.dp, Color.LightGray)
) {
LazyColumn(
Modifier.fillMaxSize().padding(top = 5.dp, end = 8.dp)
){
var romsList = getRomsFromFolder(folder)
items(romsList.size) {
Box (
modifier = Modifier.padding(5.dp, 0.dp, 5.dp, 0.dp).fillMaxWidth(),
contentAlignment = Alignment.CenterStart
) {
Row (horizontalArrangement = Arrangement.spacedBy(5.dp)){
Text(text = "" + (it+1), modifier = Modifier.weight(0.6f).background(color = Color(0, 0, 0, 20)))
Text(text = romsList[it].title, modifier = Modifier.weight(9.4f).background(color = Color(0, 0, 0, 20)))
}
}
Spacer(modifier = Modifier.height(5.dp))
}
}
}
}
This is the function that recursively gets all the file names of a folder:
fun getRomsFromFolder(curDir: File? = File(".")): MutableList<Rom> {
var romsList = mutableListOf<Rom>()
val filesList = curDir?.listFiles()
filesList?.let {
for (f in filesList) {
if (f.isDirectory) romsList.addAll(getRomsFromFolder(f))
if (f.isFile) {
romsList.add(Rom(f.name))
}
}
}
return romsList
}
The important mechanism you need to get used to is recomposition. I am not sure how it works in Compose Multiplatform, but in android recompositions depend on state changes. When composable function contains some kind of state, it automatically listens for it changes and gets recomposed on mutations.
During recomposition your UI elements of a composable, which is being recomposed, get drawn again with new values to represent actual states.
So, explaining your strange behaviours:
The app has a two strange behaviours. First because that screen has a TextField and if I write inside it, the Box filled with texts seems to be repainted calling again the function which search for the files (and those can be thousands slowing the app).
This happens because you are changing the state - the text value of a text field. So the recomposition happens. The solution is to move all logic, that does need to get called again to separate composable. The explanation is present here
The second strange behaviour is that if I open again the JFileChooser to change the folder, it repaints correctly the Box getting the file names of that folder, but if I select the same folder selected previously, the Box is not repainted, and if a file is changed in that folder, it is a problem.
This is the case, when recomposition is needed but does not happen. This happens because the composable RomsList
does not contains folder state and therefore does not recompose automatically on folder change.
You probably should not pass folder
as a simple parameter. You should remember it as a state.
val folderState by remember { mutableStateOf(folder) }
However, since your folder comes to the composable from another function, one of the solutions is to create such state in the caller function and mark the function as @Composable
. Recompositions are able to go downwards, so all nested composables of a composable will be recomposed on latter's recompositions.
I created a very simple composable that's identical to your compose structure based on our discussion.
Consider this code:
@Composable
fun MyTvScreen() {
Log.e("MyComposableSample", "MyTvScreen Recomposed")
var fileName by remember {
mutableStateOf("")
}
val someFile = File("")
Column {
TextField(
value = fileName,
onValueChange = {
fileName = it
}
)
RomsList(file = someFile)
}
}
@Composable
fun RomsList(file: File) {
Log.e("MyComposableSample", "RomsList Recomposed $file")
}
when you run this, and when you typed anything in the TextField, both composable will re-compose and produces this log output when you type something on the textfield
E/MyComposableSample: MyTvScreen Recomposed // initial composition
E/MyComposableSample: RomsList Recomposed // initial composition
// succeeding re-compositions when you typed something in the TextField
E/MyComposableSample: MyTvScreen Recomposed
E/MyComposableSample: RomsList Recomposed
E/MyComposableSample: MyTvScreen Recomposed
E/MyComposableSample: RomsList Recomposed
From this article I run a command and found that java.io.File
is not a stable type. And your RomsList
composable is not skippable
, meaning, everytime the parent composable re-composes it will also re-compose RomsList
restartable fun RomsList(
unstable file: File
)
Now that we know File
is not a @Stable
type and we have no control over its API, I wrapped it in a custom data class like this, and modified the call-sites
@Stable
data class FileWrapper(
val file: File
)
So modifying all the codes above using FileWrapper
.
@Composable
fun MyTvScreen() {
...
val someFile = FileWrapper(File(""))
Column {
TextField(
...
)
RomsList(fileWrapper = someFile)
}
}
@Composable
fun RomsList(fileWrapper: FileWrapper) {
Log.e("MyComposableSample", "RomsList Recomposed ${fileWrapper.file}")
}
Produces the log output below.
E/MyComposableSample: MyTvScreen Recomposed // initial composition
E/MyComposableSample: RomsList Recomposed // initial composition
// succeeding re-compositions when you typed something in the TextField
E/MyComposableSample: MyTvScreen Recomposed
E/MyComposableSample: MyTvScreen Recomposed
E/MyComposableSample: MyTvScreen Recomposed
E/MyComposableSample: MyTvScreen Recomposed
E/MyComposableSample: MyTvScreen Recomposed
And the running the gradle command, the report was this, RomsList
is now skippable
with a stable parameter, so when its parent composable recomposes RomsList
will get skipped.
restartable skippable fun RomsList(
stable fileWrapper: FileWrapper
)
For your second issue, would you mind trying to replace the mutableList
withmutableStateList()
? which creates an instance of a SnapshotStateList
?, this way any changes to the list will guarantee an update to a composable that reads it
fun getRomsFromFolder(curDir: File? = File(".")): MutableList<Rom> {
var romsList = mutableStateListOf<Rom>() // here
...
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With