A Simple Comparison Slider to Elegantly Compare Two Images

Return

Hey y’all!

It’s Ditch New Year’s Resolution Day where you’re finally free from the burden of that impossible promise you made to yourself! In seriousness, I guess this happens to the best of us–our temptations get hold of us and we often stray from our goals.

Anyways, this is what we’re going to be creating today:


Intro

The contraption in question is called a comparison slider, which is used to compare two images (typically for a before-and-after effect). The two images are overlapped, but the left image is cropped so that it reaches the slider, which means the right image will show from that slider onwards. This allows you to see both images at the same time while keeping the “overall” image intact.

For example, if you want to compare a cityscape image’s contrast, you can have the original at the left, and the adjusted contrast one at the right. However, the entire image still resembles the original cityscape with the buildings and everything in the same place (aka, the image positions and sizes do not change, just the cropping).

I hope this makes sense! Here’s a video:


The images are from the DevHub post-processing effects article. They used to have comparison sliders before they added the “tab” system, which is how I got the idea.

Of course, all the “UI jazz” (e.g. tweens, UICorners, etc.) will be excluded from this tutorial to prevent overcomplexity.


Application on Roblox

Now, why may comparison sliders be useful in a Roblox game? Well, not only are they cool and simple, but with a game that allows for lighting effects to be changed, it is good to give players a good before-after comparison to see if it’s worth it to compromise performance.

What’s really neat about comparison sliders as opposed to a simple “switch-back-and-forth” is that you can control which element you want to compare. If you get a glace at the entire image immediately, some minor discrepancies may be overlooked.


Structure

Depending on which method you use (there are two, which will follow), it’s either very structure-based or script/concept-based. The first uses a “cropper” frame while the second uses ImageRectSize and both crop the left image accordingly.

Method 1

image

  • Frame is just for container purposes. It has a UIAspectRatioConstraint to keep it in compliance with the images’ aspect ratio.
  • Image1 and Image2 are the before and after images, respectively.
  • Image1Frame serves as the “cropper” of Image1 using the ClipsDescendants property.
  • Slider is just a 2px thick frame that will be our slider.

Here’s what the UI looks like:

Note: Image1 and Image2 are the same absolute size as Frame itself, I just labeled them in their corresponding areas where they’re visible.

Method 2

image

Everything’s the same here except having Image1 in Frame directly–there is no “cropper” frame.

Again, these explorer layouts above simplified–there is no toggle button at the bottom left, title text at the top, dragger button at the slider, etc. all of which are purely extras.


Scripting

There are technically two methods to accomplish this, each with its pros and cons:

Method 1 Method 2
Pros Easy; structural No extra “cropper” frame
Cons Extra “cropper” frame Just a bit more diffcult

Both output the same effect, but I prefer Method 2 because it avoids the extra frame, plus I liked using the ImageRectSize, which I normally never use. I’d call it a creative approach, as opposed to the cliche Method 1.

Please start with Method 1 first to see the entire script, because I will skip certain sections that remain the same in Method 2.

Method 1

As I said, the scripting is easy, but I’ll still explain the bits.

First, declare variables, of course:

--uis for the slider mechanics
local uis = game:GetService('UserInputService')

local ui = script.Parent --screenGui
local frame = ui.Frame
local image1Frame = frame.Image1Frame
local img1, img2 = image1Frame.Image1, frame.Image2
local sliderBorder = frame.SliderBorder

Technically, Image2 never needs to be cropped, that’s why it doesn’t have it’s “cropper” frame. The reason we need it is to use its AbsoluteSize to set Image1’s, which needs to stay stagnant as its parent’s size changes with the slider’s position.

Here is the function that will run every time a “drag” happens:

--factor is the X scale position of the mouse
local function position(factor)
	
	sliderBorder.Position = UDim2.new(factor, 0, 0, 0)
	image1Frame.Size = UDim2.new(factor, 0, 1, 0)
	img1.Size = UDim2.new(0, img2.AbsoluteSize.X, 0, img2.AbsoluteSize.Y)
	
end

As you see above, we update the slider’s position according to the mouse’s X scale position (which is passed as a parameter). The size of the “cropper” frame is also adjusted to it, but we need Image1 to stay the same size, which can only be done with a pixel size. We need both images to fit perfectly, and since Image2’s size will stay constant, we can use Image2’s AbsoluteSize.

Now, this is just a simple function to return true if the given mouse position mp is inside of Frame’s bounds:

local function isInside(mp)
	
	local p = frame.AbsolutePosition
	local s = frame.AbsoluteSize
	
	return mp.X >= p.X and mp.Y >= p.Y and mp.X <= p.X + s.X and mp.Y <= p.Y + s.Y
	
end

Then, we have the UserInputService detect mouse dragging using a combination of MouseMovement and checking if the mouse button is pressed at the time:

uis.InputChanged:Connect(function(input)
	
	if input.UserInputType == Enum.UserInputType.MouseMovement then
		
		if uis:IsMouseButtonPressed(Enum.UserInputType.MouseButton1) then

            --remeber the deprecated Mouse.X and Mouse.Y? it almost like that
			local mousePos = uis:GetMouseLocation()

            --use the function to verify whether the mouse is inside Frame or not
			if isInside(mousePos) then

                --position relative from the top-left corner of the FRAME
				local pos = mousePos.X - frame.AbsolutePosition.X
				local scale = pos / frame.AbsoluteSize.X --convert pixel to scale
				scale = math.clamp(scale, 0, 1) --just in case, clamp it from 0-1
				position(scale) --call the function to update the elements

			end

		end
			
	end
	
end)

Instead of the UserInputService, you can use a TextButton or something that covers the entire Frame and that object listens of InputBegan. But, I find UserInputService a bit more dynamic and any changes made are easy and efficient.

Now you can stop here, or you can add a function to update Image1’s size whenever Frame’s size changes, so that if the player adjusted their window size, Image1 can instantly update:

frame:GetPropertyChangedSignal('AbsoluteSize'):Connect(function()
	
	img1.Size = UDim2.new(0, img2.AbsoluteSize.X, 0, img2.AbsoluteSize.Y)
	
end)

And that’s it. I told you it was easy, the main battle was just the structure.

Method 2

So, Method 2 is using ImageRectSize and some math. For those who don’t know, ImageRectSize allows us to set the “area” (in pixels) that is going to be shown. If it’s (0, 0), which is the default, the area is the entire image. When it’s combined with ImageRectOffset, you can shift that imaginary “rectangle” around. So an ImageRectOffset of (50, 50), would move that box 50px right and 50px down from the image’s top-left corner:

For example, this is a 900x900px image, and to show only the red circle in the middle, you need to set the ImageRectSize to that area (300x300px). This will focus on the top-left circle, so we need to shift the “area” 300 to the right and 300 down by setting ImageRectOffset to (300, 300).

(Note: We’re not using ImageRectOffset in this one since a combination of ImageRectSize and the Size property achieve the desired result.)

This is simplified. Since the area being focused on it different from the image’s size property, the ScaleType will kick in and try to fill the entirety of the image bounds with the resulting area. This can distort or crop the image undesirably, but there’s an easy fix! You can manually resize the image element so no filling is necessary.

Read more about the two properties here:

Let’s start coding! Declare the variables:

local uis = game:GetService('UserInputService')

local ui = script.Parent
local frame= ui.Frame
local img1, img2 = frame.Image1, frame.Image2
local sliderBorder,= frame.SliderBorder

--see explanation below for these two vars
local COMP_SIZE = 1024
local SIZE = Vector2.new(COMP_SIZE, COMP_SIZE / (1200 / 450))

When you upload an image to Roblox, it gets compressed to a maximum of 1024x1024px. This means that if your image is larger, you cannot use those pixel values for the ImageRectSize since it’d be based on the compressed size, not the original. Now SIZE represents the compressed pixel dimension of the image. Of course, if it’s wide, then the X dimension reaches the max of 1024 and the Y is any value to keep the same aspect ratio. For example, the images we’re using right now are 1200x450px before compression, doing some math, the compressed image is:

aspectRatio = 1200 / 450
sizeY = 1024 / aspectRatio
SIZE = (1024, sizeY)

This is the key! SIZE will be used in the ImageRectSize, and I’ll explain later why it needs to be constant.

Next, we have the position() function, which is a tad bit different:

local function position(factor)
	
	sliderBorder.Position = UDim2.new(factor, 0, 0, 0)

    --[[set the X as a fraction of the dragger's X scale position, 
    so the image extends to the dragger]]
	img1.ImageRectSize = Vector2.new(SIZE.X * factor, SIZE.Y)

    --set the size to avoid any distortions
	img1.Size = UDim2.new(0, frame.AbsoluteSize.X * factor, 0, frame.AbsoluteSize.Y)
	
end

Now, why do we need a constant ImageRectSize apart from the factor multiplication? Well, when you resize an image, it’s internal pixel dimension stays constant. Even if you set a 1000x500 image’s size property to a 1x1px space then it’s still being recognized as a 1000x500px when using ImageRectSize (and ImageRectOffset). This means that if the window is resized or Frame is somehow smaller, then the ImageRectSize needs to comply with the original compressed size. If you set the X to frame.AbsoluteSize.X * scale, then the image will actually stay large when the UI shrinks, which will not fit well with Image2.

Please reach out if that blurb was confusing!

Anyways, the rest until the last connection is the same (I skipped those two to preserve space):

--isInside() function
--UIS input changed

frame:GetPropertyChangedSignal('AbsoluteSize'):Connect(function()
	
    --we need to rerun the entire function to allow Image1 to accomodate
	postion(dragger.Position.X.Scale)
	
end)

And we’re done with Method 2!


Download the UI

This is the ScreenGui that is in the video, with all the “UI jazz.” Feel free to explore the script and the structure.

Method 1: Comparison_Slider.rbxm (13.3 KB)
Method 2: Comparison_Slider_2.rbxm (13.3 KB)


Closing Remarks

I know this is rather simple, but hey, anyone can learn anything at any stage. Plus, it’s a nice addition to your settings UI!

How would you rate this tutorial?
Anything unclear, overlooked, etc.

Rating
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

0 voters

How applicable is this to your game/project?
i.e. How likely are you to use this?

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

0 voters

Thanks y’all,
And have a great time!

28 Likes

Nice tutorial. I can’t wait to see the next one.

1 Like

UPDATE: I’ve added a second method of cropping the image, which is a bit more complex, but it’s worth it! It utilizes ImageRectSize and some playing-with-pixels. I recommend at least reading through this because the concept is quite intriguing and frankly under-used.

Just scroll up to the “Scripting” section that now includes two collapsibles for the methods. I’ve also added a second ScreenGui in the download section that is using the second method along with its explorer layout in the “Structure” section.

This is awesome work, thank you for sharing it!


There’s a typo in the video, it says “Compairson” above the slider and I assume it’s meant to say “Comparison”.

1 Like

Thanks for pointing that out and thanks for your feedback! I won’t be able to change the video, but it helps me to avoid such silly mistakes next time. Funny thing is, IDK, if I was in a rush or what, but I spelled the position function wrong in my original script–it wasn’t until grammarly came to the rescue that I fixed it!

1 Like

that’s amazing :open_mouth: :+1:

1 Like

I was looking at this post to try and show a progress animation on a custom proximity prompt for a game that I am working on. I was considering using this method when I accidentally came across a far easier method without any complicated scripts using UIAspectRatioConstraints and setting an axis’ scale to inf.

Here is my setup:
image

The Key ImageButton is the default look for the proximity prompt and then the ImageLabel under Progress is what it will look like at full progress. I have a UIAspectRationConstraint under this set to 1 and the X scale is set to 1, so it fills the full Progress Frame, and the Y scale is set to inf which allows it to ignore the scale of the Progress Frame no matter what size it is and show the ImageLabel at full size. Now, as the Progress Frame, that has ClipDescendants enabled, is tweened it will show and hide the image below as if it was simply lightening and darkening the actual button.

Group 108

Here is the end result:
frws36LqSC

And the script I am using is just a very basic Tween script, far less complicated than the one above:

local TweenService = game:GetService("TweenService")
local tweenInfo = TweenInfo.new(2, Enum.EasingStyle.Linear, Enum.EasingDirection.Out, -1, true, 0)

local progressBar = script.Parent.Key.Progress
local tween = TweenService:Create(progressBar, tweenInfo, { Size = UDim2.new(1, 0, 1, 0) })
tween:Play()

Not sure how often you could use this but I hope this helps!

5 Likes