Lists -- an experiment in "zero interface" UPDATED

And now, Lists – the motion picture:

http://www.youtube.com/watch?v=Kv6OC8c4h7Q

I deleted the first draft code so I could slide the video in here. The code a few messages down matches the functionality shown here.

I love experimental UI stuff and am excited to try this, but it seems that it is relying on some project data that is not there —

Line 75: s = readProjectData("lists") -- returns nil

This causes an error on line 76 with string.gmatch.

Could amend your example to create some project data first?

(We really need to come up with a solution for sharing project data.)

Blast, let me fix it. That’s just the line that retrieves any existing list.

There. See if that did it.

Very interesting! The big letters remind me a bit of the Metro UI that Microsoft is showing.

One criticism I have is that I confuse the swipe left and swipe right gestures.

  • Swiping to the left on iOS usually triggers a delete, whereas delete is swipe-right in Lists. This had me confused for a little while.
  • Creating sublists takes you to a new screen, to get back you swipe-right, which is the same gesture as delete. I realise these happen on different regions of the screen (one happens in a cell, one happens on the page) but it feels jarring (Edit: If these are tied to animations as described below then you instantly know as you start dragging which action you are going to take, which would resolve this issue.)

Gestures are good, but I think what will help cement them is to tie them into animations. For example:

  • Swiping a cell causes it to actually move horizontally under your finger
  • Swiping a cell to the right causes a delete marker to appear on the left, perhaps with tiny words saying “Release to delete” - allowing your finger to slide back cancels the delete.
  • Swiping a cell to the left causes a sublist icon to appear on the right, again with words like “Make sublist”. Releasing your finger with the cell “pulled out” causes the sublist to be created, letting your finger slide back before releasing cancels the action.

Thanks for sharing the cool project. I love UI ideas like this.

Edit: It also reminds me a bit of Clear for iPhone: http://www.realmacsoftware.com/clear/

Thanks, Simeon. Yeah, I knew I was implementing the delete “backwards,” but somehow in my head it just seemed wrong to put subitems left of their parent.

I think you’re right that tying in some animation would go a long way (though I want to avoid having any kind of button or marker appear if I can avoid it).

What do you think about having marked items fade out and self-delete after a few seconds?

Off to fiddle.

Fair enough about the button or marker. I agree it goes against the concept. Would you say that the Clear UI goes against this principle? I am uncertain if it does — it does show descriptive text while a gesture is in-progress, but that is just text.

Having marked items remove themselves seems like a good idea. As long as you can “catch” them if you change your mind (before they are completely removed).

I think the key is to know what a gesture is going to do before you have completed the gesture, and always be able to back out of the gesture. (This doesn’t apply to tap and long-press gestures).

Give this version a shot. Still a couple of behaviors to neaten up, but I think it illustrates all the ideas. Sweep left and right to navigate, touch to mark. Marked items fade and disappear after a few moments, but can be “saved” it you tap again before they are gone.

This version implements visible push for left and right and uses the fade away idea for deletion. That gets the gestures down to five: tap to create, tap item to mark/unmark, double-tap to edit and push left or right to navigate.

Task = class()

function Task:init(name, c, parent)
    self.id = math.floor(ElapsedTime * 100000)
    self.parent = parent
    self.name = name
    self.backColor = c
    self.textColor = color(31, 31, 31, 255)
    self.marked = false
    self.top = 0
    self.bottom = 0
end

function Task:draw(x)
    pushStyle()
    fontSize(24)
    w,h = textSize(self.name)
    fill(self.backColor)
    stroke(self.backColor)
    rect(x, self.bottom, WIDTH, 50)
    fill(self.textColor)
    text(self.name, 4 + x, self.bottom + 4)
    if self.marked then
        stroke(145, 38, 27, self.textColor.a)
        strokeWidth(8)
        line(4 + x, self.bottom + 20,
         4 + w + x, self.bottom + 20)
    end 
    -- fade marked items
    if self.marked then
        self.textColor.a = self.textColor.a - 2
    end
end

function Task:touched(touch)
    if touch.y >= self.bottom and touch.y <= self.top then
        return true
    end
    return false
end

function Task:toString()
    local s, p
    if self.marked then s = "1" else s = "0" end
    return self.id..","..self.parent..","..self.name..","..s
end

Main for above…


-- Main
-- Lists
-- An experiment in simplicity

displayMode(FULLSCREEN)

NORMAL = 0
EDIT = 1
MARKED = 2
SWEEPRIGHT = 3

function setup()
    tasks = {}
   -- clearProjectData()
    readLists()
    basetask = Task("Lists", color(221, 149, 149, 88), "none")
    basetask.id = "base"
    tasks["base"] = basetask
    selected = nil
    selectedtask = nil
    newtask = nil
    timer = 0
    status = NORMAL
    oldstate = nil
    font("HelveticaNeue")
    textMode(CORNER)
    textAlign(LEFT)
    sweepX = 0
end

function readLists()
    local k, t, s, i, id, name, parent, marked
    s = readProjectData("lists")
    if s ~= nil then
        for k in string.gmatch(s,"([^|]+)") do
            i = 0
            for t in string.gmatch(k,"([^,]+)") do
                i = i + 1
                if i == 1 then
                    id = t
                elseif i == 2 then
                    parent = t
                elseif i == 3 then
                    name = t
                elseif i == 4 then
                    if t == "0" then marked = false 
                    else marked = true end
                end
            end
            --print(id, name, parent, marked)
            newtask = Task(name, color(math.random(55)+200, 
                        math.random(55)+200, math.random(55)+200, 55), 
                        parent)
            newtask.id = id
            newtask.marked = marked
            table.insert(tasks, 1, newtask)
        end
    end
end

function saveLists()
    local k, t, s
    s = ""
    for k,t in pairs(tasks) do
        if t.id ~= "base" then
            if string.len(s) > 0 then s = s.."|" end
            s = s..t:toString()
        end
    end
    saveProjectData("lists", s)
end

function keyboard(key)
    if status == EDIT then
        if key ~= nil then
            if string.byte(key) == 10 then
                if string.len(tasks[selected].name) == 0 then
                    table.remove(tasks, selected)  
                    selecteditem = nil
                    selected = nil
                end
                cleanUp()
            elseif string.byte(key) == nil then
                if string.len(tasks[selected].name) > 0 then
                    tasks[selected].name = 
                    string.sub(tasks[selected].name, 
                    1, string.len(tasks[selected].name) - 1)
                end
            else
                tasks[selected].name = 
                tasks[selected].name..key
            end
        end
    end
end

function cleanUp()
    status = NORMAL
    sweepX = 0
    hideKeyboard() 
    saveLists()
end

function taskList(base, x)
    local k, t, i
    pushStyle()
    fontSize(244)
    fill(0, 0, 0, 42)
    s = base.name
    w,h = textSize(s)
    text(base.name, 0 + x, HEIGHT - h)
    fontSize(24)
    i=0
    for k, t in pairs(tasks) do
        if t.parent == base.id then
            i = i + 1
            t.bottom = HEIGHT - i * 50
            t.top = t.bottom + 50
            t:draw(x)
        end
    end
    popStyle()
end

function findTask(id)
    local k, t
    for k,t in pairs(tasks) do
        if t.id == id then return t end
    end
    return nil
end

function draw()
    local s, w, h, k, t, ct, i, ci
    
    -- delete faded items and their kids
    for i, t in ipairs(tasks) do
        if t.textColor.a < 2 then
            for ci, ct in ipairs(tasks) do
                if ct.parent == t.id then
                    table.remove(tasks, ci)
                end
            end
            table.remove(tasks, i)
        end
    end
    
    -- drawing
    background(171, 171, 182, 255)
    
    if sweepX ~= 0 then
        if sweepX < 0 then
            taskList(selectedtask, WIDTH + sweepX)
            clip(0 + sweepX, 0, WIDTH, HEIGHT)
            taskList(basetask, 0 + sweepX)
            noClip()
        end
        if sweepX > 0 and basetask.parent ~= "none" then
            taskList(basetask, 0 + sweepX)
            clip(0 + sweepX - WIDTH, 0, WIDTH, HEIGHT)
            taskList(findTask(basetask.parent), 0 + sweepX - WIDTH)
            noClip()
        end
        stroke(127, 127, 127, 113)
        strokeWidth(3)
        line(WIDTH + sweepX, HEIGHT, WIDTH + sweepX, 0)
    else
        taskList(basetask, 0)
    end
    
    if status == EDIT and ElapsedTime > timer + 0.2 then 
        strokeWidth(3)
        stroke(0, 0, 0, 255)
        w,h = textSize(tasks[selected].name)
        line(8 + w, tasks[selected].bottom + 10, 
        8 + w, tasks[selected].top - 20)
    end
    if ElapsedTime > timer + 0.4 then
        timer = ElapsedTime
    end
    
    -- touch handling

    if CurrentTouch.state == BEGAN then
        sweepX = 0
        -- find the item being selected
        selectedtask = nil
        for k, t in pairs(tasks) do
            if t.parent == basetask.id and t:touched(CurrentTouch) then
                selected = k
                selectedtask = t
            end
        end
    end
    
    if CurrentTouch.state == MOVING then
        sweepX = sweepX + CurrentTouch.deltaX
        if sweepX < 0 and selectedtask == nil then sweepX = 0 end
        if sweepX > 0 and basetask.id == "base" then sweepX = 0 end
    end
    
    if CurrentTouch.state == ENDED and 
        CurrentTouch.state ~= oldstate then
        if selectedtask ~= nil then
            if CurrentTouch.tapCount > 1 then
                -- edit an item
                status = EDIT 
                tasks[selected].marked = false
                showKeyboard()
            elseif sweepX < (WIDTH / 2) * - 1 then
                basetask = selectedtask
                selectedtask = nil
                cleanUp()
            elseif oldstate ~= MOVING then
                -- mark an item
                tasks[selected].marked = not tasks[selected].marked
                if not tasks[selected].marked then
                    tasks[selected].textColor.a = 255
                end
                cleanUp()
            end
        else
            if oldstate ~= MOVING then
                -- new task
                newtask = Task("", color(math.random(55)+200, 
                math.random(55)+200, math.random(55)+200, 55), 
                basetask.id)
                table.insert(tasks, 1, newtask)
                selected = 1
                status = EDIT
                showKeyboard()
            elseif sweepX > (WIDTH / 2) then
                -- up a level
                basetask = findTask(basetask.parent)
                selectedtask = nil
            end
        end
        sweepX = 0
    end
    
    oldstate = CurrentTouch.state

end
    

Zero UI is interesting. It gains much interest with touch-based devices. Good zero UI has to be as intuitive as possible. User must be able to figure it out without learning, ideally. Once a user must learn how to use it, IMHO it breaks the whole concept and principle of zero UI.

So, I guess the question is – does Lists make it over that hurdle. Does it meet the goal I had at the outset: no interface, but intuitive behavior.

I really like the changes you added. The drag movement makes sublists much more intuitive.

The only really noticeable issue is that the animation snaps to the next state based on whether it’s more than 50% on or off the page. Ideally you’d like it to continue moving with the momentum of your drag gesture and settle gently into the next state.

Have you had a look at the Clear UI? They seem to be going for a similar goal. They use folding and unfolding metaphors as well.

Cool concept! However it surprised me that new items were created at the top of a list. So for me I changed it to

table.insert(tasks, newtask)
selected = #tasks

I also would change the required sweep length to a fixed value rather than WIDTH/2. This probably works well in portrait mode, but in landscape mode I found myself often sweeping short of this target.

But to answer your question, for me it makes it over the hurdle! Thanks for sharing it!

To answer Mark’s question, we have to give this app (not the code) to a independen tester i.e. someone who knows about list but never use/know this app (or something similar) before. If s/he can interact with the list without being told how to do it, then clearly the UI works very well. Our answer shouldn’t be taken into account because we -as programmers- have known and understand the code beneath the UI. :slight_smile:

This is why I love Codea. It makes it easy to make our own utilitys too that we use in real life :slight_smile:

P.S. It looks really nice. Dang. Give Mark an app, let him touch it, and it will be magical. My mom actually is thinking about using it in real life shopping. This is awsome :smiley:

Thanks, @Zoyt. Much appreciated, and if your mom really does use it, let me know how it goes.

@Herwig, yeah I debated that myself. Something about top worked better for me. BTW, at one point there was no seperate ID in the Task class, just a generated key for each item, but no matter how often I applied table.sort, I couldn’t keep things in order. I’m sure I just slipped up on the logic somewhere, but I eventually seperated the ID and key and went back to using insert.

@Simeon and @Herwig, yeah, it would be much neater if it worked more from momentum than pure position. I think I can make that happen.

@Mark, Congratulations! You did a great job. Keep to work! :wink:

It’s not too hard to make a settings, is it?

Great concept