ScrollingFrames need a ScrollSpeed/ScrollSensitivity property

Edit: For anyone running into this who wants a code snippet to fix this, here you go

As a Roblox developer, it is currently too hard to give players an easy scrolling experience by controlling a ScrollingFrame’s amount scrolled per mouse-scroller-tick, without writing custom logic.

If Roblox is able to address this issue, it would improve my development experience because ScrollingFrames would require less custom code and be less painful.

I want to scroll exactly 45 pixels each scroll-tick. Instead, it seems to scroll 135 pixels, and there doesn’t seem to be a publicly accessible built-in method of fixing this.

Here’s a file with a ScrollingFrame showcasing my issue:
scroll speed issue example.rbxm (9.6 KB)

The closest thing that might be what I’m looking for could be ScrollingFrame.ScrollVelocity, but not only is it hidden in Properties panel, it requires permission 5 to use.

The official Roblox dev docs page doesn’t have any info about it either:
image

Other people seem to have run into this issue aswell:

pls implement/give access
kthx for reading🤠

55 Likes

Hello, I’d like to follow up on this. Any solutions? It’s really annoying, but I’m not sure if it counts as a bug

2 Likes

Huge support, our game has some massive ScrollingFrames and more granularity with them would be appreciated.

3 Likes

I have made my own scrolling frame because of this, definite support!

(God this was from 2022…)

The solution of scripting your own scrolling functionality is always an option, but that feels like a hack.
I think the most efficient workaround right now is to just resize the GUIs around this annoyingly intrusive issue.

1 Like

Please add this, it’s such a simple feature that it’s a bit ridiculous it doesn’t already exist. There are so many times where I’ve wanted to reduce the scrolling sensitivity on a scrolling frame because the default sensitivity results in it skipping past content entirely.

3 Likes

This suddenly blew up, but yes. Very good feature that should be added.

1 Like

Any updates on this?
I find it kind of ridiculous that the community has been asking for this since 2015 and its still not available

3 Likes

I definitely agree with this! The solution I currently have is making big margins between stuff inside of Scrolling Frames, but I am unsure of a good solution.

Here are two other solutions I’ve come up with:

  1. UIPageLayout: sort of flips though the data values as you scroll. Im not entirely sure how to use it.

  2. Make your own scroll frame. You can use GuiObject.InputBegan to find the scroll wheel Click here to see All About GuiObjects

1 Like

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…

4 Likes

Here’s some more concise standalone code for anyone needing it:

local CustomScrollingIncrements = {
	-- [ScrollingFrame] = scrolling increment
	[script.Parent.ScrollingFrame] = 10,
	--[script.Parent.AnotherScrollingFrame] = 5
}

local ContextActionService = game:GetService("ContextActionService")
local UserInputService = game:GetService("UserInputService")
local MousedOver = {}

UserInputService.InputChanged:Connect(function(Input)
	if Input.UserInputType == Enum.UserInputType.MouseWheel then
		local Rotation: number = Input.Position.Z
		for ScrollingFrame,v in pairs(MousedOver) do
			ScrollingFrame.CanvasPosition += Vector2.new(0,CustomScrollingIncrements[ScrollingFrame]*Rotation*-1)
		end
	end
end)

for ScrollingFrame: ScrollingFrame, _ in pairs(CustomScrollingIncrements) do
	ScrollingFrame.MouseEnter:Connect(function()
		ScrollingFrame.ScrollingEnabled = false
		MousedOver[ScrollingFrame] = true
		ContextActionService:BindAction(
			"CustomScrollIncrement",
			function () 
				return Enum.ContextActionResult.Sink 
			end,
			false,
			Enum.UserInputType.MouseWheel
		)
	end)
	ScrollingFrame.MouseLeave:Connect(function()
		ScrollingFrame.ScrollingEnabled = true
		MousedOver[ScrollingFrame] = nil
		ContextActionService:UnbindAction("CustomScrollIncrement")
	end)
end

4 Likes

Guys, roblox, come on… You really have to update your scrollingFrame. It’s hard to find when you’ve scrolled to the bottom, and the fact the scrolling amount is just innapropriate makes all the work I just put in implementing this completely useless, because this just feels like an incomplete feature that you can tell hasn’t been modified in like 10+ years

2 Likes