UI Scrollable List

From GiderosMobile

Here you will find various resources to help you create scrollable lists in Gideros Studio.

note: you may have to provide your own assets (fonts, gfx, …)

Scrollable List

Ui list view.png

Class

--====================================================================--        
-- ListView widget for GiderosMobile
--====================================================================--
--
-- ListView.lua
-- Version 0.1 beta
-- Author: Lukas Gergel, http://pykaso.net
-- 
-- This library is free to use and modify. Use it for free!
--
-- TODO: add methods for programatic manipulating with ListView
--
-- USAGE
--
-- myList = ListView.new({
--	width=280,
--	height=390,
--	bgTexture = Texture.new("texture.png"),
--  bgColor = 0xffffff,
--  rowSnap = true, -- experimental feature
--  friction = 0.92 -- number lower then 1
--	data=data
-- })

ListView = Core.class(Shape)

function ListView:init(params)
	-- properties
	self.velocityPrevTime = 0
	self.listHeight = 0

	--data items (pre rendered sprites)
	local data = params.data or {}

	--configuration
	self.cfg = {
		--dimension
		width = params.width or self.screenW,
		height = params.height or self.screenH,
		bgColor = params.bgColor or nil,
		rowSnap = params.rowSnap or false,
		friction = params.friction or 0.92,
		callback = params.callback or function(row) return row end,
		onScroll = params.onScroll or nil
	}

	local prevY, prevH, i, finalData = 0, 0, 0, {}

	for i = 1, #data do
		finalData[i] = {}
		finalData[i].objectData = data[i]
		finalData[i].height = data[i]:getHeight()
		finalData[i].xInit = 0
		finalData[i].yInit = prevY + prevH
		prevY = finalData[i].yInit
		prevH = finalData[i].height
		self.listHeight = self.listHeight + finalData[i].height
	end

	if(self.cfg.bgColor ~= nil) then
		self:setFillStyle(Shape.SOLID, self.cfg.bgColor, 1)
	end

	-- create defined shape
	self:beginPath()
	self:moveTo(0, 0)
	self:lineTo(0, self.cfg.height)
	self:lineTo(self.cfg.width, self.cfg.height)
	self:lineTo(self.cfg.width, 0)
	self:closePath()
	self:endPath()
	
	-- create defined shape
	self.mask = Shape.new()
	self.mask:beginPath()
	self.mask:moveTo(0, 0)
	self.mask:lineTo(0, self.cfg.height)
	self.mask:lineTo(self.cfg.width, self.cfg.height)
	self.mask:lineTo(self.cfg.width, 0)
	self.mask:closePath()
	self.mask:endPath()
	self:addChild(self.mask)

	self.listItems = Sprite.new()
	self:addChild(self.listItems)
	self.viewSize = self.cfg.height

	self.itemData = finalData
	finalData = nil
	self:createRender()
end

function ListView:newListItem(id, data)
	local thisItem = Sprite.new()
	local callback = self.cfg.callback
	local t = callback(data)
	local tv = self
	thisItem.id = id
	thisItem:addChild(t)

	thisItem:addEventListener(Event.TOUCHES_BEGIN, function(e)
		if(self.mask:hitTestPoint(e.touch.x, e.touch.y)) then
			tv:listItemTouch(e, "begin")
		end
	end)

	thisItem:addEventListener(Event.TOUCHES_MOVE, function(e)
		tv:listItemTouch(e,"move")
	end)

	thisItem:addEventListener(Event.TOUCHES_END, function(e)
		tv:listItemTouch(e,"end")
	end)

	return thisItem
end

function ListView:listItemTouch(e, act)
	local li = self.listItems
	local top, height = self:getY(), self:getHeight()

	if(act == "begin") then
		delta, velocity, prevPos = 0, 0, 0
		self.isFocus = true

		startPos = e.touch.y
		prevPos = e.touch.y

		if self.tween then
			self.tween:setPaused(true)
			self.tween = nil
		end

		self:removeEventListener("enterFrame", self.scrollList, self)
		self:addEventListener("enterFrame", self.trackVelocity, self)

	elseif(self.isFocus) then
		if(act == "move") then
			local lastItem = li:getChildAt(li:getNumChildren())
			delta = e.touch.y - prevPos
			prevPos = e.touch.y

			if (li:getChildAt(1).id == 1 and li:getY() > 0) or
					(lastItem.id == #self.itemData and
					(self.listHeight - lastItem:getY() - lastItem:getHeight()) <= 0) then
				li:setY(math.floor(li:getY() + delta / 3))
			else
				li:setY(math.floor(li:getY() + delta))
			end
			self:updateRender()
		end

		if(act == "end") then
			self:removeEventListener("enterFrame", self.trackVelocity, self)
			self:addEventListener("enterFrame", self.scrollList, self)
			self.isFocus = false
		end
	end
end

function ListView:scrollList(event)
	if math.abs(velocity) < 0.3 then
		velocity = 0
		self:removeEventListener("enterFrame", self.scrollList, self)

		-- experimental feature
		if self.cfg.rowSnap then
			local _me = self
			local lastItem = self.listItems:getChildAt(self.listItems:getNumChildren())
			local firstVisibleItem = self:getFirstVisibleRow()

			if firstVisibleItem ~= nil and self.listItems:getY() < 0 and
					self.listHeight - lastItem:getY() - lastItem:getHeight() > 0 then
				local x, y = firstVisibleItem:getBounds(self)
				local vx, vy = self.listItems:localToGlobal(self.listItems:getBounds(self))
				local final = self.listItems:getY()
				if firstVisibleItem:getHeight() + y >= firstVisibleItem:getHeight() / 2 then
					final = self.listItems:getY() - y
				else
					final = self.listItems:getY() - (firstVisibleItem:getHeight() + y)
				end
				self.tween = GTween.new(self.listItems, 0.7, {y = final}, {delay = 0.01,
						ease = easing.outQuartic, onChange = function() self:updateRender() end})
			end
		else
			-- nothing here
		end
	end

	local timePassed = event.deltaTime * 300
	local li = self.listItems

	-- Slow the list down
	velocity = velocity * self.cfg.friction
	li:setY(math.floor(self.listItems:getY() + velocity * timePassed))

	self:updateRender()

	local firstItem = li:getChildAt(1)
	local lastItem = li:getChildAt(li:getNumChildren())
	local lx, ly = self:localToGlobal(li:getBounds(self))
	local x, y, w, h = self:getBounds(self)
	local x2, y2 = self:localToGlobal(x, y)
	--local lx, ly = lastItem:getBounds(self)

	if firstItem.id == 1 and li:getY() > 0 then
		velocity = 0
		self:removeEventListener("enterFrame", self.scrollList, self)
		self.tween = GTween.new(li, 0.4, {y = li.yInit}, {delay = 0.01, ease = easing.outQuartic})
	end

	if lastItem.id == #self.itemData and self.listHeight - lastItem:getY() - lastItem:getHeight() <= 0 then
		if self.listHeight > self.viewSize and li:getY() < -self.listHeight + self.viewSize - lastItem:getHeight() * 0.5 then
			velocity = 0
			self:removeEventListener("enterFrame", self.scrollList, self)
			self.tween = GTween.new(li, 0.4, {y = math.ceil(self.viewSize - self.listHeight)},
					{delay = 0.01, ease = easing.outQuartic})
		elseif self.listHeight < self.viewSize then
			velocity = 0
			self:removeEventListener("enterFrame", self.scrollList, self)
			self.tween = GTween.new(li, 0.4, {y = li.yInit}, {delay = 0.01, ease = easing.outQuartic})
		end
	end

	return true
end

function ListView:getFirstVisibleRow()
	local index = 1
	local posY = self:getY()
	local item = nil

	while posY <= self:getY() do
		if(index < self.listItems:getNumChildren()) then
			item = self.listItems:getChildAt(index)
			local x1, y1 = self:localToGlobal(item:getBounds(self))
			posY = y1 + item:getHeight()
			index = index + 1
		else
			return nil
		end
	end

	return item
end

function ListView:createRender()
	local lastY = 0
	local position = 1
	while lastY < self.viewSize and position <= #self.itemData do
		local rowObject = self:render(nil, position)
		if (rowObject == nil) then break end
		self.listItems:addChild(rowObject)
		rowObject:setPosition(self.itemData[position].xInit, self.itemData[position].yInit)
		lastY = rowObject:getY()
		position = position + 1
	end
	self.listItems.yInit = self.listItems:getY()
end

function ListView:render(thisItem, id)
	if thisItem == nil then
	    thisItem = self:newListItem(id, self.itemData[id].objectData)
	else
		thisItem:removeChildAt(thisItem:getNumChildren())
		local callback = self.cfg.callback
		local t = callback(self.itemData[id].objectData)
		thisItem:addChild(t)
		thisItem.id = id
	end

	return thisItem
end

function ListView:updateRender()
	local firstItem = self.listItems:getChildAt(1)
	local lastItem = self.listItems:getChildAt(self.listItems:getNumChildren())
	local fx, fy = firstItem:getBounds(self)
	local lx, ly = lastItem:getBounds(self)
	local bottom = self.viewSize + self:getY()
	local fillToTop = fy > 0
	local fillToBottom = ly + lastItem:getHeight() < self.viewSize

	if fillToBottom then
		if (ly + lastItem:getHeight() < self.viewSize) and (lastItem.id ~= #self.itemData) then
			local y = lastItem:getY() + lastItem:getHeight()
			local recycledRow = firstItem
			self:render(recycledRow, lastItem.id + 1)
			self.listItems:addChild(recycledRow)
			recycledRow:setY(y)
		end

	elseif fillToTop then
		if fy > 0 and (firstItem.id ~= 1) then
			local recycledRow = lastItem
			local newItem = self:render(recycledRow, firstItem.id - 1)
			local y = firstItem:getY() - newItem:getHeight()
			self.listItems:addChildAt(recycledRow, 1)
			recycledRow:setY(y)
		end
	end

	if (self.cfg.onScroll ~= nil) then
		self.cfg.onScroll(self, firstItem)
	end
end

function ListView:trackVelocity(event)
	local timePassed = msTimer() - self.velocityPrevTime
	self.velocityPrevTime = self.velocityPrevTime + timePassed

	if prevY then
		velocity = (self.listItems:getY() - prevY) / timePassed
	end
	prevY = self.listItems:getY()
end

function msTimer()
	return math.floor(os.timer() * 500)
end

return ListView

Example

-- fonts
local font = TTFont.new("fonts/Roboto-Medium-webfont.ttf", 24)
local fontSub = TTFont.new("fonts/Roboto-Medium-webfont.ttf", 18)

-- rows layout
function row(item)
	local row = Sprite.new()

	local itembmp = Bitmap.new(Texture.new(item.icon))
	itembmp:setScale(0.75)
	itembmp:setPosition(0, 0)
	row:addChild(itembmp)

	local itemtitle = TextField.new(font, item.title)
	itemtitle:setPosition(itembmp:getX() + itembmp:getWidth() + 4, itembmp:getY() + itemtitle:getHeight())
	row:addChild(itemtitle)

	-- clickable button (arrow button)
	local arrow = Texture.new("gfx/arrow_right.png")
	local arrow_pressed = Texture.new("gfx/arrow_right_down.png")
	local button = Button.new(Bitmap.new(arrow), Bitmap.new(arrow_pressed))
	button:setScale(3, 2)
	button:setPosition(application:getContentWidth() - button:getWidth(), itembmp:getY())
	button:addEventListener("click", function()
		mytitle:setText(item.title)
	end)
	row:addChild(button)

	return row
end

-- datas
local data = {}
for i = 1, 10 do
	data[i] = row(
		{
			icon = "gfx/maurice.png",
			title = "Item ".. i,
		}
	)
end

-- scrollable list
local myList = ListView.new(
	{
		width = application:getContentWidth() - 32,
		height = 8 * application:getContentHeight() / 10,
		friction = 0.99,
		bgColor = 0xaaaaff,
		rowSnap = true, -- experimental feature
		data = data
	}
)
myList:setPosition(16, 0)

-- infos
mytitle = TextField.new(nil, "CLICK AN ARROW")
mytitle:setScale(3)
mytitle:setTextColor(0xff0000)
mytitle:setPosition(0, application:getContentHeight() - mytitle:getHeight())

-- stage
stage:addChild(myList)
stage:addChild(mytitle)