04 March 2014
Tutorial: Advanced TableView tactics
In this tutorial, we’re going to look more deeply at table views (or “list views”) which are based on a mobile device paradigm where information is presented to the user in a series of single-column rows. In iOS, apps like Mail, Clock, and Contacts are obvious table views, but they are also used in countless other apps for menus and more.
In Corona, widget.newTableView() is a widget rendered in OpenGL and it has been built to emulate some of the more common features of a native table view. Under the hood, a Corona table view is built on top of a widget.newScrollView() which handles the up/down scrolling, the spring-like behavior, and the momentum-based scrolling.
Setup
In this tutorial example, we’ll create a table view that spans the full width of the screen, positioned just below an upper title bar and spanning down to a widget.newTabView() controller at the bottom. Here’s the basic code:
1 2 3 4 5 6 7 8 9 10 |
local navBarHeight = 60 local tabBarHeight = 50 local myList = widget.newTableView { top = navBarHeight, width = display.contentWidth, height = display.contentHeight - navBarHeight - tabBarHeight, onRowRender = onRowRender, onRowTouch = onRowTouch, listener = scrollListener } |
This creates an “empty” table view of the described size. Now, we must populate it with rows. This is done using the tableView:insertRow() method which inserts a blank, empty row into the view. To populate the widget in this example, we’ll use a table of items that contain a name and phone number:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
local myData = {} myData[1] = { name="Fred", phone="555-555-1234" } myData[2] = { name="Barney", phone="555-555-1235" } myData[3] = { name="Wilma", phone="555-555-1236" } myData[4] = { name="Betty", phone="555-555-1237" } myData[5] = { name="Pebbles", phone="555-555-1238" } myData[6] = { name="BamBam", phone="555-555-1239" } myData[7] = { name="Dino", phone="555-555-1240" } for i = 1, #myData do myList:insertRow{ rowHeight = 60, isCategory = false, rowColor = { 1, 1, 1 }, lineColor = { 0.90, 0.90, 0.90 } } end |
Even with this setup, nothing is visually rendered to the table view at this point. Behind the scenes, the table view widget keeps track of which rows are on screen and draws only those rows when they are needed. This is accomplished by a function that we provide and define as the onRowRender
parameter in the table view constructor. When this function is called by the table view, we’re provided with the handle to the appropriate row.
In Corona, each row is actually a separate display.newGroup(). Thus, in the row rendering function, we can create the necessary custom display objects and use the standard group:insert(object) method to insert each into the table view row.
How does the row rendering function know what to render within each row? Let’s inspect a typical example of the row rendering function:
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 |
local function onRowRender( event ) --Set up the localized variables to be passed via the event table local row = event.row local id = row.index row.bg = display.newRect( 0, 0, display.contentWidth, 60 ) row.bg.anchorX = 0 row.bg.anchorY = 0 row.bg:setFillColor( 1, 1, 1 ) row:insert( row.bg ) row.nameText = display.newText( myData[id].name, 12, 0, native.systemFontBold, 18 ) row.nameText.anchorX = 0 row.nameText.anchorY = 0.5 row.nameText:setFillColor( 0 ) row.nameText.y = 20 row.nameText.x = 42 row.phoneText = display.newText( myData[id].phone, 12, 0, native.systemFont, 18 ) row.phoneText.anchorX = 0 row.phoneText.anchorY = 0.5 row.phoneText:setFillColor( 0.5 ) row.phoneText.y = 40 row.phoneText.x = 42 row.rightArrow = display.newImageRect( "rightarrow.png", 15 , 40, 40 ) row.rightArrow.x = display.contentWidth - 20 row.rightArrow.y = row.height / 2 row:insert( row.nameText ) row:insert( row.phoneText ) row:insert( row.rightArrow ) return true end |
The “magic” here is how we use the row’s ID number to map against the myData
dataset. However, while this works great for simple cases, it won’t work for more advanced table views that contain “category” rows or empty rows for spacing purposes.
Introducing passable parameters
This feature has been available for some time but it hasn’t been discussed too much. Essentially, it provides a programmatic way to associate a row and specific data without relying on an ID number. Let’s use the same example but pass in parameters instead:
1 2 3 4 5 6 7 8 9 10 11 12 |
for i = 1, #myData do myList:insertRow{ rowHeight = 60, isCategory = false, rowColor = { 1, 1, 1 }, lineColor = { 0.90, 0.90, 0.90 }, params = { name = myData[i].name, phone = myData[i].phone } } end |
Now we can insert rows that contain data using the params
table. Rows that have no data can be inserted without parameters (category rows, for example). Then, in the row rendering function, we can simply test to see if it’s a category row or not and render it accordingly:
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 |
local function onRowRender( event ) --Set up the localized variables to be passed via the event table local row = event.row local id = row.index local params = event.row.params row.bg = display.newRect( 0, 0, display.contentWidth, 60 ) row.bg.anchorX = 0 row.bg.anchorY = 0 row.bg:setFillColor( 1, 1, 1 ) row:insert( row.bg ) if ( event.row.params ) then row.nameText = display.newText( params.name, 12, 0, native.systemFontBold, 18 ) row.nameText.anchorX = 0 row.nameText.anchorY = 0.5 row.nameText:setFillColor( 0 ) row.nameText.y = 20 row.nameText.x = 42 row.phoneText = display.newText( params.phone, 12, 0, native.systemFont, 18 ) row.phoneText.anchorX = 0 row.phoneText.anchorY = 0.5 row.phoneText:setFillColor( 0.5 ) row.phoneText.y = 40 row.phoneText.x = 42 row.rightArrow = display.newImageRect( "rightarrow.png", 15 , 40, 40 ) row.rightArrow.x = display.contentWidth - 20 row.rightArrow.y = row.height / 2 row:insert( row.nameText ) row:insert( row.phoneText ) row:insert( row.rightArrow ) end return true end |
Now the row rendering function doesn’t need to know anything about our data structure. This helps support the concept of Model-View-Controller (MVC). With this method, we can focus on our View and know which data to get without knowledge of how the Model is tracking the data. In most cases, this is a very good way to populate table view rows and it should make it easier to visualize data with the associated row.
Reloading the table view on a status bar tap
This is a frequently-requested feature that’s easy to implement with a small amount of code. How the data is pulled in will depend largely on the app design, and it’s your responsibility to create a reload function that will be called as part of the tap handler. In most cases, the common functionality will be:
- Retrieve the updated data (new tweets or RSS feeds, for example).
- Flush (clear) the existing table view’s data.
- Re-insert the new data into the table view.
To flush/empty the rows as mentioned in step #2, we can call this function:
1 |
myList:deleteAllRows() |
Now, to build in the “status bar tap” functionality, we can create a transparent rectangle where the status bar resides and add a "tap"
event handler on it. This tap function will, not surprisingly, call the custom reload function. It’s that simple!
1 2 3 4 |
local reloadBar = display.newRect( display.contentCenterX, display.topStatusBarContentHeight*0.5, display.contentWidth, display.topStatusBarContentHeight ) reloadBar.isVisible = false reloadBar.isHitTestable = true reloadBar:addEventListener( "tap", reloadTable ) |
There’s one important aspect to note: since the rectangle object is invisible (.isVisible = false
), we must set .isHitTestable
to true so it reacts to tap/touch events.
Implementing “spring reloading”
Table view “spring reloading” is the technique of pulling down on the list to have it reload/refresh. This isn’t quite as easy to implement as the status bar tap method, but let’s walk through it.
There are some prerequisites to consider as part of the User Interface and User Experience (UI/UX) for spring reloading. When a user pulls down on a table view, it reveals the “background” of the app. In some cases, you may want to place something behind the table view, like a solid gray block. Also, most spring reload systems have an animated graphic that shows when the user has pulled down far enough, prompting them to release their touch. In iOS, this is usually a spinner, which you may consider imitating via the Corona spinner widget. Ultimately, though, it’s your decision about how to handle the UI/UX for your app.
Before implementing spring reloading, let’s further examine the table view event system. Here’s our example table view constructor again:
1 2 3 4 5 6 7 8 |
local myList = widget.newTableView { top = navBarHeight, width = display.contentWidth, height = display.contentHeight - navBarHeight - tabBarHeight, onRowRender = onRowRender, onRowTouch = onRowTouch, listener = scrollListener } |
As we’ve seen so far, the onRowRender
function handles rendering and display of the actual table rows. Below that, the onRowTouch
function handles "tap"
and "touch"
events on the individual rows (see the documentation for widget.newTableView() for usage examples). The final function, assigned to the listener
property, is the function that will listen for scroll-related events on the table — and those events are of particular interest in spring reloading. Using this listener, we’ll be able to detect when the table view starts to move, when it’s still moving under momentum, and when it stops moving. We’ll also get an event.phase = nil
when the table view reaches the limits of its scrolling, and it’s this event phase which indicates that we should reload the table view. Let’s look at the example code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
local springStart = 0 local needToReload = false local function scrollListener( event ) if ( event.phase == "began" ) then springStart = event.target.parent.parent:getContentPosition() needToReload = false elseif ( event.phase == "moved" ) then if ( event.target.parent.parent:getContentPosition() > springStart + 60 ) then needToReload = true end elseif ( event.limitReached == true and event.phase == nil and event.direction == "down" and needToReload == true ) then --print( "Reloading Table!" ) needToReload = false reloadTable() end return true end |
Notice how these conditional checks are used for different processes within the spring reloading:
- In the
"began"
phase, we store the current content position of the table view. This way, when the user pulls down on the list, accidental reloads are not triggered if the user pulls down just slightly. We also set the flag variableneedToReload
tofalse
in this phase. If you wish to have an animation start to indicate they are beginning a spring reload, this would be a good time to start it. - In the
"moved"
phase, if the table view position changes by a set amount more than it started at, in this example 60 pixels, we set theneedToReload
flag totrue
. Since the user could still be dragging the table view at this time, we do not trigger the reload yet. If you have a graphic or animation showing the user it’s now time to let go or they have pulled down enough, you would change the graphic/animation here as well. - In the
"ended"
phase, you can stop any animations you started in the"began"
or"moved"
phases. - In the final conditional check, we don’t want to reload only when the movement ends. If we did, the table view would reload every time the scrolling stopped on the table view. Instead, we’ll check for a series of conditions, starting with the
event.limitReached
event equal totrue
. This event has no phase (nil
), so we also check for the absence ofevent.phase
. Next, we check that the movement direction is"down"
(typically, reloads don’t occur after scrolling up) and we also confirm that theneedToReload
flag istrue
(i.e. the user has pulled the view down enough distance). If all of these conditions are met, we call thereloadTable()
function to start the table view reload.
In conclusion
Hopefully this tutorial has shown you how to add some creative and useful features to the table view widget. With the ability to use parameters to pass data to the row rendering function, plus these easy-to-implement reloading features, you can now supercharge your table views.
Carlos
Posted at 17:25h, 04 MarchThank you Rob for another useful and great tutorial!
JCH_APPLE
Posted at 00:11h, 05 March+ 1 for the great quality of this tutorial.
List often use remote images. In this case, onRowRender must be called once image is loaded.
Is there an easy way to show a placeholder for an image with a spinner and update rows one image is loaded (inspite of waiting image to be loaded).
Rob Miracle
Posted at 16:21h, 05 MarchLoading Remote images is something I’ve done in tableView’s quite a bit. It’s tricky, but you can have a place holder and then call your display.loadRemoteImage() and in it’s call back, remove the placeholder and draw the image in it’s place. It’s helpful to have all code for this inside of the onRowRender() function for scoping purposes.
JCH_APPLE
Posted at 23:37h, 05 MarchThank you Rob, I will try this approach. As of today I load the image and when loading is complete I insert row.
It works but can result in rows not displayed in the good order (if product 2 has a smaller image than product 1, it loads faster and it’s displayed before product one).
Your idea will avoid such behavior.
Rob Miracle
Posted at 16:47h, 06 MarchThe way I do it it ties the download to the row itself and not to the order they are inserted. Since all the work goes on in onRowRender, you store the placeholder in the row:
row.icon = dipslay.newImageRect(“placeholder.png”, 60, 60)
row:insert(icon)
So not only is the object held with the row, it gets inserted into the display group as well. Then call display.loadRemoteImage(). In the listener, if you get the image, remove row.icon and save the image to row.icon and insert it into the group.
M
Posted at 11:03h, 06 MarchThanks for the write up. Does the Config file still need to be set to 320×480? Last time I tried tableview on an app using a 640×960 config it did not work.
Rob Miracle
Posted at 16:50h, 06 MarchI’m not sure of any reason why tableViews wouldn’t work on 640×960 except that the scroll bar won’t scale up. But there are other widgets that really work better keeping your app at 320×480.
Rob
Marc
Posted at 19:15h, 26 MarchThanx Rob for the tutorial.
Can I download the source somewhere?
Regards, Marc
Rob Miracle
Posted at 16:32h, 27 MarchYou can get the Business App sample from our github repository and add the above to it. That’s what I used to test these features in. I just don’t have it in a pushable state at the moment.
Dave Baxter
Posted at 02:55h, 19 AprilJust adding this to my app and there is a problem with the second onRowRender code block at the bottom, it’s cut off and the word
is sitting there.
Works fine by the way, not implemented the springy thing am refreshing on a button but am using the params and reading in a XML document and displaying it no problems.
Dave
Rob Miracle
Posted at 08:35h, 19 AprilFixed. Thanks for spotting it.
Abdulaziz
Posted at 22:00h, 11 MayThanks Rob for the great tutorial..
In the Spring Reloading,, normally in some apps like Twitter for example, when user pulls down the list, the refresh starts but I noticed the content stayed down in the last place it reaches when user pulled it. When the refresh is over the content goes back to the initial height..
do you have an idea how to achieve that,
Thanks
Abdul
Rob Miracle
Posted at 08:55h, 12 MayYou could put the refresh request inside a timer so that the main function returns immediately and then when the timer function finishes, it will update. The timer wouldn’t need to be very long, say a couple of milliseconds. The idea is to get it so that you’re not waiting on it to finish.
Rob
Isaac
Posted at 22:19h, 03 DecemberHi Rob, I was looking into what Abdulaziz is asking. So where do you actually put the request timer so that when you are reloading and getting the response from the server that the app will show the animation or the content stayed down.
Praveen
Posted at 22:16h, 30 MayThanks for another excellent tutorial.
I fleshed out the code and tweaked it a bit adding the pull-down/refresh animation. I used row-1 to hold the refresh animation.
https://github.com/pspk/tableview
I’ve also added this to the code-exchange.
Pathmazing Dev
Posted at 23:49h, 25 JuneHi,
Is there a way to disable tableview mask?
Rob Miracle
Posted at 15:47h, 26 JuneMany people wanted to have tableViews that would fit many different devices without having to have dozens of mask files and trying to figure out which mask file to use. To do this, we have to use containers and they self-mask. You could always download the open source versions from our github.com repository and change it from a container back to a group and provide your own masking system.
Rob
roddy
Posted at 07:56h, 18 JulyHi Rob
Is it possible to add an invisible button on top of the Status Bar inside a Composer Scene?
Roddy
Rob Miracle
Posted at 14:33h, 18 JulyNot really. The Status bar absorbs any touches on it.
Rob
roddy
Posted at 07:41h, 19 JulyThanks Rob, really appreciate your prompt reply.
So, from your article above… “Now, to build in the “status bar tap” functionality, we can create a transparent rectangle where the status bar resides and add a “tap” event handler on it.” Would that transparent rectangle have to be created in main.lua?
Cheers
Roddy
Rob Miracle
Posted at 07:44h, 19 JulyIt works in the simulator and if you have the device status bar turned off. While we can’t beat the iOS status bar being there, you can still provide that functionality (I.e. tapping on the nav bar) if you hide the status bar.
Rob
roddy
Posted at 04:18h, 20 JulyThanks, Rob! I’ll have a play and see what works best.
Many thanks for your replies! 🙂
Roddy
Mark
Posted at 19:55h, 19 AugustIs there any way to get the x,y positions of the event within onTableRowTouch? I’d like to treat press/release events where the user accidentally moves their finger a pixel or two as a tap still. Otherwise, you have to be super precise with your touch for the event.phase to be a tap.
Rob Miracle
Posted at 17:09h, 20 AugustNot that I’m aware of without getting the Open Source version from github and updating it to do so. The widgets are pure Lua and we’ve made the code available for people who want to have custom features like this.
Rob
Vivek
Posted at 22:14h, 31 AugustRob,
Is there a simpler way of cancelling the request made by loadRemoteImage method? In particular, I am trying to stop the listener from displaying the downloaded images when the scene:hide() is called.
– I tried a boolean which is set to false when scene:hide() is called, but images are still displayed on default x, y (at the left-top of the screen).
– I tried setting network.cancel(event.requestId) in the listener function, but it gave me a segmentation fault 11, and Corona Simulator crashed.
I was thinking wouldn’t it be much more convenient if you can return the handle, which is returned by network.download call made inside display.loadRemoteImage. This way these handles can be saved in a table and cancelled whenever needed.
Vivek
Rob Miracle
Posted at 18:37h, 01 Septemberdisplay.loadRemoteImage is just a convenience method around network.download. However, it doesn’t handle cancelling the download. If you need to be able to cancel the download, you should use network.download() and then use display.newImage() to load the image.
Rob
Alberto
Posted at 06:33h, 09 OctoberHy!
I’m trying the part of “Introducing Passable Parameters”.
When I arrive at the code
“myList:insertRow{
rowHeight = 60,
isCategory = false ” …
I get the error “bad argument #-2 to ‘insert’ (proxy expected got nil) “.
I’m using the latest CoronaSDK on MacOSX 10.8 .
I just copy the sample, but I put my data.
Any ideas? Thanks
Rob Miracle
Posted at 20:20h, 09 OctoberPlease ask in the forums and post a little more code inside of [code] and [/code] brackets.
Kevin Thompson
Posted at 20:40h, 16 DecemberThe code works great… but I’m having a problem when touching an area of the tableview that isn’t populated with a row. If that area is touched, it returns an error: attempt to call method ‘getContentPosition’ (a nil value).
Any idea how to get around that?
Kevin Thompson
Posted at 20:41h, 16 DecemberSorry, I’m referring to the pull down refresh code.
Rob Miracle
Posted at 16:44h, 17 DecemberCan you ask this in the forums and post the code you’re using inside [code] and [/code] tags?
Forum comments don’t format code very well.
Rob
Kevin Thompson
Posted at 18:19h, 17 December[code]
local widget = require(‘widget’)
local springStart = 0
local needToReload = false
local function scrollListener( event )
if ( event.phase == “began” ) then
springStart = event.target.parent.parent:getContentPosition()
needToReload = false
elseif ( event.phase == “moved” ) then
if springStart ~= nil then
if ( event.target.parent.parent:getContentPosition() > springStart + 60 ) then
needToReload = true
end
end
elseif ( event.limitReached == true and event.phase == nil and event.direction == “down” and needToReload == true ) then
needToReload = false
reloadTable = true
end
return true
end
tableView = widget.newTableView
{
x = _W*.6,
y = _H*.5,
height = 300,
width = 200,
onRowRender = onRowRender,
onRowTouch = onRowTouch,
listener = scrollListener,
}
[/code]
Rob Miracle
Posted at 18:21h, 17 DecemberI meant, go to: http://forums.coronalabs.com and start a new thread there.
Don’t post code here.
Amir
Posted at 06:46h, 18 DecemberI am using the tableView which results into another tableView. It works fine on the simulator howver, it messes up on the device. Has anyone had same problem?
Rob Miracle
Posted at 17:10h, 18 DecemberPlease ask for help on this in the forums.
Rob
Rui Pereira
Posted at 04:15h, 04 JanuaryThe table shows but no row text, only blank lines.
Rob Miracle
Posted at 07:21h, 04 JanuaryPlease ask this in the forums. We need to see code and code does not work well in blog comments. You will need to post your onRowRender() function, the code where you create the tableView and your code where you insert your rows into the table to start. Please use [code] and [/code] tags around each block of code or use the <> button in the formatting bar in the forums post editor.
Krystian
Posted at 14:03h, 04 AprilTo anyone who would like to use top/left values instead of x/y values.
If you plan on using tableview on devices with much higher resolution than your config.lua setting, you will end up having your scrollview moved to the top and to the left by 1 or more pixels. This is due to a bug in widget library, where top/left values are calculated into x/y, and at the end of calculation someone decided to do math.floor on those values 😉
David
Posted at 17:12h, 06 JulyI am playing around with the tableView widget and have come across a hurdle. Well, two actually, but I’ll work on this first as this would resolve my second.
I start in portrait mode with a width of 375. (The number doesn’t really matter as it will change per device.) Table renders, no problem. I then rotate the device, but the table width remains 375. How do I re-render the tableView so that it now spans the landscape width?
I use the following method onResize for the tabBar and it works like a champ.
display.remove(tabBar) — Clean up old tabBar
tabBar = nil — Clean up old tabBar
generateTabBar = makeTabBar () — Create a new tabBar for the new dimensions
Unfortunately, similar code does not appear to work for the TableView.
Any thoughts? Thanks!
David
Rob Miracle
Posted at 14:13h, 11 JulyWhen you detect the device rotate, destroy and re-create the table
Sanghyun Lee
Posted at 11:17h, 29 DecemberIs it possible to make table view like below?
In general, tableview looks like this
————————————–
Row 1
————————————–
Row 2
————————————–
Row 3
————————————–
But what I want to do is as below.
—————————-
Row 1 l Row 2
—————————-
Row 3 l Row 4
—————————-
Is it possible??