Skip to content

Instantly share code, notes, and snippets.

@mvyasu
Last active June 27, 2024 19:46
Show Gist options
  • Select an option

  • Save mvyasu/26fc7750367b1406f1255249eb856659 to your computer and use it in GitHub Desktop.

Select an option

Save mvyasu/26fc7750367b1406f1255249eb856659 to your computer and use it in GitHub Desktop.
A component made for Fusion that is a ScrollingFrame that allows extremely large numbers of elements to exist with better performance
-- originally by @boatbomber
-- modified by @mvyasu
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Fusion = require(ReplicatedStorage.Fusion)
local unwrap = require(ReplicatedStorage.Fusion.State.unwrap)
local Children = Fusion.Children
local ForPairs = Fusion.ForPairs
local Computed = Fusion.Computed
local OnChange = Fusion.OnChange
local Hydrate = Fusion.Hydrate
local Value = Fusion.Value
local Ref = Fusion.Ref
local New = Fusion.New
local Out = Fusion.Out
local COMPONENT_ONLY_PROPS = {
"ItemCount",
"ItemLength",
"ItemPadding",
"ItemPreloadCount",
"VelocityToRenderBelow",
"Destructor",
"RenderItem",
}
type VirtualScroller = {
ItemCount: Fusion.CanBeState<number>, --how many items are in the scroller
ItemLength: Fusion.CanBeState<number>, --how many pixels for the length of each item used for the size & position offset
ItemPadding: Fusion.CanBeState<number>?, --how many pixels is between each item
ItemPreloadCount: Fusion.CanBeState<number>?, --how many objects outside the ScrollingFrame's view that should be loaded on both ends
VelocityToRenderBelow: Fusion.CanBeState<number>?, --if the virtual scroller is moving faster than the specified rate, then it'll wait to render
Destructor: (key: any, value: any, metadata: any?) -> ()?, --the destructor function for the ForPairs which uses RenderItem
RenderItem: (index: number) -> GuiObject, metadata: any?, --takes the index of what item it is and returns a GuiObject
[any]: any
}
return function(props: VirtualScroller): ScrollingFrame
local windowSize = Value(Vector2.zero)
local canvasPosition = Value(Vector2.zero)
local scrollingDirection = Value(Enum.ScrollingDirection.Y)
local velocityToRenderBelow = props.VelocityToRenderBelow or math.huge
local itemPreloadCount = props.ItemPreloadCount or 1
local itemPadding = props.ItemPadding or 0
local itemLength = props.ItemLength
local itemCount = props.ItemCount
local isVertical = Computed(function()
return unwrap(scrollingDirection)==Enum.ScrollingDirection.Y
end)
local itemsToRender = Computed(function()
local currentCanvasPosition = unwrap(canvasPosition)
local currentWindowSize = unwrap(windowSize)
local currentScrollingDirection = unwrap(scrollingDirection)
local currentItemCount = unwrap(itemCount)
local currentItemLength = unwrap(itemLength)
local currentItemPadding = unwrap(itemPadding)
local minIndex = 0
local maxIndex = -1
if currentItemCount > 0 then
local canvasDirectionPosition = if unwrap(isVertical) then currentCanvasPosition.Y else currentCanvasPosition.X
local windowDirectionSize = if unwrap(isVertical) then currentWindowSize.Y else currentWindowSize.X
minIndex = 1 + math.floor(canvasDirectionPosition / (currentItemLength + currentItemPadding) )
maxIndex = math.ceil((canvasDirectionPosition + windowDirectionSize) / (currentItemLength + currentItemPadding) )
-- Add extra on either side for seamless load
local currentItemPreloadCount = unwrap(itemPreloadCount)
minIndex = math.clamp(minIndex-currentItemPreloadCount, 1, currentItemCount)
maxIndex = math.clamp(maxIndex+currentItemPreloadCount, 1, currentItemCount)
end
local items = table.create(math.max(0, maxIndex - minIndex + 1))
for i = minIndex, maxIndex do
items[i] = true
end
return items
end)
local fullCanvasSize = Computed(function()
local currentItemCount = unwrap(itemCount)
return (currentItemCount * unwrap(itemLength)) + (math.max(0, currentItemCount-1) * unwrap(itemPadding))
end)
local hydrateProps = table.clone(props)
for _,propToStrip in COMPONENT_ONLY_PROPS do
hydrateProps[propToStrip] = nil
end
local lastPauseWaitThread = nil
local lastVelocityUpdate = 0
local lastCanvasPosition = Vector2.zero
local scrollingFrame = Value()
return Hydrate(New "ScrollingFrame" {
VerticalScrollBarInset = Enum.ScrollBarInset.ScrollBar,
BackgroundTransparency = 1,
ClipsDescendants = true,
CanvasSize = Computed(function()
return if unwrap(isVertical) then UDim2.fromOffset(0, unwrap(fullCanvasSize)) else UDim2.fromOffset(unwrap(fullCanvasSize), 0)
end),
[Ref] = scrollingFrame,
[Out "AbsoluteWindowSize"] = windowSize,
[Out "ScrollingDirection"] = scrollingDirection,
[OnChange "CanvasPosition"] = function()
local scrollingFrame = unwrap(scrollingFrame)
if scrollingFrame==nil then
return
end
local previousCanvasPosition = lastCanvasPosition
local currentCanvasPosition = scrollingFrame.CanvasPosition
lastCanvasPosition = currentCanvasPosition
-- exit if the canvas hasn't moved enough to warrant rendering new items
local distance = (unwrap(canvasPosition) - currentCanvasPosition).Magnitude
local minimum = unwrap(itemLength) + unwrap(itemPadding)
if distance < minimum then return end
-- don't set the canvas position for the scrolling frame to update because it's moving too fast
local realDistance = (previousCanvasPosition - currentCanvasPosition).Magnitude
local currentVelocity = distance / (tick()-lastVelocityUpdate)
lastVelocityUpdate = tick()
if lastPauseWaitThread then
task.cancel(lastPauseWaitThread)
lastPauseWaitThread = nil
end
if realDistance > velocityToRenderBelow then
lastPauseWaitThread = task.delay(1/30, function()
canvasPosition:set(scrollingFrame.CanvasPosition)
end)
--if the virtual scroller is still scrolling, then the thread
--above will be cancelled and potentially render the items
--if the velocity is slow enough
return
end
canvasPosition:set(scrollingFrame.CanvasPosition)
end,
[Children] = ForPairs(itemsToRender, function(index: number)
local newRenderedItem, metadata = props.RenderItem(index)
return index, Hydrate(newRenderedItem)({
Name = "Item_"..index,
Size = Computed(function()
return if unwrap(isVertical) then UDim2.new(1, 0, 0, unwrap(itemLength)) else UDim2.new(0, unwrap(itemLength), 1, 0)
end),
Position = Computed(function()
local itemDimensionPosition = ((index-1) * unwrap(itemLength)) + ((index-1) * unwrap(itemPadding))
return if unwrap(isVertical) then UDim2.fromOffset(0, itemDimensionPosition) else UDim2.fromOffset(itemDimensionPosition, 0)
end),
}), metadata
end, props.Destructor),
})(hydrateProps)
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment