Here is some code I made (as part of a UI framework thing). You can control how much to scroll, set snapping, how much time a scroll lasts, and it keeps the original behaviour of scroll bars, mobile scrolling (and probably controller scrolling, idk how controllers work)
local HttpService = game:GetService("HttpService")
local RunService = game:GetService("RunService")
local TickModule = require(game.ReplicatedStorage.Modules.TickModule)
local ScrollingFrameTable = nil
local ScrollingFrame = script.Parent
local GUID = HttpService:GenerateGUID(false)
local Scroll = {}
-- // The degree of the quadratic function. y = a(x-h)^n + k
local n = 6
local Axis = {
X = {},
Y = {},
}
for _, a in ipairs({"X","Y"}) do
local t = Axis[a]
t.StartTick = TickModule:UTC()
t.Tick = TickModule:UTC()
t.h = 1
t.k = 0
t.b = 0
t.a = 0
t.v0 = 0
t.P_x = function(x) x = math.clamp(x,0,t.h) return t.a*((x - t.h)^n) + t.k end
t.V_x = function(x) x = math.clamp(x,0,t.h) return n*t.a*((x - t.h)^(n-1)) end
t.A_x = function(x) x = math.clamp(x,0,t.h) return t.h == 0 and 1 or x/t.h end -- Returns the progression, as an alpha value (0% - 100%)
end
local Task = nil
local CanvasPositionModifiedExternally = false
local function CanvasPositionChanged() -- When clicking the scrollbar, disable the custom scrolling
Disconnect()
CanvasPositionModifiedExternally = true
if Task then task.cancel(Task) end
Task = task.delay(0.5,function()
RunService:BindToRenderStep("ScrollingFrameSnapping_"..GUID,Enum.RenderPriority.Last.Value, function()
RunService:UnbindFromRenderStep("ScrollingFrameSnapping_"..GUID)
if not ScrollingFrameTable then return end
ScrollingFrameTable:ScrollByAmount(0, "Y", true, 2) -- TODO -- Make it only apply to the axis that was modified
ScrollingFrameTable:ScrollByAmount(0, "X", true, 2)
end)
end)
end
local Connection = ScrollingFrame:GetPropertyChangedSignal("CanvasPosition"):Connect(CanvasPositionChanged)
local function ChangeCanvasPosition(Position : Vector2)
Connection:Disconnect()
CanvasPositionModifiedExternally = false
ScrollingFrame.CanvasPosition = Position
Connection = ScrollingFrame:GetPropertyChangedSignal("CanvasPosition"):Connect(CanvasPositionChanged)
end
local IsBound = false
function Disconnect()
if not IsBound then return end -- print("Unbound")
RunService:UnbindFromRenderStep("ScrollingFrameUpdateCanvasPosition_"..GUID)
IsBound = false
end
local function BindToRenderStepped()
if IsBound then return end -- print("Bound")
IsBound = true
RunService:BindToRenderStep("ScrollingFrameUpdateCanvasPosition_"..GUID,Enum.RenderPriority.First.Value, function()
local Tick = TickModule:UTC()
Axis.X.Tick = Tick
Axis.Y.Tick = Tick
ChangeCanvasPosition(Vector2.new(Axis.X.P_x(Axis.X.Tick - Axis.X.StartTick),Axis.Y.P_x(Axis.Y.Tick - Axis.Y.StartTick)))
if Axis.X.A_x(Axis.X.Tick - Axis.X.StartTick) + Axis.Y.A_x(Axis.Y.Tick - Axis.Y.StartTick) == 2 then Disconnect() end
end)
end
local function UpdateCanvasPositionAlpha() -- TODO -- update from CanvasPositionChanged
local X = Axis["X"].k/(ScrollingFrame.AbsoluteCanvasSize["X"] - ScrollingFrame.AbsoluteSize["X"])
local Y = Axis["Y"].k/(ScrollingFrame.AbsoluteCanvasSize["Y"] - ScrollingFrame.AbsoluteSize["Y"])
-- NaN protection
X = X == X and X or 0
Y = Y == Y and Y or 0
ScrollingFrameTable.CanvasPositionAlpha = Vector2.new(X, Y)
local Signal = ScrollingFrameTable:GetPropertyChangedSignal("CanvasPositionAlpha")
if not Signal then return end -- Not initialized yet
Signal:Fire(ScrollingFrameTable.CanvasPositionAlpha)
end
function Scroll:OverwriteScroll(ScrollDistance : number, a : "X"|"Y", SnapDistance : number, SnapOffset : number, ScrollTime : number)
ScrollDistance *= -1 -- Inverting both axis cuz it's dumb
local CanvasPosition = ScrollingFrame.CanvasPosition
if CanvasPositionModifiedExternally then
Axis[a].k = CanvasPosition[a]
end
local Raw_k = Axis[a].k + ScrollDistance
Raw_k = SnapDistance ~= 0 and math.round(Raw_k/SnapDistance)*SnapDistance or Raw_k
Axis[a].b = CanvasPosition[a] -- Axis[a].P_x(TickModule:UTC() - Axis[a].Tick)
Axis[a].k = math.clamp(Axis[a].k + ScrollDistance, 0, math.max(0,ScrollingFrame.AbsoluteCanvasSize[a] - ScrollingFrame.AbsoluteSize[a]))
Axis[a].k = SnapDistance ~= 0 and math.round((Axis[a].k - SnapOffset)/SnapDistance)*SnapDistance + SnapOffset or Axis[a].k
-- The later part is to reduce the ScrollTime when at the edges, prevents it from slowing down at the edges
Axis[a].h = math.abs(ScrollTime * ((Axis[a].b - Axis[a].k)/(Axis[a].b - Raw_k))) -- TODO -- replace with proper math, by finding x when y = 0 or max distance
Axis[a].h = Axis[a].h == Axis[a].h and Axis[a].h or 0 -- NaN protection
Axis[a].a = (Axis[a].b - Axis[a].k)/((-Axis[a].h)^n)
Axis[a].a = Axis[a].a == Axis[a].a and Axis[a].a or 0 -- NaN protection
Axis[a].v0 = Axis[a].a*n * ((-Axis[a].h)^(n-1))
Axis[a].StartTick = TickModule:UTC()
UpdateCanvasPositionAlpha()
task.defer(BindToRenderStepped)
end
function Scroll:OverwriteScrollRaw(Position : number, a : "X"|"Y", SnapDistance : number, SnapOffset : number, ScrollTime : number)
local k = Position
local CanvasPosition = ScrollingFrame.CanvasPosition
Axis[a].b = CanvasPosition[a] -- yAxis.P_x(TickModule:UTC() - yAxis.Tick)
Axis[a].k = math.clamp(k, 0, math.max(0,ScrollingFrame.AbsoluteCanvasSize[a] - ScrollingFrame.AbsoluteSize[a]))
Axis[a].k = SnapDistance ~= 0 and math.round((Axis[a].k - SnapOffset)/SnapDistance)*SnapDistance + SnapOffset or Axis[a].k
Axis[a].h = math.abs(ScrollTime)
Axis[a].a = (Axis[a].b - Axis[a].k)/((-Axis[a].h)^n)
Axis[a].a = Axis[a].a == Axis[a].a and Axis[a].a or 0 -- NaN protection
Axis[a].v0 = Axis[a].a*n * ((-Axis[a].h)^(n-1))
Axis[a].StartTick = TickModule:UTC()
UpdateCanvasPositionAlpha()
task.defer(BindToRenderStepped)
end
function Scroll:Init(t)
ScrollingFrameTable = t
UpdateCanvasPositionAlpha()
end
return Scroll
Remove the UpdateCanvasPositionAlpha() function, or modify it if you’d want to use that property. It doesn’t apply when scrolling
The CanvasPositionModifiedExternally
variable will also probably be ineffective with SignalBehaviour set to Deferred, as I have noticed that changes to properties become deffered
This is how I use it in my code to scroll the scrolling frame through scripts
function Table:ScrollByAmount(Amount : number, Axis : "X"|"Y", SnapEnabled : boolean?, ScrollTimeOverwrite : number?)
local AbsoluteSize = Table.Instance.AbsoluteSize
local SnapDistance = SnapEnabled == false and 0 or Table.SnapDistance[Axis].Scale * AbsoluteSize[Axis] + Table.SnapDistance[Axis].Offset
local SnapOffset = SnapEnabled == false and 0 or Table.SnapOffset[Axis].Scale * AbsoluteSize[Axis] + Table.SnapOffset[Axis].Offset
ModuleScript:OverwriteScroll(Amount, Axis, SnapDistance, SnapOffset, ScrollTimeOverwrite or Table.ScrollTime)
end
function Table:ScrollToPosition(Position : number, Axis : "X"|"Y", SnapEnabled : boolean?, ScrollTimeOverwrite : number?)
local AbsoluteSize = Table.Instance.AbsoluteSize
local SnapDistance = SnapEnabled == false and 0 or Table.SnapDistance[Axis].Scale * AbsoluteSize[Axis] + Table.SnapDistance[Axis].Offset
local SnapOffset = SnapEnabled == false and 0 or Table.SnapOffset[Axis].Scale * AbsoluteSize[Axis] + Table.SnapOffset[Axis].Offset
ModuleScript:OverwriteScrollRaw(Position, Axis, SnapDistance, SnapOffset, ScrollTimeOverwrite or Table.ScrollTime)
end
(ModuleScript being the first code snippet of this reply)
And code snippet to overwrite the default scrolling behaviour when using the scroll wheel
UserInputService.PointerAction:Connect(function(Wheel : number, Pan : Vector2, Pinch : number, GameProcessedEvent : boolean)
table.insert(ScheduledFunctions,function(Guis, ButtonIndex)
for i, v in ipairs(Guis) do
local InstanceTable = MouseEvents:GetInstanceTableFromInstance(v)
if not InstanceTable then continue end
if not InstanceTable:IsA("ScrollingFrame") then continue end
local AbsoluteSize = InstanceTable.Instance.AbsoluteSize
Wheel = math.sign(Wheel)
local Cond1 = InstanceTable.Instance.ScrollingDirection == Enum.ScrollingDirection.XY
local Cond2 = UserInputService:IsKeyDown(Enum.KeyCode.LeftShift)
local Axis = if Cond1 then Cond2 and "X" or "Y" else InstanceTable.Instance.ScrollingDirection.Name
local Scroll = Wheel * (InstanceTable.ScrollDistance[Axis].Scale * AbsoluteSize[Axis] + InstanceTable.ScrollDistance[Axis].Offset)
local SnapDistance = InstanceTable.SnapDistance[Axis].Scale * AbsoluteSize[Axis] + InstanceTable.SnapDistance[Axis].Offset
local SnapOffset = InstanceTable.SnapOffset[Axis].Scale * AbsoluteSize[Axis] + InstanceTable.SnapOffset[Axis].Offset
InstanceTable["PointerAction"]:Fire(Vector2[string.lower(Axis).."Axis"]*Scroll, i, ButtonIndex ~= 1)
if InstanceTable.BruteForceScroll or ButtonIndex == 1 then
local ModuleScript = InstanceTable.Instance:FindFirstChild("_Scroll")
if not ModuleScript then return end
ModuleScript = require(ModuleScript)
ModuleScript:OverwriteScroll(Scroll, Axis, SnapDistance, SnapOffset, InstanceTable.ScrollTime)
end
end
end)
end)
This code is, again, from a UI framework that has custom events for MouseEnter and MouseLeave, and other (such as PointerAction). This code should still work by removing that stuff, and connecting to UserInputService.PointerAction when the mouse enters the scrolling frame, and disconnecting it when leaving
Scroll times of 0 don’t work (it doesn’t overwrite the default scrolling), but very small scroll times work. I actually forgot how my code overwrites the default scrolling, it might be that changing CanvasPosition stops the default scrolling, but that doesn’t explain why scroll times of 0 don’t work…