Pull to Refresh
If you’ve been using any modern smartphone for any length of time I’m sure you’ve seen “Pull to Refresh” (PTF). This has quickly become the preferred and expected way for a user to force the content they are looking at to refresh from the Internet. Simply drag the screen downwards with a finger, then release it and let it snap back up, and anything you were looking at on the screen is updated with the latest content. I believe this paradigm originated on Android, but wherever it came from, it’s here on BlackBerry 10 in a big way. There is no built-in support for PTF in BlackBerry 10 or Cascades, so if you want it in your app you’ll need to code it yourself.
The Objective
I integrated PTF into my multiFEED application, and I’ll use it as the template for this tutorial. When a user drags the front page ListView
down a bit they see this:
…and if they pull down a bit further the arrow swivels upward and the text changes to this:
At this point the refresh trigger is “cocked” and if the user releases it then the refresh will happen. If the user changes their mind and pushes the ListView back up the arrow will swivel down again and the refresh won’t happen even if they now release it suddenly. Note that the arrow and message text are horizontally centered.
When the refresh is triggered, if it takes a while to execute we want to hide the arrow and message and also prevent re-cocking till the refresh completes.
ListView
The basic prerequisite to implement PTF is a ListView
to attach it to. The vertical scrolling provided by ListView
is a natural place to insert PTF logic, and luckily it supplies a convenient hook for it. First we need a “visual” to display above the list items when the list is dragged down past the top. We’ll create this in a file called RefreshHeader.qml
:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
Container { id: refreshHeaderContainer ImageView { id: refreshImage imageSource: "asset:///images/Pulldown.png" horizontalAlignment: HorizontalAlignment.Center } Label { id: refreshText horizontalAlignment: HorizontalAlignment.Center text: qsTr("Pull down to refresh...") } } |
This QML will recreate the first image above when the ListView
is partially dragged down, but won’t change in any way when the user drags all the way down. We’ll add that later. Now we will tell the ListView to use our new header:
1 2 3 4 5 6 |
ListView { dataModel: yourDataModel leadingVisual: RefreshHeader { } leadingVisualSnapThreshold: 2.0 } |
The leadingVisualSnapThreshold
setting tells the ListView
that if the user drags down past the top more than twice the height of the header image and text combined that it should leave the header exposed unless the user explicitly pushes it back up.
Animating the Header
At this point dragging your ListView
down past the top will display the refresh header, but nothing will happen if you drag it further down or release it suddenly. Let’s make the arrow swivel and text change when the user drags past a certain point:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
Container { property bool refreshing: false property bool refreshCocked: false property int pullThreshold: 50 id: refreshHeaderContainer attachedObjects: [ LayoutUpdateHandler { id: refreshHandler onLayoutFrameChanged: { if (! refreshing) { if (! refreshCocked && (layoutFrame.y >= pullThreshold)) { refreshImage.rotationZ = 180.0; refreshText.text = qsTr("Release to refresh"); refreshCocked = true; } else if (refreshCocked && (layoutFrame.y < pullThreshold)) { refreshImage.rotationZ = 0.0; refreshText.text = qsTr("Pull down to refresh..."); refreshCocked = false; } } } } ] } |
Note the two new properties, refreshing
and refreshCocked
. The former will be set elsewhere when a refresh is in progress, and the latter is set by the LayoutUpdateHandler
when the user drags down past pullThreshold
. The purpose of refreshCocked
will become clear later.
The attached LayoutUpdateHandler
watches for changes to the way the ListView
is being displayed and signals with layoutFrameChanged
when the scroll position changes. In onLayoutFrameChanged
the value of layoutFrame.y
will be zero if the list is right at the top, negative if the user has scrolled the list upward, and positive if they have dragged it down past the top. If we are not already in the middle of a refresh, and the refresh trigger is not already cocked then lines 12 and 13 rotate the arrow image upward and change the text in the refresh header if the user has dragged down past pullThreshold
. Cascades gives us a freebie by animating the arrow rotation smoothly instead of just snapping it to the new orientation. Alternately, if the refresh is already cocked and the user pushed the list back up below pullThreshold
, the the arrow is rotated back downward and the text is reverted to its default. Lines 14 and 18 cock or uncock the refresh trigger.
Handling User Touch
Our refresh header can’t respond to user touch events directly, it must be notified about them by the parent ListView
. We need to create a touch handler function on our header which the ListView
can call:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
Container { signal triggerRefresh() property bool refreshing: false property bool refreshCocked: false id: refreshHeaderContainer function onListViewTouch(event) { // ---If refresh in progress do nothing if (! refreshing) { // ---Make sure we always start with header at original height refreshHeaderContainer.resetPreferredHeight(); if (event.touchType == TouchType.Up) { // ---User lifted finger off list if (refreshCocked) { // ---Trigger was cocked, do the refresh refreshCocked = false; refreshing = true; // ---Hide the header during the refresh refreshHeaderContainer.visible = false; // ---Collapse header to ensure list always snaps back to top during refresh refreshHeaderContainer.setPreferredHeight(0); // ---Send the signal to do the refresh triggerRefresh(); // ---Show the refresh header again refreshHeaderContainer.visible = true; refreshing = false; } } } } } |
Line 9 prevents unnecessary processing while a refresh in on progress. Line 13 watches for the user to lift their finger off the list, and when they do line 15 checks to see if the refresh trigger is cocked. If so then line 21 hides the header for the duration of the refresh. Even when the header is hidden, if the user pulls the list down far enough the ListView
won’t pop right back up to the top when released, same as when it is visible, so we have to collapse the header to zero height too with line 24. Line 27 emits the signal to do the refresh. Line 30 shows the header again, but for reasons I’ll explain later we don’t want to restore it to its original height here too, but instead rely on line 11 to always do that when the user touches the list again.
Now we can modify our ListView to use what we have so far:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
ListView { dataModel: yourDataModel leadingVisual: RefreshHeader { id: refreshHeader onTriggerRefresh: { // ---Put your refresh code here } } leadingVisualSnapThreshold: 2.0 onTouch: { // ---Pass the touch event on to the refresh header refreshHeader.onListViewTouch(event); } } |
Asynchronous Refresh
If your refresh code executes very quickly then you don’t need to do anything else, pull-to-refresh will work fine with the code already presented, but in most situations you will need to refresh your data from the Internet and response time is likely to be too long to just hang and wait for. In this case, you are better off doing the refresh asynchronously, that is, initiate the refresh and then forget about it until it reports that it is finished. Lets add this capability to RefreshHeader.qml
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
Container { signal triggerRefresh() property bool refreshing: false property bool refreshCocked: false property bool asynchronous id: refreshHeaderContainer function onListViewTouch(event) { // ---If refresh in progress do nothing if (! refreshing) { // ---Make sure we always start with header at original height refreshHeaderContainer.resetPreferredHeight(); if (event.touchType == TouchType.Up) { // ---User lifted finger off list if (refreshCocked) { // ---Trigger was cocked, do the refresh refreshCocked = false; refreshing = true; // ---Hide the header during the refresh refreshHeaderContainer.visible = false; // ---Collapse header to ensure list always snaps back to top during refresh refreshHeaderContainer.setPreferredHeight(0); // ---Send the signal to do the refresh triggerRefresh(); if (! asynchronous) { // ---Show the refresh header again refreshHeaderContainer.visible = true; refreshing = false; } } } } } function refreshDone() { refreshContainer.visible = true; refreshImage.rotationZ = 0.0; refreshText.text = qsTr("Pull down to refresh..."); refreshing = false; } } |
In this version we have added a new asynchronous
property so we can turn the feature on or off. There is also a new function call refreshDone()
that will act a a slot to catch the end of the refresh. Finally, on line 30 we check for asynchronous mode and if it is switched on we don’t restore the refresh header visibility or turn off the refreshing flag since we’ll do that when the refresh reports that it is finished.
The refreshDone()
function just returns the refresh header to starting conditions and turns off the refreshing flag. To use asynchronous mode with our ListView
just add a few lines:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
ListView { dataModel: yourDataModel leadingVisual: RefreshHeader { id: refreshHeader asynchronous: true onTriggerRefresh: { // ---Launch asynchronous refresh RefreshManager.refresh(); } } leadingVisualSnapThreshold: 2.0 onTouch: { // ---Pass the touch event on to the refresh header refreshHeader.onListViewTouch(event); } function refreshDone() { refreshHeader.refreshDone(); } } |
RefreshManager.refresh()
is just a placeholder for any other control and function that can perform the refresh asynchronously then trigger the refreshDone()
function on the ListView
when it has finished. The refreshDone()
function just passes the signal on down to the same-named event on the RefreshHeader
.
Refreshing With a C++ Function
In my multiFEED application the refresh functionality is all handled by a Q_INVOKABLE
C++ function on the main application class. For this to work I have to connect the “refresh finished” signal from the application to the refreshDone()
slot on the ListView
on the fly. My ListView code looks like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
ListView { id: itemList dataModel: yourDataModel leadingVisual: RefreshHeader { id: refreshHeader asynchronous: true onTriggerRefresh: { // ---Launch asynchronous refresh app.refreshDoneSignal.connect(itemList.refreshDone); app.doRefresh(); } } leadingVisualSnapThreshold: 2.0 onTouch: { // ---Pass the touch event on to the refresh header refreshHeader.onListViewTouch(event); } function refreshDone() { app.refreshDoneSignal.disconnect(itemList.refreshDone); refreshHeader.refreshDone(); } } |
Orientation Handling
We are almost done, but there is one thing that doesn’t quite work right. On a full touch device with landscape mode the rotating arrow and refresh header text get skewed off-center when the device orientation is changed because the width of the header isn’t automatically rescaled. For this we need to attach an OrientationHandler
to the Container
in RefreshHeader.qml
:
1 2 3 4 5 6 7 8 9 |
Container { id: refreshHeaderContainer preferredWidth: (orientationHandler.orientation == UIOrientation.Landscape) ? DisplayInfo.height : DisplayInfo.width attachedObjects: [ OrientationHandler { id: orientationHandler } ] } |
The preferredWidth
property on the Container uses the OrientationHandler
to determine how wide it should be. Whenever you assign the result of an expression to a property like this Cascades connects a slot to the corresponding signal behind the scenes. In this case there is an invisible handler for orientationHandler.onOrientationChanged
, so every time the user reorients the device the preferred width is reset. Note that DisplayInfo.height
and DisplayInfo.width
require a little explanation. There is no built-in way to determine the screen dimensions from a BlackBerry10 application so we have to add a little code to our main application cpp file to make this information available at runtime. This code must be inserted after main.qml
has been loaded, but before it is set as the root scene:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// ---This is the existing code from the main application class constructor qml = QmlDocument::create( "asset:///main.qml" ).parent( this ); qml->setContextProperty( "app", this ); // ---This is the additional code to add for display dimension reporting DisplayInfo display; int width = display.pixelSize().width(); int height = display.pixelSize().height(); QDeclarativePropertyMap* displayProperties = new QDeclarativePropertyMap; displayProperties->insert( "width", QVariant( width ) ); displayProperties->insert( "height", QVariant( height ) ); // ---Make available to the app as DisplayInfo.height and DisplayInfo.width qml->setContextProperty( "DisplayInfo", displayProperties ); |
Putting It All Together
Combining everything we get:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 |
Container { signal triggerRefresh() property bool refreshing: false property bool refreshCocked: false property int pullThreshold: 50 property bool asynchronous preferredWidth: (orientationHandler.orientation == UIOrientation.Landscape) ? DisplayInfo.height : DisplayInfo.width id: refreshHeaderContainer attachedObjects: [ LayoutUpdateHandler { id: refreshHandler onLayoutFrameChanged: { if (! refreshing) { if (! refreshCocked && (layoutFrame.y >= pullThreshold)) { refreshImage.rotationZ = 180.0; refreshText.text = qsTr("Release to refresh"); refreshCocked = true; } else if (refreshCocked && (layoutFrame.y < pullThreshold)) { refreshImage.rotationZ = 0.0; refreshText.text = qsTr("Pull down to refresh..."); refreshCocked = false; } } } }, OrientationHandler { id: orientationHandler } ] ImageView { id: refreshImage imageSource: "asset:///images/Pulldown.png" horizontalAlignment: HorizontalAlignment.Center } Label { id: refreshText horizontalAlignment: HorizontalAlignment.Center text: qsTr("Pull down to refresh...") } function onListViewTouch(event) { // ---If refresh in progress do nothing if (! refreshing) { // ---Make sure we always start with header at original height refreshHeaderContainer.resetPreferredHeight(); if (event.touchType == TouchType.Up) { // ---User lifted finger off list if (refreshCocked) { // ---Trigger was cocked, do the refresh refreshCocked = false; refreshing = true; // ---Hide the header during the refresh refreshHeaderContainer.visible = false; // ---Collapse header to ensure list always snaps back to top during refresh refreshHeaderContainer.setPreferredHeight(0); // ---Send the signal to do the refresh triggerRefresh(); if (! asynchronous) { // ---Show the refresh header again refreshHeaderContainer.visible = true; refreshing = false; } } } } } function refreshDone() { refreshContainer.visible = true; refreshImage.rotationZ = 0.0; refreshText.text = qsTr("Pull down to refresh..."); refreshing = false; } } |
…and to use it our ListView looks something like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
ListView { dataModel: yourDataModel leadingVisual: RefreshHeader { id: refreshHeader asynchronous: true onTriggerRefresh: { // ---Launch asynchronous refresh RefreshManager.refresh(); } } leadingVisualSnapThreshold: 2.0 onTouch: { // ---Pass the touch event on to the refresh header refreshHeader.onListViewTouch(event); } function refreshDone() { refreshHeader.refreshDone(); } } |
Conclusion
The RefreshHeader we just built can be used with any ListView with just a few extra lines of QML and JavaScript. Now every ListView in all your applications can have “pull-to-refresh” functionality with very little effort.
‹ WebView and HTML anchor tags