Last active
June 27, 2024 19:46
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| -- 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