2

I'm developing a Kotlin application with JavaFX, and I've made a wrapper of a "custom ListView". The operation is more or less the following:

The ListView shows a list of clients that is loaded when the application starts. The user has, among many options, two that are relevant: enable and disable the selected client. Disabled clients are highlighted with a special css style, from an external file.

The problem I'm having is that when I disable a client, not only does the css style apply to this client, but also to one of the extra empty rows, and when I press the button to re-enable the same client, I have to do it twice. (once to unstyle the empty row, once to unstyle the correct row).

The application is very large and takes the data of each client from a database, but I've simplified the code to show it here.

TestApplication.kt:

class TestApplication : Application() {

    override fun start(stage: Stage) {

        val fxmlLoader = FXMLLoader(TestApplication::class.java.getResource("test-view.fxml"))
        val scene = Scene(fxmlLoader.load())
        stage.title = "Test application"
        stage.scene = scene
        scene.stylesheets.add(this::class.java.getResource("custom.css").toExternalForm())

        stage.show()
    }
}

fun main() {
    Application.launch(TestApplication::class.java)
}

File TestController.kt:


class TestController: Initializable {

    private lateinit var objListViewWrapper: ListViewWrapper

    // This MutableList is sent to a model (not included here) to update the customers database table:
    private var listCustomers: MutableList<MutableMap<String, String>> = getCustomers()

    @FXML
    lateinit var lvCustomers: ListView<CustomerData>

    override fun initialize(p0: URL?, p1: ResourceBundle?) {
        objListViewWrapper = ListViewWrapper(lvCustomers, "id", "text")

        generateListView()
    }

    private fun generateListView(){
        objListViewWrapper.clearListView()
        objListViewWrapper.populateListView(listCustomers)
    }

    private fun updateListViewContent(){
        listCustomers.forEach { customer ->

            val id = customer.getValue("id")
            val active = customer.getValue("active").toInt()

            lvCustomers.items.forEach { lvCustomer ->
                if(lvCustomer.getId() == id){
                    lvCustomer.setActive(active)
                }
            }

        }

        objListViewWrapper.clearSelection()

    }

    private fun getCustomers(): MutableList<MutableMap<String, String>>{
        var li: MutableList<MutableMap<String, String>> = mutableListOf()

        val listNames = listOf(
            "John Doe", "Jan Jansen",
            "Osman Whitfield", "Jane Smith",
            "Joe Bloggs", "Michalina Cottrell"
        )

        listNames.forEachIndexed { index, name ->
            val mp: MutableMap<String, String> = mutableMapOf()

            mp["id"] = (1 + index).toString()
            mp["text"] = name.uppercase()
            mp["active"] = "1"

            li.add(mp)
        }

        return li
    }

    @FXML
    fun disableCustomer(){
        val selectedItem = objListViewWrapper.getSelectedItem()

        if(selectedItem == null){
            println("Error: You must select a customer.")
            return
        }

        val idCustomer = selectedItem.getId()

        listCustomers.forEach { mutableMap ->
            if(idCustomer == mutableMap.getValue("id")){
                mutableMap["active"] = "0"
            }
        }

        updateListViewContent()
    }

    @FXML
    fun enableCustomer(){
        val selectedItem = objListViewWrapper.getSelectedItem()

        if(selectedItem == null){
            println("Error: You must select a customer.")
            return
        }

        val idCustomer = selectedItem.getId()

        listCustomers.forEach { mutableMap ->
            if(idCustomer == mutableMap.getValue("id")){
                mutableMap["active"] = "1"
            }
        }

        updateListViewContent()
    }
    
}


File ListViewWrapper.kt:


class CustomerData(id: String, text: String, active: Int) {

    var id: SimpleStringProperty = SimpleStringProperty(id)
    var text: SimpleStringProperty = SimpleStringProperty(text)
    var active: SimpleIntegerProperty = SimpleIntegerProperty(active)

    fun getId(): String {
        return this.id.get()
    }

    fun setId(id: String) {
        this.id.set(id)
    }

    fun idProperty(): SimpleStringProperty {
        return id
    }

    fun getText(): String {
        return this.text.get()
    }

    fun setText(text: String) {
        this.text.set(text)
    }

    fun textProperty(): SimpleStringProperty {
        return text
    }

    fun getActive(): Int {
        return this.active.get()
    }

    fun setActive(active: Int) {
        this.active.set(active)
    }

    fun activeProperty(): SimpleIntegerProperty {
        return active
    }

}


class ListViewWrapper(listView: ListView<CustomerData>, idField: String = "id", textField: String = "text") {

    private var listView = listView
    private var idField = idField
    private var textField = textField

    private var obsList: ObservableList<CustomerData> = FXCollections.observableArrayList()
    private val cssDisabledRow = "row-disabled"

    init {

        listView.setCellFactory { lv ->
            object : ListCell<CustomerData?>() {

                override fun updateItem(obj: CustomerData?, empty: Boolean) {
                    super.updateItem(obj, empty)

                    if (empty || obj == null) {
                        text = null
                        style = null
                        graphic = null

                    } else if(obj.getActive() == 0) {
                        text = obj.getText()

                        if(!styleClass.contains(cssDisabledRow)){
                            styleClass.add(cssDisabledRow)
                        }

                    } else {
                        text = obj.getText()

                        if(styleClass.contains(cssDisabledRow)){
                            styleClass.remove(cssDisabledRow)
                        }
                    }
                }

            }
        }
    }

    fun clearListView(){
        obsList.clear()
    }

    fun clearSelection(){
        listView.selectionModel.clearSelection()
    }

    fun populateListView(li: MutableList<MutableMap<String, String>>){
        if(li.isEmpty()){
            println("The list of items is empty.")
            return
        }

        li.forEach {
            val id = it.getValue(idField)
            val text = it.getValue(textField)
            val active = it.getValue("active").toInt()

            obsList.add(CustomerData(id, text, active))

        }

        listView.items.setAll(obsList)

    }

    fun getSelectedItem(): CustomerData? {
        return listView.selectionModel.selectedItem
    }

}

File test-view.fxml:


<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.geometry.Insets?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.ListView?>
<?import javafx.scene.layout.AnchorPane?>
<?import javafx.scene.layout.HBox?>

<AnchorPane maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" prefHeight="350.0" prefWidth="400.0" xmlns="http://javafx.com/javafx/16" xmlns:fx="http://javafx.com/fxml/1" fx:controller="com.example.testproject.TestController">
   <padding>
      <Insets bottom="4.0" left="4.0" right="4.0" top="4.0" />
   </padding>
   <children>
      <ListView fx:id="lvCustomers" layoutX="48.0" layoutY="42.0" prefHeight="200.0" prefWidth="200.0" AnchorPane.bottomAnchor="36.0" AnchorPane.leftAnchor="0.0" AnchorPane.rightAnchor="0.0" AnchorPane.topAnchor="0.0" />
      <HBox alignment="CENTER" layoutX="14.0" layoutY="250.0" prefHeight="32.0" spacing="4.0" AnchorPane.bottomAnchor="0.0" AnchorPane.leftAnchor="0.0" AnchorPane.rightAnchor="0.0">
         <children>
            <Button fx:id="btnDisable" mnemonicParsing="false" onAction="#disableCustomer" prefWidth="100.0" text="Disable" />
            <Button fx:id="btnEnable" mnemonicParsing="false" onAction="#enableCustomer" prefWidth="100.0" text="Enable" />
         </children>
      </HBox>
   </children>
</AnchorPane>

File custom.css:


.list-cell:filled:hover {
    -fx-background-color: #0093ff;
    -fx-text-fill: white;
}

.row-disabled{
    -fx-background-color: #c2c2c2 !important;
    -fx-text-fill: red;
}

.list-cell:filled:selected:focused, .list-cell:filled:selected {
    -fx-background-color: linear-gradient(#328BDB 0%, #207BCF 25%, #1973C9 75%, #0A65BF 100%);
    -fx-text-fill: white;
}

Screenshots:

disabled rows in dark gray so that they are more noticeable here

EDIT:

Thanks to jewelsea's comments I've realized how to work with the ListView. The ListView didn't need to be regenerated, it just had to update the "active" property. I've edited the code with the modifications to make it work. I know the code is long, and can be cumbersome for those trying to find the problem, but I think this will clear up any doubts newbies have.

  • 1
    I don’t know Kotlin, so I can’t answer this. I think you could have done more to minimize this to create a [mcve]. Why does your MainTestController appear to subclass Application? (Maybe it doesn’t and I just can’t understand the Kotlin code). A controller should never be an application. – jewelsea Jan 14 '22 at 19:59
  • 1
    [styleClass](https://openjfx.io/javadoc/17/javafx.graphics/javafx/css/Styleable.html#getStyleClass()) is a list, not a set. If you add an item to the list twice, it will be in the list twice. [remove](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/ArrayList.html#remove(java.lang.Object)) will only remove the first occurrence, not all occurrences. Perhaps you should check if the class is not already present in the list before adding it. – jewelsea Jan 14 '22 at 20:41
  • 1
    Generally, it is unusual to have a call [refresh()](https://openjfx.io/javadoc/17/javafx.controls/javafx/scene/control/ListView.html#refresh()) on a listView. Usually, the listview will refresh automatically. If it does not, then perhaps you have other issues with your application. – jewelsea Jan 14 '22 at 20:46
  • Your CustomListView and CustomListViewObject are both not actually list views, which is very confusing. – jewelsea Jan 14 '22 at 20:48
  • 2
    Perhaps you also need an [extractor](https://stackoverflow.com/questions/31687642/callback-and-extractors-for-javafx-observablelist). I am guessing there are multiple issues. A minimum example, in addition to being minimal, would also be complete, including CSS and FXML. – jewelsea Jan 14 '22 at 20:58
  • 1
    Thank you jewelsea. I've made the corrections, removed some things and modified it to make it easier to understand. It's still long and not at all pleasant for those looking for bugs, but I think it will help newbies, as many other long answers on this site have helped me. – Germán Fernández Jan 15 '22 at 00:08
  • 1
    The updated code is much improved. If you have solved your problem (I think so from your update, but am not sure), then you can [add an answer](https://stackoverflow.com/help/self-answer), which is better than putting a solution in a question. You don’t need to replicate all the question code on the answer, just the relevant bits that fix the problem, with an explanation. – jewelsea Jan 15 '22 at 00:34
  • 1
    Thanks for pointing it out. I've answered my question. – Germán Fernández Jan 15 '22 at 04:51

1 Answers1

2

I'll answer my question and take the opportunity to clarify how I solved it:

As I said, the problem was that I was regenerating the "ObservableList" that contained the customer data, instead of just updating the "active" property.

Earlier in the code, what was done was to use the generateListView() method to remove the items from the ListView and repopulate it again, each time a client was disabled or enabled. To fix it I created the updateListViewContent() method. As you can see, this method only updates the "active" property, taking the value assigned inside listCustomers.

private fun updateListViewContent(){
    listCustomers.forEach { customer ->

        val id = customer.getValue("id")
        val active = customer.getValue("active").toInt()

        lvCustomers.items.forEach { lvCustomer ->
            if(lvCustomer.getId() == id){
                lvCustomer.setActive(active)
            }
        }

    }

    objListViewWrapper.clearSelection()
}

listCustomers is a list that contains maps with the data of each customer, and lvCustomers is the ListView that contains the items of type CustomerData. If the id of the client that has been disabled or enabled matches one of those that exists in the ListView, the active property is updated.

The active property can be changed directly by clicking the buttons. This example doesn't really need listCustomers. This is a variable that I use in the original code to store extra data, which does not belong to this ListView, and which is collected in forms presented in a "step-by-step" way.