Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Understanding Compose declarative logic

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
}
like image 965
NullPointerException Avatar asked Oct 19 '25 12:10

NullPointerException


2 Answers

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.

like image 175
Steyrix Avatar answered Oct 22 '25 05:10

Steyrix


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
...
like image 41
z.g.y Avatar answered Oct 22 '25 06:10

z.g.y