Introduction

This is the second article in a series that started with Understanding ListView. If you’re new to ListView you might want to take a look at that article first, as it explains some important concepts about how ListView works, and we’ll be building on that information in this article.

ListCell as a Layout

ListView really starts to shine when you look at it as an alternative to TableView to display complex data models. ListView is especially good when you have sparsely populated fields in your data, where TableView would gobble up valuable screen real estate on largely empty columns. It can also compress the display of information into a much tighter space and improve the visibility of certain data. Even more, ListView is extremely powerful when you look at it as a container for layouts that using the power of VirtualFlow to handle large amounts of data.

For the example of this, I found a really nifty resource on the web - a JSON file filled with thousands and thousands of clues and answers from the game show, “Jeopardy!”. The data for this is highly structured, with information about the show that each question appeared on, what round it came from, the question value and so on. It also has good potential to be interactive, which further shows how ListView can be much, much more than just a “List”.

Before we look at any code, let’s take a look at what the final product should look like:

Jeopardy! Screen Shot

Yeah, it looks a little busy, but that’s the point (as a matter of fact, it looks like a bad 1990’s website). Each row on the ListView here has an image to show what round the question came from, and then a section that shows the question itself, with a heading that’s holds the value of the question and its category. On the right side there’s some information about the show number and the date and a Button that says, “Reveal Answer”. When the user clicks the Button, it disappears and is replaced with the answer and a pair of RadioButtons that say, “Right” and “Wrong”. When you click one of the RadioButtons it changes the background colour of the question heading to either red or green.

The whole thing starts to look like the visualization of an interface for a practice application for Jeopardy! contestants. There’s no database functionality behind it to allow subsets of the questions to be selected, or to track the results, but the visuals are there.

You can see from this that each row works like its own application screen, yet they are contained within the ListView and it really does correspond to the visual display of a List of data.

I switched over to Kotlin for this example. It’s just easier to do some of the layout in Kotlin, and the JSON handling is absolutely trivial using the built-in Kotlin serialization library. If you need to know more about Kotlin and JavaFX, you can check out the articles that I’ve written here. Even if you’re not familar with Kotlin you should be able to figure out what’s going on in these code samples as it’s pretty similar to Java in many ways.

The Data

The original data for this was a 50MB JSON file. I stripped out the first 10KB or so of data to use as a sample. The JSON data looks like this:

{
    "category": "HISTORY",
    "air_date": "2004-12-31",
    "question": "'This Asian political party was founded in 1885 with \"Indian National\" as part of its name'",
    "value": "$1000",
    "answer": "the Congress Party",
    "round": "Jeopardy!",
    "show_number": "4680"
}

I want to point out that this is the state that data came. If you’re offended that the field names are backwards, and the “question” is actually the Jeopardy! “answer” and response is in the “answer” field, or that the response is not in the form of a question…

Well, so am I. But I wasn’t about to fiddle with the JSON data to fix it.

And I de-serialized it into a data object that has the same wrong field names:

@Serializable
data class JeopardyQuestion(
    val category: String = "",
    @SerialName("air_date") val airDate: String = "",
    val question: String = "",
    val value: String = "",
    val answer: String = "",
    val round: String = "",
    @SerialName("show_number") val showNumber: Int = 0
)

You can see how simple the Kotlin serialization library is to use. In this case, I just left everything as Strings because I’m not doing anything other than displaying it as Strings on the screen (except for round, which we’ll get to). If I was going to search or sort on value or airDate then I’d have to convert them to other, more appropriate, data types. But this works for the demo.

Let’s take a look at a sample data model:

class DemoListModel1(question: JeopardyQuestion) {
    val category: StringProperty = SimpleStringProperty(question.category)
    val airDate: StringProperty = SimpleStringProperty(question.airDate)
    val question: StringProperty = SimpleStringProperty(question.question.removeSurrounding("'"))
    val questionValue: StringProperty = SimpleStringProperty(question.value)
    val answer: StringProperty = SimpleStringProperty(question.answer)
    val round: ObjectProperty<QuestionType> = SimpleObjectProperty(QuestionType.fromString(question.round))
    val showNumber: IntegerProperty = SimpleIntegerProperty(question.showNumber)
    val revealed: BooleanProperty = SimpleBooleanProperty(false)
    val gotItRight: BooleanProperty = SimpleBooleanProperty(false)
    val gotItWrong: BooleanProperty = SimpleBooleanProperty(false)

    public fun unbindFrom(other: DemoListModel1?) {
        other?.also { otherModel: DemoListModel1 ->
            revealed.unbindBidirectional(otherModel.revealed)
            gotItRight.unbindBidirectional(otherModel.gotItRight)
            gotItWrong.unbindBidirectional(otherModel.gotItWrong)
        }
        question.unbind()
        round.unbind()
        answer.unbind()
        category.unbind()
        questionValue.unbind()
        showNumber.unbind()
        airDate.unbind()
    }

    public fun bindTo(other: DemoListModel1) {
        question.bind(other.question)
        round.bind(other.round)
        questionValue.bind(other.questionValue)
        category.bind(other.category)
        answer.bind(other.answer)
        showNumber.bind(other.showNumber)
        airDate.bind(other.airDate)
        revealed.bindBidirectional(other.revealed)
        gotItRight.bindBidirectional(other.gotItRight)
        gotItWrong.bindBidirectional(other.gotItWrong)
    }
}

The constructor allows us to create DemoListModel1 from JeopardyQuestion, simply putting the values from JeopardyQuestion into the corresponding Property fields in DemoListModel1. There are 3 BooleanProperty fields that aren’t populated from JeopardyQuestion and these are all used to control aspects of the display in each ListView row.

After that, we have two methods that allow the individual fields in two DemoListModel1s to be bound together. Since we’re going to use bidirectional binding on those 3 BooleanProperty fields, we need to pass the previously bound DemoListModel so that the unbinding can happen. These are basically convenience methods that allow this logic to be kept out of our custom ListCell. We’ll see this in a little bit.

So let’s look at that:

    val format = Json { ignoreUnknownKeys = true }

    public fun getData(): List<JeopardyQuestion> {
        val fileData: String = DemoApplication1::class.java.getResource("JEOPARDY_QUESTIONS.json")?.readText() ?: ""
        val data: List<JeopardyQuestion> = format.decodeFromString<List<JeopardyQuestion>>(fileData)
        println("The Data $data")
        return data.shuffled()
    }

    public fun loadModel(data: List<JeopardyQuestion>) = data.map { DemoListModel1(it) }
}

I left it as two methods because, if there was a database or service behind this, you’d use a Task in the Controller and call the getData() function from the background thread, and loadModel() from the FXAT. That’s just habit, but I didn’t bother with the Task since I’m only using a small dataset from a file resource.

The Layout and the Application

The layout for the application is just the ListView:

class DemoViewBuilder1(val data: ObservableList<DemoListModel1>) : Builder<Region> {
    override fun build(): Region {
        return ListView<DemoListModel1>().apply {
            items = data
            cellFactory = Callback { JeopardyCell() }
        }
    }
}

Here’s the Controller:

class DemoController1() {
    val model = FXCollections.observableArrayList<DemoListModel1>()
    val interactor = DemoInteractor1()
    val viewBuilder = DemoViewBuilder1(model)

    init {
        model.setAll(interactor.loadModel(interactor.getData()))
    }

    public fun getView() = viewBuilder.build()
}

And the Application itself:

class DemoApplication1 : Application() {

    val format = Json { ignoreUnknownKeys = true }
    override fun start(stage: Stage) {
        val scene = Scene(DemoController1().getView(), 820.0, 840.0)
        scene.addWidgetStyles()
        stage.title = "Jeopardy Questions!"
        stage.scene = scene
        stage.show()
    }
}

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

Finally, we’ll look at the enum for the “round”, which I called QuestionType:

enum class QuestionType(filePath: String) {
    JEOPARDY("Jeopardy_S38_OnSetLogo.png"),
    DOUBLE_JEOPARDY("JS37TOC-S38_DJ.png"),
    FINAL_JEOPARDY("JS37TOC-S38_FJ.png");

    val image = Image(QuestionType::class.java.getResource(filePath)?.toExternalForm())

    companion object {
        public fun fromString(stringName: String) = when (stringName) {
            "Jeopardy!" -> JEOPARDY
            "Double Jeopardy!" -> DOUBLE_JEOPARDY
            "Final Jeopardy!" -> FINAL_JEOPARDY
            else -> JEOPARDY
        }
    }
}

This is very similar to the Animal enum earlier. However, we’ve added a static fromString() function to make the mapping from the JSON String values easier.

The Custom ListCell

You can see from this that just about all of the components of the application are very straight-forward and simple. The Interactor gets the data and loads it into the Presentation Model, which is just a list of JavaFX Property POJO’s, the layout is just a ListView and the Controller doesn’t do much except instantiate things and get the Interactor started on fetching the data.

Which means that all the cool stuff must be in the custom ListCell! First let’s take another look at Cell.updateItem()

About Cell.updateItem() (Part 2 if you’ve read the Previous Article)

Let’s talk about what Cell.updateItem() actually does. First, here’s the source code for it:

 protected void updateItem(T item, boolean empty) {
    setItem(item);
    setEmpty(empty);
    if (empty && isSelected()) {
        updateSelected(false);
    }
}

That’s not much, actually. What do setItem(), setEmpty() and updateSelected() do? Here’s the setItem() code:

private ObjectProperty<T> item = new SimpleObjectProperty<T>(this, "item");

public final ObjectProperty<T> itemProperty() { return item; }

/**
* Sets the item to the given value - should not be called directly as the
* item is managed by the virtualized control.
* @param value the new data value to set in this cell
*/
public final void setItem(T value) { item.set(value); }

OK. From this we can see that there’s an ObjectProperty<T> in the Cell called item. T is just the generic type of the data that we’re putting in the Cell. And Cell.updateItem() just takes whatever new data is being put into the cell, and loads it into that property. Which explains the name, updateItem() - because that’s what it does. Along the way, it also updates empty and selected, which are also properties of the Cell.

I think that this is the way to view Cell.updateItem(). It’s data update function, and it should just do data update stuff. More specifically, item, empty and selected form the Presentation Model for the Cell, and that’s all that Cell.updateItem() should be doing, even when we override it - updating the Presentation Model.

It’s tempting at this point to consider item to be the Presentation Model for the Cell and to build a layout based on that. That’s not a bad idea, except for one thing: Since item is actually a Property wrapper around that Presentation Model, it will change constantly as the application runs. This is fundamentally different from the way we create standard Reactive layouts, where the Presentation Model is immutable, and only the values in its properties change.

This makes it very difficult to simply Bind your data properties in the Nodes in your layout to properties in the object contained in item, since whenever the value in item changes all of the field properties inside it change their references. So, if you do something like this:

  val someText = Text().apply{
      textProperty.bind(getItem().abcProperty())
  }

Then you are going to have problems. The first of which is that getItem() is going to return Null when this code is run as part of the constructor of your ListCell, that’s because nothing will have been loaded into the ListCell yet.

But even if you could get around that somehow, as soon as a new value is loaded into item, then getItem().abcProperty() would return an entirely new Property, and your binding on someText.textProperty() would become invalid.

Clearly, that’s a problem.

A better approach is to create an immutable Presentation Model of the same type as the ListCell data type (in other words, the same type as the object contained in item), and bind your Node properties to that object. Then manage the values in its fields, updating them as item changes.

And that brings us back to Cell.updateItem().

Since we know that any changes to item have to go through here, we can treat it as a virtual ChangeListener and use it to reset our immutable Presentation Model. Also, since we want our layout to respond to any changes in the underlying data in real-time, we Bind the fields in our Presentation Model to the fields in the new item. Let’s take a look at that:

public override fun updateItem(item: DemoListModel1?, isEmpty: Boolean) {
    model.unbindFrom(this.item)
    super.updateItem(item, isEmpty)
    graphic = if (!isEmpty && (item != null)) {
        model.bindTo(item)
        layout
    } else {
        null
    }
}

In this code snippet, model is the immutable Presentation Model in our ListCell. Now you can see the reason that we put the bindTo() and unbindFrom() methods into DemoListModel1. The other thing to note about this is that we are unbinding before we call super.updateItem(). This means that we are really treating updateItem() as a ChangeListener since we’re accessing both the old value in item and the new value that’s going in.

In Kotlin, if is also an expression, and the value of the last line in a block is the return value of that block. The if statement here will either populate graphic with Null or our layout depending on the the condition.

The Layout

Honestly, we could just stop this article right here, since you’ve seen all of the code specific to manipulating the data into and out of the ListCell. At this point the layout of the ListCell is just like any other layout, with an immutable Presentation Model that we bind to the various properties of our layout’s Nodes. But we’ll take a look at it anyway.

Here’s the entire ListCell:

class JeopardyCell() : ListCell<DemoListModel1>() {

    private val model = DemoListModel1(JeopardyQuestion())
    private val layout: Region

    companion object {
        val wrongAnswerPS: PseudoClass = PseudoClass.getPseudoClass("wrong")
        val rightAnswerPS: PseudoClass = PseudoClass.getPseudoClass("right")
    }

    init {
        layout = HBox(10.0, createImageBox(), createQuestionPanel(), createRightPanel()) addStyle "cell-box"
        text = null
    }

    private fun createImageBox() = StackPane(
        imageViewOf(Bindings.createObjectBinding({ model.round.value.image }, model.round))
    ) addStyle "image-box"

    private fun createQuestionPanel() =
        VBox(4.0, createHeadingBox(), wrappingTextOf(model.question, 440.0)) withMinWidth 450.0

    private fun createHeadingBox() = HBox(10.0).apply {
        children += Label() styleAs "question-value" bindTo model.questionValue
        children += headingOf(model.category)
        model.gotItRight.addListener(InvalidationListener {
            this.pseudoClassStateChanged(
                rightAnswerPS,
                model.gotItRight.value
            )
        })
        model.gotItWrong.addListener(InvalidationListener {
            this.pseudoClassStateChanged(
                wrongAnswerPS,
                model.gotItWrong.value
            )
        })
    } addStyle "heading-box"

    private fun createRightPanel() = VBox(2.0, createShowInfo(), createAnswerPane())

    private fun createShowInfo() = TextFlow(
        styledTextOf("Show ", "show-info"),
        styledTextOf(model.showNumber.asString(), "show-info"),
        styledTextOf(": ", "show-info"),
        styledTextOf(model.airDate, "show-info")
    )

    private fun createAnswerPane() = StackPane().apply {
        children += Button("Reveal Answer").apply {
            onAction = EventHandler { model.revealed.value = true }
            visibleProperty().bind(model.revealed.not())
        } styleAs "answer-button"
        children += createRevealAnswerBox()
        alignment = Pos.CENTER_LEFT
    }

    private fun createRevealAnswerBox() = VBox(2.0).apply {
        children += (Label() styleAs "label-answer" bindTo model.answer).apply {
            maxWidth = 180.0
            minWidth = 180.0
            isWrapText = true
        }
        children += createRightWrongBox()
        visibleProperty().bind(model.revealed)
    }


    private fun createRightWrongBox() = HBox(6.0).apply {
        val rightRB = RadioButton("Right")
        val wrongRB = RadioButton("Wrong")
        rightRB.selectedProperty().bindBidirectional(model.gotItRight)
        wrongRB.selectedProperty().bindBidirectional(model.gotItWrong)
        ToggleGroup().apply {
            toggles += listOf(rightRB, wrongRB)
        }
        children += listOf(rightRB, wrongRB)
    }


    public override fun updateItem(item: DemoListModel1?, isEmpty: Boolean) {
        model.unbindFrom(this.item)
        super.updateItem(item, isEmpty)
        graphic = if (!isEmpty && (item != null)) {
            model.bindTo(item)
            layout
        } else {
            null
        }
    }
}

This layout code makes extensive use of a library of factory methods and extension methods (which is a Kotlin thing) to compress the layout code so that it’s just layout, with as much configuration code as possible off somewhere else where we don’t need to worry about it. Most of the meanings are intuitively obvious (I hope). For instance: styledTextOf(StringProperty, String) creates a Text bound to the specified StringProperty and with the specified CSS selector appended to its StyleClass list. Even if you don’t really understand the syntax, you should be able to get the idea about what it’s doing.

The main layout is an HBox with 3 sections: an image, a “Question Box” and then a Pane with show info and the interactive answer stuff. These are all built in their own builder methods.

The PseudoClass code is there to change the background colour of the header box to correspond to whether or not the question was answered correctly.

Since the “Show Information” section is composed of some static text and some bound values, a TextFlow was used. This seemlessly puts the various pieces together into a single element.

The interactive part is where most of the complexity lies. Basically, it’s a StackPane with two children, only one of which is visible at any given time. The visibility is controlled by model.revealed; the Button is visible when it’s false and the revealAnswerBox when it’s true. Clicking the Button does nothing more than set model.revealed to true.

The revealAnswerBox is just a VBox with a Label showing the answer and an HBox holding the RadioButtons. The RadioButtons are in a ToggleGroup so that only one can be selected at any given time. Each RadioButton has its selected property bound to one of model.gotItRight or model.gotItWrong. Selecting one of these RadioButtons will trigger a change in one or both of the PseudoClasses that control the background colour of the question heading box.

What’s important to note here is that all three of the interactive elements are bidirectionally bound (well, the Button is an action) to the model properties which are, in turn, bidirectionally bound to the item properties. And the item is actually a reference to one of the DemoListModel1 objects in the list backing the ListView. This means that clicking the Button or selecting one of the RadioButtons triggers a permanent change to the related field in one of the items in the ObservableList.

And this is important because when the user scrolls that row out of the viewport, then that ListCell is released to be reused for another row. But if the user scrolls that row back into the viewport, then those updated values are going to be loaded back into the ListCell through updateItem().

Here’s the Stylesheet:

.root {
   -theme-colour: #3612dF;
   -contrast-colour: #8F2F2F;
   -complementary1: #3c91e6;
   -complementary2: #daebfd;
   -wrong-colour: #de9dc0;
   -right-colour: #76a975;
   -image-background-colour: #17104d;
}

.label-heading {
  -fx-text-fill: -theme-colour;
  -fx-font-weight: bold;
  -fx-font-size: 20px;
}

.question-value {
  -fx-text-fill: -contrast-colour;
  -fx-font-weight: bold;
  -fx-font-size: 20px;
}

.show-info {
  -fx-fill: -contrast-colour;
  -fx-font-weight: bold;
  -fx-font-size: 14px;
}

.label-answer {
  -fx-text-fill: -theme-colour;
  -fx-font-weight: bold;
  -fx-font-size: 15px;
}

.heading-box {
  -fx-border-color: -theme-colour;
  -fx-border-radius: 4px;
  -fx-padding: 2px;
  -fx-background-color: -complementary2;
  -fx-background-radius: 4px;
}

.heading-box:wrong {
  -fx-background-color: -wrong-colour;
}

.heading-box:right {
  -fx-background-color: -right-colour;
}

.image-box {
  -fx-background-color: -image-background-colour;
}

.cell-box {
  -fx-border-color: -theme-colour;
  -fx-border-radius: 2px;
  -fx-padding: 2px;
}

.answer-button {
    -fx-background-color:
        linear-gradient(-theme-colour, -contrast-colour),
        radial-gradient(center 50% -10%, radius 200%, -theme-colour 45%, -complementary1 50%);
    -fx-background-radius: 6, 5;
    -fx-background-insets: 0, 1;
    -fx-effect: dropshadow( three-pass-box , rgba(0,0,0,0.4) , 5, 0.0 , 0 , 1 );
    -fx-text-fill: #F9CCA2;
}

Conclusion

The point of this tutorial is to help you to see a ListView as a container for a reusable layout allowing it to display a potentially large amount of date - not just as a vehicle to show a “list” of stuff. Essentially, this reusable layout is no different from any other kind of Reactive layout that you might create, except that you need to deal with the issue of loading new Presentation Models into it as cells are reused when they scroll into the viewport.

Once you see ListView in this light, it opens up a whole world of possibilities for interesting GUI designs. And it frees you from the spreadsheet-like confines of TableView.

Categories:

Updated: