Channels ▼

Eric Bruno

Dr. Dobb's Bloggers

Multi-column/select JavaFX ListView

April 24, 2011

I've been working with and writing about JavaFX 2.0 for the past few months, but I thought I'd revisit JavaFX 1.3.1 this week. Last year, I described how to build a custom JavaFX 1.3.1 control when I built a custom multi-select list control in this article. The result was an easy-to-use list with lots of flexibility in terms of multiple selection — something the stock JavaFX 1.3.1 ListView control doesn't support natively.

However, around the same time, Oracle's Jonathan Giles posted a blog with a different approach to build a multi-select ListView. While my solution was more an example of how to do custom control development for JavaFX, Giles' solution is a much more elegant way to solve this specific issue. In it, he creates a ListView CellFactory, which generates custom ListCells that control their own state, hence achieving the multiple select functionality. The advantage this approach has over mine is that it uses the JavaFX ListView's built-in resource management, which saves on memory consumption for large lists. My solution wasn't that sophisticated.

Multi-column & Multi-select List

A short time after this, I had a need to create a multi-column list with multi-select support. For this solution, I chose to build on Giles' multi-select solution, and use JavaFX CSS skinning, to make what appears to be a multi-column list that scrolls horizontally. See the screenshot below as an example:

MultiSelect Veritcal

The application code to use and display this list is very simple, as shown here:

Stage {
    var scene: Scene;
    title: "Multi-row / Multi-select List"
    scene: scene = Scene {
        width: 640
        height: 480
        content: [
            MultiColumnList {
                horizontal: false
                layoutX: 5
                layoutY: 5
                width: bind scene.width - 10
                height: bind scene.height - 20
                items: for ( i in [1..100] ) {
                          "Item {i}"
                       }
            }
        ]
    }
}

There's also support to provide a callback, so that as selections are made via the mouse, your code can respond. As you'd expect, the control or shift keys can be used to create multiple selections. Subsequently, as cells are deselected, your application is notified via the callback as well.

It all begins with Giles' multi-select list, which I create in my project as its own custom JavaFX control. It's comprised of four source files:

  • MultiSelectListView.fx: a class that extends the built-in ListView, but uses a custom CellFactory class to create the list's contents
  • MultiSelectListCell.fx: a class that extends the built-in ListCell, but adds state to implement part of multiple selection
  • MultiSelectListCellBehavior.fx: a class that, once again, extends the built-in ListCellBehavior class, and handles mouse clicks to finalize the multi-selection behavior
  • MultiSelectionListCellSkin.fx: the control's UI skin class that overrides the default ListViewBehavior for the list, and plugs in the custom cell behavior class, MultiSelectListCellBehavior.fx.

Now on to the code I wrote to make this a multi-column list. In short, to accomplish this functionality, I simply slapped more than one list next to each other. But it's never that easy; some work needs to be done to make the group of lists appear and behave as one. For instance, you need to make sure that as one list scrolls, the others scroll as well, and that as keyboard selection moves up and down, left and right, the selection and focus travels across the separate lists the as it does when moving up or down a single list.

Additionally, you need to remove some of the ListView display details, such as the borders and focus rectangles, so that the grouped of lists truly appear as one. This part was quite simple, thanks to JavaFX CSS skinning support. For instance, the code below turns off the borders, focus rectangle, and the padding around each ListView used in this solution:

var listviews =
    bind for ( list in [1..max(colsPerView, 4) ] ) {
        MultiSelectListView {
            id: "{list}"

            style: "-fx-background-color: null;"
                   "-fx-background-radius: 0;"
                   "-fx-background-insets: 0;"
                   "-fx-border-style: dotted;"
                   "-fx-padding: .00;"

            pannable: false
            
            // ...
        }
    }

I also set the pannable variable to false, so that the user cannot use anything except a single scrollbar (discussed next) to scroll any of the lists. Besides the code to populate the lists and to handle scrolling and selection, this is basically all that's needed. Since the code to populate the lists ensures that there are never more entries in each list than can be displayed, I don't need to worry about individual ListView scrollbars. I do, however, need to add my own scrollbar to control scrolling all of the lists simultaneously.

This is where things get a little more complicated, since I wanted to support both multi-row and multi-column lists. For example, a multi-row list has a scrollbar at the bottom of the lists, and appears to scroll the contents left and right, as shown here:

MultiSelect Horizontal

In this mode, each column (implemented as a single list) contains just enough items to fill its visible space. The remaining columns continue the items in sequence (i.e. items 1 through 10 in the first column, 11 through 20 in the next column, and so on). Each column is a fixed width, so as you resize the containing window, lists are removed or added to fill in the space. Scrolling simply changes the items shown in each of the lists. For instance, when you scroll right in the list above, the first list's contents are swapped to contain items 11 through 20, and so on through each list, to give the illusion of scrolling. Thanks to Java and JavaFX, and the relatively small number of entries that can possibly be made visible, this operation is very, very fast.

A multi-column list, on the other hand, appears like the list shown at the beginning of this blog. Here, the scrollbar is placed on the right, and the number of lists (columns of data) shown is fixed at four. The data is even distributed across these lists, so that when you scroll to the bottom, you see the entries hidden lower within each list. As an analogy, the vertical mode works similar to the UNIX 'ls' command, where data is distributed across a set number of columns, as shown here:

Unix ls command

The horizontal mode works like Windows Explorer in list mode:

Windows list

For the vertical mode, a single scrollbar is placed along the right-most edge of the control:

scroller = ScrollBar {
    vertical: true
    clickToPosition: false
    translateX: bind (multiList.columns * -14) - 1
    translateY: -1
    height: bind (control.height - 10) + 1
    layoutInfo: bind LayoutInfo { width: 14 }
    min: 0
    max: itemsPerColumn * itemHeight
    unitIncrement: bind itemHeight
    blockIncrement: bind itemHeight * ((control.height - 10) / itemHeight)
}

The lists and the scrollbar are each placed in a JavaFX HBox, which manages the layout horizontally. However, since each list in this view would otherwise have a scrollbar of its own, the code translates the X positions so that each list — except the first one — overlaps the previous list by the width of its scrollbar. This hides all of the scrollbars except for the last list. To hide that one, a separate scrollbar (shown in the code above) is placed on top of it. Since it's in the HBox as well, its X coordinate needs to be translated by the number of lists on display. This scrollbar is used to scroll all of the lists together with the following code:

var position = bind scroller.value on replace {
    if ( multiList.horizontal == false ) {
        // Make sure all lists have the same number of entries
        adjustListEntries();

        if ( ignoreScroller == false ) {
            // First, calculate the line within the listviews to make visible
            var itemNum: Float = position / itemHeight;

            // Next, convert it to a ratio of the total number of lines in each list
            itemNum /= (scroller.max / itemHeight);
            for ( list in lists ) {
                var _skin: SkinAdapter = list.skin as SkinAdapter;
                var _flow: VirtualFlow = _skin.rootRegion.content[0] as VirtualFlow;
                _flow.setPosition(itemNum);
            }
        }
    }
}

Now it's time for a quick disclaimer: This code uses undocumented, internal JavaFX calls to make the scrolling work. And since JavaFX 2.0 changes everything about the JavaFX API, this is all but guaranteed not to work when the next version of JavaFX hits the streets. However, scrolling for the horizontal mode, where a single scrollbar is placed in horizontal mode below each of the lists, is much simpler and doesn't require the use of undocumented API calls. Let's quickly explore the algorithms used to populate the lists in both of these modes (horizontal and vertical).

Item Population Algorithms

When the list works in vertical mode (think UNIX 'ls' command), the items for each of the four ListView controls used is calculated as below:

ListView {
    items:  multiList.items [ 
        calcItemsPerColumn() * ( list - 1)
          ..
        min( (calcItemsPerColumn() * list) - 1, multiList.count) ]
    // …
}

It starts with a calculation of the items per column. This is done by dividing the total number of items by the total number of columns, taking into account the potential for divide by zero:

function calcItemsPerColumn(): Integer {
    var count = multiList.count / max(multiList.columns, 1);
    if ( count == 0 ) {
        return 1;
    }

    if ( count * multiList.columns < multiList.items.size() ) {
        return count + 1;
    }

    return count;
}

Next, it calculates the starting index and ending index within the array of items based upon the list number itself (list 1, list 2, and so on). Very simple, actually. For the horizontal mode list (think Windows Explorer list view), it's a little more complex, but not much more:

var itemsPerCol: Integer = bind (control.height / HORIZ_TEXT_ITEM_HEIGHT) as Integer;
ListView {
    items: bind multiList.items[ 
        ((list+start)-1) * itemsPerCol
          ..
        min( (itemsPerCol * (list+start)) - 1, multiList.items.size()) ]
    // …
}

The code calculates the number of lines within each list by multiplying the text font height, in pixels, by the height in pixels of the control itself. It then calculates the item array offsets by using not only the list number, but also a starting point number. This is required since scrolling left or right changes the starting point within the array by the number of items within a single list. A little difficult to explain, but a very simple adjustment once you think it over a little.

Keyboard Navigation

The rest of the code for the control is mainly comprised of keyboard navigation. For instance, as the user scrolls left or right, the selection needs to be removed from one list, and then placed within the next one (or previous one depending on the arrow key pressed). This functionality required most of the effort — and the use of some more undocumented code — and sometimes it does get a little confused. However, with a little tweaking, I've been able to get it to work well enough for my applications.

You can download all of the code for the custom control and the sample test application here. Hopefully, you'll find it useful, at least until JavaFX 2.0 arrives, which promises to provide both a true multi-select ListView control, as well as a Grid control for similar functionality. Happy coding!

— EJB

Related Reading


More Insights






Currently we allow the following HTML tags in comments:

Single tags

These tags can be used alone and don't need an ending tag.

<br> Defines a single line break

<hr> Defines a horizontal line

Matching tags

These require an ending tag - e.g. <i>italic text</i>

<a> Defines an anchor

<b> Defines bold text

<big> Defines big text

<blockquote> Defines a long quotation

<caption> Defines a table caption

<cite> Defines a citation

<code> Defines computer code text

<em> Defines emphasized text

<fieldset> Defines a border around elements in a form

<h1> This is heading 1

<h2> This is heading 2

<h3> This is heading 3

<h4> This is heading 4

<h5> This is heading 5

<h6> This is heading 6

<i> Defines italic text

<p> Defines a paragraph

<pre> Defines preformatted text

<q> Defines a short quotation

<samp> Defines sample computer code text

<small> Defines small text

<span> Defines a section in a document

<s> Defines strikethrough text

<strike> Defines strikethrough text

<strong> Defines strong text

<sub> Defines subscripted text

<sup> Defines superscripted text

<u> Defines underlined text

Dr. Dobb's encourages readers to engage in spirited, healthy debate, including taking us to task. However, Dr. Dobb's moderates all comments posted to our site, and reserves the right to modify or remove any content that it determines to be derogatory, offensive, inflammatory, vulgar, irrelevant/off-topic, racist or obvious marketing or spam. Dr. Dobb's further reserves the right to disable the profile of any commenter participating in said activities.

 
Disqus Tips To upload an avatar photo, first complete your Disqus profile. | View the list of supported HTML tags you can use to style comments. | Please read our commenting policy.
 


Video