UIGradient Tweener: Easily animate the color of your UIGradients!

Hello everyone :waving_hand:!


Have you ever tried to tween the color of a UIGradient? If you have, you probably noticed that it doesn’t work, since ColorSequences can’t be animated, just like many other data types.

That’s why I decided to create UIGradient Tweener!

This module lets you very easily animate the color of a UIGradient, as it follows roughly the same structure as a normal tween and works with any color sequence!


The module:
GradientTweener.rbxm (7.8 KB)
Roblox won’t let me publish it in the Creator Hub somehow(“Missusing Roblox Systems”)


Functions:

GradientTweener.Create:
(UIGradient: UIGradient, TweenInf: TweenInfo, NewColorSequence: ColorSequence) -> typeof(GradientTweener.Create())

The constructor function. It allows you to create a tween for a UIGradient.

GetTweenObjectFromGradient:
(UIGradient:UIGradient) -> typeof(GradientTweener.Create())?

Returns the Tween object associated with an UIGradient. If no tween object where for for a specific UIGradient, it returns nil.


Properties:

  • self.Instance: UIGradient → The UIGradient that will be tweened
  • self.TweenInfo: TweenInfo → The TweenInfo used (cannot be changed during execution)
  • self.ColorSequence: ColorSequence → The target color sequence

Methods:

  • self:Play() → Plays the tween
  • self:Pause() → Pauses the tween
  • self:Cancel() → Pauses the tween and put it at its original state
  • self:Destroy() → Destroy the tween object

Example:

local GradientTweener = require(game.ReplicatedStorage.GradientTweener)

local frame = script.Parent

--The target ColorSequence
local new_color = ColorSequence.new(
    {
        ColorSequenceKeypoint.new(0, Color3.fromRGB(0, 0, 255)),
        ColorSequenceKeypoint.new(.2, Color3.fromRGB(238, 0, 255)),
        ColorSequenceKeypoint.new(1, Color3.fromRGB(255, 0, 4))
    }
)

--Creating the tween object
local tween = GradientTweener.Create(
    frame.UIGradient, 
    TweenInfo.new(2, Enum.EasingStyle.Exponential, Enum.EasingDirection.InOut, 0, true, 1),
    new_color
)

--We play the tween !
tween:Play()

task.wait(1)

--Pausing the tween
tween:Pause()

task.wait(1)

--Resuming the tween.
tween:Play()

Result:


Advantages:

  • Can tween any type of sequence
  • Easy to use (If you know how to use TweenService, it uses a similar structure)

Disadvantages:

  • No RBXScriptConnection to intercept events (will be fixed in a future update)

Source code (GradientTweener):

local m = {}

local ProgressionFormulas = require(script:WaitForChild("ProgressionFormulas"))

local rs = game:GetService("RunService")

local tweens = {}
tweens.__index = tweens

local createds = {}

type TweenObject = {
	["Instance"]: UIGradient,
	["TweenInfo"]: TweenInfo,
	["ColorSequence"]: ColorSequence,
} & typeof(tweens)


--This function links the new keypoints to the old ones and returns the link table.
local function LinkKeypoints(Old:{ColorSequenceKeypoint}, New:{ColorSequenceKeypoint})
	--It works because the amount of points is always equal to the amount of keypoints of the new sequence
	--Which means that the table of the old sequence is bijective in the new one (due to the part of code above)
	local links: {{{["Time"]:number, ["Value"]:number}}} = {}

	--Doing what I said earlier î (this is an up arrow btw)
	for index, old in Old do
		local new_keypoint = New[index]

		table.insert(links, {
			{
				Time = old.Time,
				Value = old.Value
			},
			{
				Time = new_keypoint.Time,
				Value = new_keypoint.Value
			}
		})
	end
	
	return links
end

local function ReconcileKeypoints(current_sequence:ColorSequence, new_sequence:ColorSequence)
	--This part of the code reconciles the keypoints of the old sequence to the new one
	local current_keypoints = current_sequence.Keypoints
	local new_keypoints = new_sequence.Keypoints

	local current_amount = #current_keypoints
	local new_amount = #new_keypoints
	
	--If the amount of keypoints between both is different. We need to add lerped keypoints.
	--What this part does is, it will add the missing keypoints to the old sequence so they can also be tweened.
	if current_amount ~= new_amount then
		--If the new sequence has more keypoints than the old one,
		--we need to add the missing keypoints to the old sequence using color lerping.

		--Getting the lowest and highest amount of keypoints so we can adjust them to have the same quantity of keypoints
		--Tables are using the same address in memory, meaning using variables isn't a problem.
		local lowest = current_amount < new_amount and current_keypoints or new_keypoints
		local highest = current_amount > new_amount and current_keypoints or new_keypoints

		for _, new in highest do
			if #lowest == #highest then
				break
			end

			local found = false
			local key_time = new.Time

			for _, old in lowest do
				--If we found a keypoint with the same time, we don't need to do anything. It will be tweened.
				if old.Time == key_time then
					found = true
					break
				end
			end

			--If not keypoints were found in the old sequence, we need to add it.
			if not found then
				--Adding a new keypoint to the current time and with lerped color from the current sequence
				--Blank values.
				local previous_keypoint: ColorSequenceKeypoint, next_keypoint:ColorSequenceKeypoint =
					ColorSequenceKeypoint.new(0, Color3.fromRGB(255,255,255)),
					ColorSequenceKeypoint.new(0, Color3.fromRGB(255,255,255))

				--Searching the previous and next keypoints to lerp color between them
				for _, kp in lowest do
					local t = kp.Time

					if t < key_time and previous_keypoint.Time >= t then
						previous_keypoint = kp
					elseif t > key_time and next_keypoint.Time <= t then
						next_keypoint = kp
					end
				end

				--Guessing the keypoint using the time value of the new sequence and color lerping between
				--previous and next keypoints
				local keypoint_guess = ColorSequenceKeypoint.new(
					key_time,
					previous_keypoint.Value:Lerp(next_keypoint.Value, key_time)
				)

				table.insert(lowest, keypoint_guess)
			end
		end
	end

	--Sorting here is really important: It helps getting easly which keypoints in the current sequence should be associated
	--with which one in the new sequence using time as sorter.
	--So even if the keypoints have different time positions, they can still be linked using their indexes.

	table.sort(current_keypoints, function(a, b)
		return a.Time < b.Time
	end)

	table.sort(new_keypoints, function(a, b)
		return a.Time < b.Time
	end)
	
	return {current_keypoints, new_keypoints}
end

--[[
Creates a tween object for the given UIGradient.

Note that if a tween was already inked to the UIGradient, it will be destroyed using <code>:Destroy()</code> method.
]] 
function m.Create(SequenceInstance:UIGradient, TI: TweenInfo?, NewColorSequence:ColorSequence?): TweenObject
	if not SequenceInstance then
		error("GradientTweener:Create() requires a UIGradient object as the first argument")
	end
	
	local self = {}
	
	local prev_object = m.GetTweenObjectFromGradient(SequenceInstance)
	
	if prev_object then
		prev_object:Destroy()
	end
	
	self.Instance = SequenceInstance
	self.TweenInfo = TI or TweenInfo.new()
	self.ColorSequence = NewColorSequence or ColorSequence.new(Color3.new(1,1,1))
	
	self._tweening_connection = nil
	self._global_progression = 0 --The progression of the whole tween, including reverse and delaytime
	self._progression = 0 --The progression of the tween, not counting reverse and delaytime
	self._playing = false
	
	self._original_sequence = SequenceInstance.Color
	
	table.insert(createds, self)
	
	return setmetatable(self, tweens)
end

function m.GetTweenObjectFromGradient(Gradient:UIGradient): TweenObject
	for _, v in createds do
		if v.Instance == Gradient then
			return v
		end
	end
end

--Plays or resume the tween.
function tweens:Play(): ()
	--Initializing variables
	local inst:UIGradient = self.Instance
	local tweeninfo:TweenInfo = self.TweenInfo
	
	local playing = self._playing
	
	local current_sequence = inst.Color
	
	--The progression must be overriden since the user called the method more than once.
	if playing then
		self._global_progression = 0
		self._progression = 0
		
		current_sequence = self._original_sequence
	end
	
	--Applying changes to the current sequence and the new one so they are easier to manipulate.
	self._reconciled_keypoints = ReconcileKeypoints(current_sequence, self.ColorSequence)
	
	local current_keypoints = self._reconciled_keypoints[1]
	local new_keypoints = self._reconciled_keypoints[2]
	
	local current_amount = #current_keypoints
	local new_amount = #new_keypoints
	
	local links = LinkKeypoints(current_keypoints, new_keypoints)
	
	--Now that we have the same amount of keypoints, we can now start the tweening.
	--These two variables are used to determine in which way the tween should go if the TweenInfo.Reverses is enabled.
	local current_state = current_keypoints
	local goal_state = new_keypoints
	
	local reverses = self.TweenInfo.Reverses
	local delay_time = self.TweenInfo.DelayTime
	
	local loops_completed = 0
	local target_loop = self.TweenInfo.RepeatCount + 1
	
	--Prevents the tween to step. (Used for the TweenInfo.DelayTime feature))
	local disabled = false
	
	--If it reverses, it means that it will do twice more than the amount of times specified in TweenInfo.RepeatCount
	if reverses then
		target_loop *= 2
	end
	
	self._playing = true
	
	self._tweening_connection = rs.Heartbeat:Connect(function(dt)
		local progression_step = dt / self.TweenInfo.Time

		if reverses then
			--If the tween reverses, it means that the progression should take twice longer to reach 1

			if delay_time > 0 then
				progression_step = dt / ((self.TweenInfo.Time * 2) + delay_time)
			else
				progression_step = dt / (self.TweenInfo.Time * 2)
			end
		end

		self._global_progression += progression_step
		
		if disabled then return end
		
		self._progression += dt / self.TweenInfo.Time -- 5 is the duration of the tween
		
		if self._global_progression >= 1 then
			self._global_progression = 0
		end
		
		if self._progression >= 1 then
			
			loops_completed += 1
			
			local disable_tween = false
			
			if self.TweenInfo.RepeatCount ~= -1 then
				if loops_completed >= target_loop then
					disable_tween = true
				end
			end
			
			if not disable_tween then
				--Swapping the current sequence and the goal sequence
				if current_state == current_keypoints then
					current_state = new_keypoints
					goal_state = current_keypoints
				else
					current_state = current_keypoints
					goal_state = new_keypoints
				end

				--We do the same thing as before, but we start from the end of the sequence to the old sequence
				links = LinkKeypoints(current_state, goal_state)
				self._progression = 0
				
				if delay_time > 0 then
					disabled = true
					
					task.wait(delay_time)
					disabled = false
				end
				return
			end
			
			self._tweening_connection:Disconnect()
			self._tweening_connection = nil
			--Tweening is done !
			
			return
		end
		
		local step_keypoints: {ColorSequenceKeypoint} = {}
		local ease_time = ProgressionFormulas[self.TweenInfo.EasingStyle.Name]
			[self.TweenInfo.EasingDirection.Name](self._progression)
		
		--Creating the step keypoints to make the step sequence for the ui gradient
		for _, diff_data in links do
			local old = diff_data[1]
			local new = diff_data[2]
			
			--Getting the new keypoint
			local new_keypoint = ColorSequenceKeypoint.new(
				old.Time + (new.Time - old.Time) * ease_time, --Lerping formula: A * (B - A) * t
				old.Value:Lerp(new.Value, ease_time)
			)
			
			table.insert(step_keypoints, new_keypoint)
		end
		
		
		inst.Color = ColorSequence.new(step_keypoints)
	end)
end

--Pause the tween. It can be resumed using <code>self:Play()</code>
function tweens:Pause(): ()
	if self._tweening_connection then
		self._tweening_connection:Disconnect()
	end
	
	self._playing = false
end

--Cancels the tween. The tween goes back to its initial state. It can be resumed using <code>self:Play()</code>
function tweens:Cancel(): ()
	if self._tweening_connection then
		self._tweening_connection:Disconnect()
	end
	
	self.Instance.Color = self._original_sequence
	
	self._playing = false
end

--Destroys the tween and all its resources. This function automatically fires 
--if another tween is created for the same UIGradient.
function tweens:Destroy(): ()
	if self._tweening_connection then
		self._tweening_connection:Disconnect()
	end
	
	local index = table.find(createds, self)
	
	if index then
		table.remove(createds, index)
	end
	
	setmetatable(self, nil)
	
	self = nil
end

return m

Source code (ProgressionFormulas)

--Most of them are made by the roblox AI, i cant verify if it's correct mb guys
--I'll fix that once I'm less lazy, you can use "https://easings.net/" to easly get those formulas!
local formulas = {
	Sine = {
		In = function(x:number)
			return 1 - math.cos((x * math.pi) / 2)
		end,
		Out = function(x:number)
			return math.sin((x * math.pi) / 2);
		end,
		InOut = function(x:number)
			return -(math.cos(math.pi * x) - 1) / 2
		end,
	},

	Quad = {
		In = function(x:number)
			return x^2
		end,
		Out = function(x:number)
			return x * (x - 2)
		end,
		InOut = function(x:number)
			if x < 0.5 then
				return 2 * x^2
			else
				return 2 * (2 - x) * x - 1
			end
		end,
	},

	Cubic = {
		In = function(x:number)
			return x^3
		end,
		Out = function(x:number)
			return (x - 1)^3 + 1
		end,
		InOut = function(x:number)
			if x < 0.5 then
				return 4 * x^3
			else
				return 1 + 4 * (x - 1)^3
			end
		end,
	},

	Quart = {
		In = function(x:number)
			return x^4
		end,
		Out = function(x:number)
			return 1 - (1 - x)^4
		end,
		InOut = function(x:number)
			if x < 0.5 then
				return 8 * x^4
			else
				return 1 - 8 * (x - 1)^4
			end
		end,
	},

	Quint = {
		In = function(x:number)
			return x^5
		end,
		Out = function(x:number)
			return (x - 1)^5 + 1
		end,
		InOut = function(x:number)
			if x < 0.5 then
				return 16 * x^5
			else
				return 16 * (x - 1)^5 + 1
			end
		end,
	},

	Exponential = {
		In = function(x:number)
			if x == 0 then
				return 0
			else
				return 2 ^ (10 * (x - 1))
			end
		end,

		Out = function(x:number)
			if x == 1 then
				return 1
			else
				return 1 - 2 ^ (-10 * x)
			end
		end,

		InOut = function(x:number)
			if x == 0 or x == 1 then
				return x
			else
				if x < 0.5 then
					return 2 ^ (20 * x - 10) / 2
				else
					return 1 - 2 ^ (-20 * x + 10) / 2
				end
			end
		end,
	},

	Elastic = {
		In = function(x:number)
			local c4 = (2 * math.pi) / 3

			if x == 0 then
				return 0
			elseif x == 1 then
				return 1
			else
				return -math.pow(2, 10 * x - 10) * math.sin((x * 10 - 10.75) * c4)
			end
		end,

		Out = function(x:number)
			local c4 = (2 * math.pi) / 3

			if x == 0 then
				return 0
			elseif x == 1 then
				return 1
			else
				return math.pow(2, -10 * x) * math.sin((x * 10 - 0.75) * c4) + 1
			end
		end,

		InOut = function(x:number)
			local c5 = (2 * math.pi) / 4.5

			if x == 0 then
				return 0
			elseif x == 1 then
				return 1
			elseif x < 0.5 then
				return -(math.pow(2, 20 * x - 10) * math.sin((20 * x - 11.125) * c5)) / 2
			else
				return (math.pow(2, -20 * x + 10) * math.sin((20 * x - 11.125) * c5)) / 2 + 1
			end
		end,
	},

	Bounce = {
		In = function(x:number)
			local n1 = 7.5625
			local d1 = 2.75

			if x < 1 / d1 then
				return n1 * x * x
			elseif x < 2 / d1 then
				x = x - 1.5 / d1
				return n1 * x * x + 0.75
			elseif x < 2.5 / d1 then
				x = x - 2.25 / d1
				return n1 * x * x + 0.9375
			else
				x = x - 2.625 / d1
				return n1 * x * x + 0.984375
			end
		end,

		Out = function(x:number)
			local function InBounce(y:number)
				local n1 = 7.5625
				local d1 = 2.75

				if y < 1 / d1 then
					return n1 * y * y
				elseif y < 2 / d1 then
					y = y - 1.5 / d1
					return n1 * y * y + 0.75
				elseif y < 2.5 / d1 then
					y = y - 2.25 / d1
					return n1 * y * y + 0.9375
				else
					y = y - 2.625 / d1
					return n1 * y * y + 0.984375
				end
			end
			
			return 1 - InBounce(1 - x)
		end,
		
		InOut = function(x:number)
			local function OutBounce(x:number)
				local function InBounce(y:number)
					local n1 = 7.5625
					local d1 = 2.75

					if y < 1 / d1 then
						return n1 * y * y
					elseif y < 2 / d1 then
						y = y - 1.5 / d1
						return n1 * y * y + 0.75
					elseif y < 2.5 / d1 then
						y = y - 2.25 / d1
						return n1 * y * y + 0.9375
					else
						y = y - 2.625 / d1
						return n1 * y * y + 0.984375
					end
				end
				
				return 1 - InBounce(1 - x)
			end
			
			if x < 0.5 then
				return (1 - OutBounce(1 - 2 * x)) / 2
			else
				return (1 + OutBounce(2 * x - 1)) / 2
			end
		end,
	},
	
	Circular = {
		In = function(x:number)
			return -1 * (math.sqrt(1 - x * x) - 1)
		end,
		
		Out = function(x:number)
			return math.sqrt(1 - math.pow(x - 1, 2));
		end,
		
		InOut = function(x:number)
			if x < 0.5 then
				return -math.sqrt(1 - x * x) / 2
			else
				return (math.sqrt(1 - (x - 1) * (x - 1)) - 1) / 2
			end
		end,
	},
}

return formulas

(I hope the comments I’ve put are understandable :sob:)


Let me know what you think ! :+1:

7 Likes

Definitely an interesting module, I’m using Tween+ (also has gradient tweening support) but I’ll definitely check this out!

1 Like