Tutorial: Advanced TableView tactics

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:

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:

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:

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:

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:

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:

  1. Retrieve the updated data (new tweets or RSS feeds, for example).
  2. Flush (clear) the existing table view’s data.
  3. Re-insert the new data into the table view.

To flush/empty the rows as mentioned in step #2, we can call this function:

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!

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:

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:

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 variable needToReload to false 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 the needToReload flag to true. 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 to true. This event has no phase (nil), so we also check for the absence of event.phase. Next, we check that the movement direction is "down" (typically, reloads don’t occur after scrolling up) and we also confirm that the needToReload flag is true (i.e. the user has pulled the view down enough distance). If all of these conditions are met, we call the reloadTable() 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.


Rob Miracle
[email protected]

Rob is the Developer Relations Manager for Corona Labs. Besides being passionate about helping other developers make great games using Corona, he is also enjoys making games in his spare time. Rob has been coding games since 1979 from personal computers to mainframes. He has over 16 years professional experience in the gaming industry.

41 Comments
  • Carlos
    Posted at 17:25h, 04 March

    Thank 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 March

      Loading 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 March

        Thank 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 March

          The 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 March

    Thanks 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 March

      I’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 March

    Thanx Rob for the tutorial.
    Can I download the source somewhere?
    Regards, Marc

    • Rob Miracle
      Posted at 16:32h, 27 March

      You 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 April

    Just 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 April

      Fixed. Thanks for spotting it.

  • Abdulaziz
    Posted at 22:00h, 11 May

    Thanks 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 May

      You 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 December

        Hi 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 May

    Thanks 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 June

    Hi,

    Is there a way to disable tableview mask?

    • Rob Miracle
      Posted at 15:47h, 26 June

      Many 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 July

    Hi 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 July

      Not really. The Status bar absorbs any touches on it.

      Rob

      • roddy
        Posted at 07:41h, 19 July

        Thanks 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 July

          It 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 July

            Thanks, Rob! I’ll have a play and see what works best.

            Many thanks for your replies! 🙂

            Roddy

  • Mark
    Posted at 19:55h, 19 August

    Is 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 August

      Not 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 August

    Rob,

    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 September

      display.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 October

    Hy!
    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 October

      Please ask in the forums and post a little more code inside of [code] and [/code] brackets.

  • Kevin Thompson
    Posted at 20:40h, 16 December

    The 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 December

      Sorry, I’m referring to the pull down refresh code.

    • Rob Miracle
      Posted at 16:44h, 17 December

      Can 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 December

          I meant, go to: http://forums.coronalabs.com and start a new thread there.

          Don’t post code here.

  • Amir
    Posted at 06:46h, 18 December

    I 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 December

    Please ask for help on this in the forums.

    Rob

  • Rui Pereira
    Posted at 04:15h, 04 January

    The table shows but no row text, only blank lines.

    • Rob Miracle
      Posted at 07:21h, 04 January

      Please 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 April

    To 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 July

    I 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 July

      When you detect the device rotate, destroy and re-create the table

  • Sanghyun Lee
    Posted at 11:17h, 29 December

    Is 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??