Custom Grid - with Custom scrolling and keyEvent handling for Roku

Amitdogra
6 min readMar 1, 2024

--

In this blog post, we’ll delve into the art of crafting Custom grid with custom scrolling and key event handling. Let’s dive in!

To integrate a custom grid, begin by organizing your project structure:

📁 CustomGrid
│ ├─ 📄 CustomGrid.xml
│ └─ 📄 CustomGrid.brs

📁 ChannelItem
│ ├─ 📄 ChannelItem.xml
│ └─ 📄 ChannelItem.brs

In this structure:

  • The CustomGrid folder contains two files: customgrid.xml and customgrid.brs, responsible for managing the custom grid functionality, including translation, scrolling, and key event handling.
  • The ChannelItem folder contains two files: ChannelItem.xml and ChannelItem.brs, which define the design and behavior of individual items displayed within the grid.

Note: Code for above files (CustomGrid.xml, CustomGrid.brs, ChannelItem.xml, ChannelItem.brs) are attached below👇👇👇.

Next step, to integrate a custom grid into your Roku app, simply add the following snippet code to any UI view or page:

<CustomGrid id="gridItems" clippingRect="[0, 0, 1737, 796]" translation="[10, 50]" itemComponentName="ChannelItem" />
' here itemcomponentName is individual griditem , (this will use ChannelItem node )

This snippet initializes the custom grid component and specifies attributes such as clipping rectangle, translation, and the item component name. You can easily modify these fields to suit your specific needs and design preferences.

IMPORTANT: To populate and display data within the grid, use ContentNode.
Refer to the visual guide below for a seamless integration.

.

.

.

Code

  • CustomGrid.xml
<?xml version="1.0" encoding="UTF-8"?>

<component name="CustomGrid" extends="Group">
<interface>
<field id="content" type="node" onChange="onContentChanged" />
<field id="itemComponentName" type="string" />
<field id="numColumns" type="integer" value="4" />
<field id="itemSpacings" type="intarray" value="[19, 40]" />
<field id="itemSize" type="intarray" value="[300, 250]" />
<field id="rowItemFocusIndex" type="intarray" value="[0, 0]" onChange="onRowItemFocusIndexChanged" />
<field id="rowItemSelectedIndex" type="intarray" onChange="onRowItemSelected" alwaysNotify="true" />
<field id="selectedItem" type="node" />
</interface>

<script type="text/brightscript" uri="./CustomGrid.brs" />

<children>
<Group id="resultGridWrapper" />
<Animation id="gridScrollAnimation" duration="0.5" easeFunction="inOutQuad" optional="true">
<Vector2DFieldInterpolator id="gridScrollAnimationInterpolator" key="[0.0, 1.0]" fieldToInterp="resultGridWrapper.translation" />
</Animation>
</children>
</component>
  • CustomGrid.brs
'******************************************************************************************
'* File: CustomGrid.brs
'******************************************************************************************

'***********************************************************************
'* init()
'***********************************************************************
function init() as void
m.resultGridWrapper = m.top.findNode("resultGridWrapper")
m.gridScrollAnimation = m.top.findNode("gridScrollAnimation")
m.gridScrollAnimationInterpolator = m.top.findNode("gridScrollAnimationInterpolator")

m.top.observeField("focusedChild", "onFocusedChildChanged")
m.displayedRows = [0, 1]
m.displayedColumn = 0
m.rowItemFocusIndex = [0, 0]
m.lastTranslation = [0, 0]
end function

'***********************************************************************
'* onContentChanged()
'***********************************************************************
function onContentChanged() as void
m.content = m.top.content

currentCount = m.content.getChildCount()
if (m.content <> invalid)
if (m.resultGridWrapper.getChildCount() > 0 and m.itemCount <> invalid)
endItemIndex = currentCount - 1
startItemIndex = m.itemCount
else
endItemIndex = m.content.getChildCount() - 1
startItemIndex = 0
end if

m.itemCount = m.content.getChildCount()
itemComponentName = m.top.itemComponentName
for i = startItemIndex to endItemIndex
item = m.resultGridWrapper.createChild(itemComponentName)
item.itemContent = m.content.getChild(i)
end for

updateItemLayout(startItemIndex, endItemIndex)
end if
end function

'***********************************************************************
'* updateItemLayout()
'***********************************************************************
function updateItemLayout(startItemIndex = 0 as integer, endItemIndex = m.content.getChildCount() as integer)
itemSpacings = m.top.itemSpacings
itemSize = m.top.itemSize
for i = startItemIndex to endItemIndex
item = m.resultGridWrapper.getChild(i)
item.translation = [((i mod 4) * (itemSpacings[0] + itemSize[0])), caculateRowIndex(i) * (itemSpacings[1] + itemSize[1])]
end for
end function


'***********************************************************************
'* onFocusedChildChanged(data)
'* Should be overridden by inherited view.
'***********************************************************************
function onFocusedChildChanged(data = invalid) as void

if(m.rowItemFocusIndex = invalid)
m.rowItemFocusIndex = m.top.rowItemFocusIndex
end if
if(m.rowItemFocusIndex <> invalid)
itemFocusedIndex = m.rowItemFocusIndex[0] * 4 + m.rowItemFocusIndex[1]
if (m.top.hasFocus() and m.resultGridWrapper.getChildCount() > 0)
m.resultGridWrapper.getChild(itemFocusedIndex).setFocus(true)
end if
end if
end function

'***********************************************************************
'* onKeyRight(isPressed, timeInMs)
'***********************************************************************
function onKeyRight(isPressed as boolean, timeInMs as longinteger) as boolean
isHandled = false
if (isPressed = true)
rowItemFocusIndex = m.rowItemFocusIndex
rowIndex = rowItemFocusIndex[0]
rowItemIndex = rowItemFocusIndex[1]
itemFocusedIndex = m.rowItemFocusIndex[0] * 4 + m.rowItemFocusIndex[1]

if ((rowItemIndex < 3) and (itemFocusedIndex < m.itemCount - 1))
m.top.rowItemFocusIndex = [rowIndex, rowItemIndex + 1]
end if

isHandled = true
end if

return isHandled
end function

'***********************************************************************
'* onKeyLeft(isPressed, timeInMs)
'***********************************************************************
function onKeyLeft(isPressed as boolean, timeInMs as longinteger) as boolean
isHandled = false
if (isPressed = true)
rowItemFocusIndex = m.rowItemFocusIndex
rowIndex = rowItemFocusIndex[0]
rowItemIndex = rowItemFocusIndex[1]

if (rowItemIndex > 0)
m.top.rowItemFocusIndex = [rowIndex, rowItemIndex - 1]
isHandled = true
else
isHandled = false
end if
end if

return isHandled
end function

'***********************************************************************
'* onKeyDown(isPressed, timeInMs)
'***********************************************************************
function onKeyDown(isPressed as boolean, timeInMs as longinteger) as boolean
isHandled = false

if (isPressed = true)
if (m.lastTimeInMs <> invalid)
if (getCurrentTimeInMs() - m.lastTimeInMs < m.gridScrollAnimation.duration * 500)
return true
end if
end if
rowItemFocusIndex = m.rowItemFocusIndex
rowIndex = rowItemFocusIndex[0]
rowItemIndex = rowItemFocusIndex[1]
maxRowIndex = caculateRowIndex(m.itemCount - 1)
if (rowIndex < maxRowIndex)
lastRowItemMaxIndex = (m.itemCount - 1) mod 4
if (rowIndex + 1 = maxRowIndex and rowItemIndex > lastRowItemMaxIndex)
rowItemIndex = lastRowItemMaxIndex
m.displayedColumn = lastRowItemMaxIndex
end if
m.top.rowItemFocusIndex = [rowIndex + 1, rowItemIndex]
end if
m.lastTimeInMs = timeInMs
isHandled = true
end if

return isHandled
end function

'***********************************************************************
'* onKeyLeft(isPressed, timeInMs)
'***********************************************************************
function onKeyUp(isPressed as boolean, timeInMs as longinteger) as boolean
isHandled = false

if (isPressed = true)
rowItemFocusIndex = m.rowItemFocusIndex
rowIndex = rowItemFocusIndex[0]
rowItemIndex = rowItemFocusIndex[1]

if (rowIndex > 0)
if (m.lastTimeInMs <> invalid)
if (getCurrentTimeInMs() - m.lastTimeInMs < m.gridScrollAnimation.duration * 500)
return true
end if
end if
m.top.rowItemFocusIndex = [rowIndex - 1, rowItemIndex]
isHandled = true
m.lastTimeInMs = timeInMs
end if
end if

return isHandled
end function


'***********************************************************************
'* onKeyOK(isPressed, timeInMs)
'***********************************************************************
function onKeyOK(isPressed as boolean, timeInMs as longinteger) as boolean

isHandled = false
if (isPressed = true)

m.top.rowItemSelectedIndex = m.rowItemFocusIndex
isHandled = true
end if

return isHandled
end function


'***********************************************************************
'* onRowItemFocusIndexChanged()
'***********************************************************************
function onRowItemFocusIndexChanged()
m.rowItemFocusIndex = m.top.rowItemFocusIndex
itemFocusedIndex = m.rowItemFocusIndex[0] * 4 + m.rowItemFocusIndex[1]
newFocusedItem = m.resultGridWrapper.getChild(itemFocusedIndex)

if(m.top.isInFocusChain())
newFocusedItem.setFocus(true)
if (m.displayedColumn = m.rowItemFocusIndex[1])
if (m.rowItemFocusIndex[0] > m.displayedRows[0])
m.displayedRows = [m.rowItemFocusIndex[0] - 1, m.rowItemFocusIndex[0]]
handleGridScrollAnimation()
else if (m.rowItemFocusIndex[0] < m.displayedRows[1])
m.displayedRows = [m.rowItemFocusIndex[0], m.rowItemFocusIndex[0] + 1]
handleGridScrollAnimation(true)
end if
end if
m.displayedColumn = m.rowItemFocusIndex[1]
end if
end function

'***********************************************************************
'* handleGridScrollAnimation()
'***********************************************************************
function handleGridScrollAnimation(reverse = false as boolean)
yItemSpacing = m.top.itemSpacings[1]

offsetY = yItemSpacing + m.top.itemSize[1]
m.gridScrollAnimation.duration = 0.5

if (reverse = true) offsetY = -offsetY
nextTranslation = [0, m.lastTranslation[1] - offsetY]

m.gridScrollAnimationInterpolator.keyValue = [m.resultGridWrapper.translation, nextTranslation]
m.gridScrollAnimation.control = "start"
m.lastTranslation = nextTranslation
m.lastReverse = reverse
end function

'*************************************************************
'** caculateRowIndex()
'*************************************************************
function caculateRowIndex(index as integer) as integer
numColumns = m.top.numColumns
if (Int((index + 1) / numColumns) = (index + 1) / numColumns)
rowIndex = (index + 1) / numColumns
else
rowIndex = Int((index + 1) / numColumns) + 1
end if

return rowIndex - 1
end function


'***********************************************************************
'* onRowItemSelected()
'***********************************************************************
function onRowItemSelected() as void
content = m.top.content
numColumns = m.top.numColumns
if (content <> invalid)
rowItemSelected = m.top.rowItemSelectedIndex
item = content.getChild(numColumns * rowItemSelected[0] + rowItemSelected[1])

if (item <> invalid)
m.top.selectedItem = item
m.top.selectedItem = invalid
end if
end if
stop
end function


function onKeyEvent(key as string, isPressed as boolean) as boolean
isHandled = false
if(isPressed = true)

currentTime = getCurrentTimeInMs()
if(key = "up")
return onKeyUp(isPressed, currentTime)

else if(key = "down")
return onKeyDown(isPressed, currentTime)

else if(key = "left")
return onKeyLeft(isPressed, currentTime)

else if(key = "right")
return onKeyRight(isPressed, currentTime)
else if(key = "ok")
return onKeyOK(isPressed, currentTime)
end if

end if
return isHandled
end function

' utility functions

'******************************************************************************************
'* getCurrentTimeInMs()
'* Return the current time in miliseconds
'******************************************************************************************
function getCurrentTimeInMs() as longinteger
dt = getSystemDateTime()

inSec = dt.AsSeconds()
inMs = inSec * 1000 + dt.GetMilliseconds()

return inMs
end function

'******************************************************************************************
'* getSystemDateTime()
'* Return the roDateTime object to use
'******************************************************************************************
function getSystemDateTime() as object
if (m.systemDateTime = invalid)
m.systemDateTime = CreateObject("roDateTime")
else
' Update to current time
m.systemDateTime.Mark()
end if

return m.systemDateTime
end function
  • ChannelItem.xml
<?xml version="1.0" encoding="utf-8"?>
<component name="ChannelItem" extends="Group">
<interface>
<field id="width" type="float" />
<field id="height" type="float" />
<field id="itemContent" type="node" onChange="onItemContentChanged" />
<field id="itemHasFocus" type="boolean" onChange="onItemHasFocusChanged" />
<field id="scaleFactor" type="vector2d" onChange="onScaleFactorChanged" />
</interface>
<script type="text/brightscript" uri="./ChannelItem.brs" />

<children>
<Group id="maskGroup" scaleRotateCenter="[210, 189]">
<MaskGroup maskUri="pkg://images/item-mask-hd.png" maskSize="[300,250]">
<Poster id="background" uri="pkg://images/swimlane-background.png" blendColor="0x191919" width="300" height="250" />
<Poster id="poster" width="300" height="170" loadWidth="300" loadHeight="170" loadDisplayMode="scaleToZoom" />
</MaskGroup>
</Group>

<Poster id="logo" width="30" height="30" translation="[16, 200]"/>
<Label id="titleLabel" lineSpacing="0" wrap="true" maxLines="2" width="260" translation="[125, 190]" />
</children>
</component>
  • ChannelItem.brs
'******************************************************************************************
'* File: ChannelItem.brs
'******************************************************************************************

'***********************************************************************
'* init()
'***********************************************************************
function init() as void
m.lastFocusPercent = 0
m.scale = [0, 0]

m.backgroundColor = "0x191919"
m.focusBackgroundColor = "0x385DF7"


m.titleLabel = m.top.findNode("titleLabel")
m.maskGroup = m.top.findNode("maskGroup")
m.background = m.top.findNode("background")
m.poster = m.top.findNode("poster")
m.logo = m.top.findNode("logo")
m.progressBar = m.top.findNode("progressBar")

m.background.blendColor = m.backgroundColor

m.top.observeField("focusedChild", "onFocusedChildChanged")
end function

'***********************************************************************
'* onItemContentChanged()
'***********************************************************************
function onItemContentChanged() as void
content = m.top.itemContent
' here, random image url is used, You can collect url from m.top.itemContent

random = str(Rnd(200)).trim() 'random number 0 to 200
defaultImgUrl = "https://picsum.photos/id/"+random+"/420/219"

if(content.logoUrl <> invalid and content.logoUrl <> "")
m.logo.uri = content.logoUrl
else
m.logo.uri = defaultImgUrl
endif
m.titleLabel.text = content.title

if(content.imageUrl <> invalid and content.imageUrl <> "")
m.poster.uri = content.imageUrl
else
m.poster.uri = defaultImgUrl
endif
end function

'***********************************************************************
'* onItemHasFocusChanged()
'***********************************************************************
function onItemHasFocusChanged() as void
if (m.top.itemHasFocus)
m.background.blendColor = m.focusBackgroundColor
else
m.background.blendColor = m.backgroundColor
end if
end function

'***********************************************************************
'* onFocusedChildChanged()
'***********************************************************************
function onFocusedChildChanged() as void
m.top.itemHasFocus = m.top.hasFocus()
end function

'***********************************************************************
'* onScaleFactorChanged()
'***********************************************************************
function onScaleFactorChanged() as void
m.maskGroup.scale = m.top.scaleFactor
end function

--

--

Amitdogra

Passionate Roku developer with a love for web technologies and backend wizardry. 🚀 ✨ #Android #Streaming #Roku #Web #Nodejs