Search code examples
listviewjavafxkotlintornadofx

endless scroll on listview with kotlin and tornadofx


I wanna implement endless scroll for listview.

The app receives data from server portionally and currently it shows only last 50 rows. I'm using tornadofx, here's the code:

MainController.kt:

private fun setAllServerHistoryList() {
        if (curSelectedLogId != -1) {
            serverHistoryList.clear()
            runAsync {
                serverAPI.getServerHistory(curSelectedLogId)
            } ui {
                serverHistoryList.setAll(it)
            }
        }
    }

MainView.kt:

private val historyListView = listview(controller.serverHistoryList) {
        selectionModel = NoSelectionModel()
        cellFormat{...}
}

getServerHistory makes GET request to server and returns parsed data, I can easily ask other rows using offset and count parameters.

But I haven't realized yet how can I add listener to detect amount of rows left to both sides of list (in case if I'm opening view not from the bottom but in range of 10000:10200), and how to add data to existing list with avoiding memory errors.

I've already read lots of answers about same question but most of them (if not all) about android and java apps.

Thanks in advance for all the responds.

UPD with rewritten solution from answers (cheers to @TornadoFX):

import javafx.collections.FXCollections
import javafx.scene.control.ListCell
import javafx.util.Callback
import tornadofx.*


data class ScrollableItemWrapper(val data : String, val lastItem : Boolean = false) {

    val id = nextId() // always increasing

    companion object {
        private var idgen = 1 // faux static class member
        fun nextId() = idgen++
    }
}


class EndlessScrollView : View("Endless Scroll") {

    var currentBatch = 1

    var last_batch = 0

    val TOTAL_NUM_BATCHES = 10

    private val records = FXCollections.observableArrayList<ScrollableItemWrapper>()

    override val root = listview(records) {
        selectionModel = NoSelectionModel()

        cellFactory = Callback { listView ->

            object : ListCell<ScrollableItemWrapper>() {

                private val listener = ChangeListener<Number> { obs, ov, nv ->
                    if ( userData != null ) {
                        val scrollable = userData as ScrollableItemWrapper
                        val pos = listView.height - this.height - nv.toDouble()
                        if ( scrollable.lastItem && pos > 0 && (pos < this.height) && index < last_batch*50) {
                            val currentPos = index
                            runAsync {
                                getNextBatch()
                            } ui {
                                listView.scrollTo(currentPos)
                                records.addAll( it )
                            }
                        }
                    }
                }

                init {
                    layoutYProperty().addListener(listener)
                }

                override fun updateItem(item: ScrollableItemWrapper?, empty: Boolean) {
                    super.updateItem(item, empty)
                    if( item != null && !empty ) {
                        text = "[${item.id}]" + item.data
                        userData = item
                    } else {
                        text = null
                        userData = null
                    }
                }

            }
        }
    }

    init {
        records.addAll( getNextBatch() )
    }

    private fun getNextBatch() : List<ScrollableItemWrapper> {
        if( currentBatch < TOTAL_NUM_BATCHES  ) {
            last_batch++
            return 1.rangeTo(50).map {
                ScrollableItemWrapper("Batch ${currentBatch} Record ${it}", it == 50)
            }.apply {
                currentBatch++
            }.toList()
        } else {
            return emptyList()
        }
    }
}

Solution

  • Try this code which I've used in a plain JavaFX app. It relies on a side effect that has worked through multiple versions of JavaFX. A wrapper class is used to pair the data with a flag indicating whether or not an item is the last in a batch. In the cell factory, a listener is registered and activated when the layoutY property is given space. If layoutY increases from 0 -- implying that the cell is shown -- and the last item flag is set, more data is fetched.

    That data is added but the scroll position is saved so that the ListView doesn't jump through the whole fetched set.

    data class ScrollableItemWrapper(val data : String, val lastItem : Boolean = false) {
    
      val id = nextId() // always increasing
    
      companion object {
        private var idgen = 1 // faux static class member
        fun nextId() = idgen++
      }
    }
    class EndlessScrollView : View("Endless Scroll") {
    
        var currentBatch = 1
    
        val TOTAL_NUM_BATCHES = 10
    
        val records = mutableListOf<ScrollableItemWrapper>().observable()
    
        override val root = listview(records) {
            cellFactory = Callback {
    
                object : ListCell<ScrollableItemWrapper>() {
    
                    private val listener = ChangeListener<Number> { obs, ov, nv ->
                        if ( userData != null ) {
                            val scrollable = userData as ScrollableItemWrapper
                            if( scrollable.lastItem &&
                                ((listView.height - this.height - nv.toDouble()) > 0.0)) {
    
                                val currentPos = index
                                runAsync {
                                    getNextBatch()
                                } ui {
                                    listView.scrollTo(currentPos)
                                    records.addAll( it )
                                }
                            }
                        }
                    }
    
                    init {
                        layoutYProperty().addListener(listener);
                    }
    
                    override fun updateItem(item: ScrollableItemWrapper?, empty: Boolean) {
                        super.updateItem(item, empty)
                        if( item != null && !empty ) {
                            text = "[${item.id}]" + item.data!!
                            userData = item
                        } else {
                            text = null
                            userData = null
                        }
                    }
    
                    protected fun finalize() {
                        layoutYProperty().removeListener(listener)
                    }
                }
            }
        }
    
        init {
            records.addAll( getNextBatch() )
        }
    
        private fun getNextBatch() : List<ScrollableItemWrapper> {
            if( currentBatch <= TOTAL_NUM_BATCHES  ) {
                return 'A'.rangeTo('Z').map {
                    ScrollableItemWrapper("Batch ${currentBatch} Record ${it}", it == 'Z')
                }.apply {
                    currentBatch++
                }.toList()
            } else {
                return emptyList()
            }
        }
    }