August 31, 2018
This article presents a UI common on mobile platforms. Since mobile lacks a keyboard, there isn't an F5 key to initiate a screen refresh. Apps like Twitter or Slack use an upward or downward swipe to retrieve data. To provide a visual cue that the operation is underway, the swipe will move a part of the screen -- up or down -- and display a progress indicator. The following program provides feedback using a DragEvent.
This short video shows the app fetching data from a remote web server. Dragging down on the ListView in the center shows a progress indicator. When the data is retrieved, the progress indicator goes away. (The web server is fast; the second and third retrievals are not as noticeable as the first.)
The app employs some slight-of-hand to show the progress. The ListView containing the messages obscures the ProgressIndicator panel. When the user drags, they start an animation that translates the ListView downward. The screen keeps this translation until after the fetch. Post-fetch, a second animation that reverses the translation is played.
The ListView is based on a Kotlin data class Message
listed below. There is also a custom event that will be posted when a fetch is completed.
data class Message(val data : String, val ts : LocalDateTime)
class FetchCompletedEvent : FXEvent()
The View is a StackPane containing the ListView (on top) and a ProgressIndicator panel (on bottom). Both are always present. The ProgressIndicator panel is obscured when not in an active fetch.
class DragFetchView : View("Drag Fetch App") {
val c : DragFetchController by inject()
override val root = stackpane {
vbox {
label("Fetching")
progressindicator()
alignment = Pos.TOP_CENTER
spacing = 4.0
paddingTop = 10.0
}
listview(c.msgs) {
var inDrag = false
setOnDragDetected {
this.translateYProperty().animate(endValue = 100.0, duration = .3.seconds)
inDrag = true
}
setOnMouseReleased {
if( inDrag && !c.status.running.value ) {
c.fetchAsync()
}
inDrag = false
}
placeholder = label("Drag to fetch messages...")
cellFormat {
text = null
graphic = hbox {
addClass(Styles.message)
label(it.data).addClass(Styles.messageText)
pane().hgrow = Priority.ALWAYS
label(it.ts.format(DateTimeFormatter.ofPattern("hh:mm:ss"))).addClass(Styles.messageTime)
alignment = Pos.BASELINE_CENTER
}
}
subscribe<FetchCompletedEvent> {
this@listview.translateYProperty().animate(endValue = 0.0, duration = .3.seconds)
}
}
}
override fun onDock() {
primaryStage.minWidth = 320.0
primaryStage.minHeight = 568.0
}
}
The fetch action is initiated by a MouseEvent DRAG_DETECTED. I track the drag state in a variable inDrag
and start the fetch when the mouse is released. DRAG_DETECTED can be confusing because there is also a set of DragEvents. I use those events when implementing Drag-and-Drop. It may be easier to think of DragEvent as DropEvent, a notional JavaFX class.
Since the web server may be fast, I've chosen to animate the showing of the ProgressIndicator panel. This will make sure that something in the UI responds to the user's drag. We don't want the operation to complete so quickly that the user feels the app did not actually do anything. The property that I'm animating is translateY. This is a convenient transformation that works on all type of containers and does not require any coordinate conversion.
The fetch operation is delegated to the Controller presented below. The fetch is retrieving a small text file from a web server. The parsing and modulo is used to return the next item in a circular list of 10 messages, one per fetch. The actual algorithm isn't important since it's a demonstration. At the end of the operation, a bound ObservableList is updated with the new message and an event is posted.
class DragFetchController : Controller() {
val api : Rest by inject()
val msgs = mutableListOf<Message>().observable()
val status : TaskStatus by inject()
val NUM_MESSAGES = 10
var counter = 0 // %10 cycles over the 10 messages on the server
init {
api.baseURI = "https://www.bekwam.net/data"
}
private fun fetch() : String? {
val response = api.get("/messages.txt")
if( response.ok() ) {
val br = BufferedReader(InputStreamReader(response.content()))
br.use {
var s = br.readLine()
val index = counter % NUM_MESSAGES
var i = 0
while( s != null && (i<index) ) {
s = br.readLine()
i++
}
counter++
return s
}
}
return null
}
fun fetchAsync() {
runAsync {
fetch()
} ui {
if( it != null ) {
msgs.add(Message(it, LocalDateTime.now()))
}
fire(FetchCompletedEvent())
} fail {
println( it )
}
}
}
When the View receives the FetchCompletedEvent, it reverses the translateY animation that allowed the ProgressIndicator panel to peek out from under the ListView.
Using a DRAG_DETECTED event, we can get a swipe event as you would do with a mobile app. Although there is scrolling attached to this JavaFX ListView, the lack of a ScrollEvent makes this tough to work with. I have used a hack here for a number of years when I've had to tie the fetch to scrolling "below" the last item. This solution introduces a second action (Drag vs. Scroll) to untangle the fetching from the ListView navigation.
The complete DragFetchApp.kt.txt file including the dark-themed Stylesheet can be downloaded here.
By Carl Walker
President and Principal Consultant of Bekwam, Inc