Of men & scroll galleries: in-depth tutorial

In my last project I had to build a skin selection menu. I could have gone away by simply using a scrollrect, but who likes cool when you can have awesome?

I didn’t want just a scrollrect. I wanted Icons to change size depending on screen position. And to cover each other when too far away from the currently selected one. All with inertia and snap-to-option movement. Of course, no tap-to-select, whatever’s centered should be selected. In other words I wanted a scrolling gallery of images that looked and felt awesome where I could pick my ninja.

I also wanted a Lamborghini, but you can’t get THAT just by coding it.

Yet.

Let’s begin with some high-level concepts

So what to do? First I started with some math.
I had to figure out how to mathematecally handle all the movements, which functions to encode in unity so that at every frame I had exactly what I needed. Of course using math does not mean using calculus, if you remember this trick of mine.

So, mathematically speaking I wanted a bellshaped curve (AKA Gaussian) of zoom, and some easing for position on x axis.
All that must be controlled with a swipe, which means with a single parameter. What we have is a linear swipe, what we want is an eased movement and zoom.

An example of easing in animation
The upper picture represents a linear movement, the lower one an eased movement

The trick is not to think on what kind of formulas can give the result I need, but to just revert the functions that would do the trick graphically, which means a bell shaped curve for zoom and an s-shaped one for x position.
The mechanics will be very similar to spinning an invisible wheel, with our images are glued on its border and then watching from a small distance. You just increase linearly the rotation angle at its center and then you’ll see exactly the movement we need: big and fast picture in the middle, many others small and slow on the sides. We’ll call the rotation angle of the imaginary wheel our “alpha” angle.

Brace yourselves: code is coming

Let’s review the variables we’ll need. AnimationCurves first.

 public AnimationCurve positionCurve;
 public AnimationCurve zoomCurve;

These will be our “math” functions. Zoomcurve needs a bell-shape with peak in .5f, positioncurve it’s a regular ease-in ease-out.

public RectTransform[] imgset;

Here we’ll store the images to move. Actually their RectTransforms.

And then there’s some “constants”:

public float rotationSensibility = 1f; //how sensible is to swipe input
protected float alphaStep = 1f; //will host the image-to-image distance.
public float swipeFieldWidth = 300f;//Total distance from leftmost position to rightmost one

And finally some internal variables

[SerializeField] protected float alpha; //the parameter around wich all things revolve
protected float lastFrameAlpha; //last frame's alpha value
public CatchSwipe inputSource; //A module to handle input that gives us the net x-axis swipe value
Dictionary<RectTransform, float> alphaoff = new Dictionary<RectTransform, float>();//a dictionary to store all image's offsets

The eagle-eye view

So, our high-level code would be something like this:

    public virtual void Start()
    {
        alphaInitialization();
        refreshImages();
    }

    protected virtual void Update()
    {
        updateAlpha();
        if (Mathf.Abs(lastFrameAlpha - alpha) > 0.001f)
        {
            refreshImages();
        }
        lastFrameAlpha = alpha;
    }

Which basically reads: first, initialize my shit and show it, then update my alpha and if it’s different than last frame, show the update .
It’s a simple behaviour.

Warning: this check will save a lot of cpu but may get some added effects not too smooth with extra slow movements, it’s up to you to decide your tradeoff.

Let’s see what those functions actually do.

 protected virtual void alphaInitialization()
    {
        alpha = 1;

We just start with the leftmost image at center.

        if (imgset.Length > 1)
            alphaStep = 1f / (imgset.Length - 1f);

Here we calculate the alphaStep if it needs to be less than one. We want the images to be equally spaced on the imaginary wheel border, so that we can calculate easily how they move.

        float minAlpha = -(imgset.Length - 1f) * alphaStep / 2f;
        alphaoff.Clear();
        foreach (var item in imgset)
        {
            alphaoff.Add(item, minAlpha);
            minAlpha += alphaStep;
        }

Then we calculate what the offset values are for our images. This will enable us to rule them all with just one ring… err… parameter, the “alpha” rotation value of the imaginary wheel. The middle image uses the base alpha value and the others will shift it with the offset. Simple huh?

        lastFrameAlpha = alpha;
    }

And lastly, we also initialize the lastFrameAlpha value.

protected virtual void updateAlpha()
    {
        alpha = Mathf.Clamp(alpha + inputSource.NetSwipe.x * rotationSensibility, 0, 1 );
    }

Now, this is what one would expect to be the complicated part… but actually it’s the simplest. When the user swipes he’s just spinning the wheel, nothing more. So we increase the alpha by exactly the amount he swipes times the sensibility.

And now let’s see where the magic actually happens!

    protected virtual void refreshImages()
    {
        foreach (var img in imgset)
        {
            float transformedPosition = positionCurve.Evaluate(alpha + alphaoff[img]);

HERE! did you see that? Here I changed the alpha value to a phisical position. Before we only knew how much the wheel would spin (alpha), then we got how exactly it was spinning for the image we’re checking (alpha + alphaoff[img]). The actual transformation would have been some good old math if we didn’t use an AnimationCurve to do it in our place.
Fuck that. I’m lazy and want the UI designer to be able to change how the slider behaves without touching MY code with his filthy fingers.
Our result is still normalized in a 0-1 scale. To get a position in the proper scale we need :

            img.localPosition = Vector3.right * (transformedPosition * swipeFieldWidth - (swipeFieldWidth / 2f));

So, basically, by subtracting half swipeFieldWidth we have the results centered in 0, but then we’ll have that with transformedPosition==0, a position of (- swipeFieldWidth/2,0,0) will be assigned, and with transformedPosition==1,the result will of course be (swipeFieldWidth/2,0,0). The result is that the width of it all it’s exactly swipeFieldWidth.

            img.localScale = Vector3.one * (zoomCurve.Evaluate(transformedPosition));
        }
    }

And this is what gives the “zoom” effect: we just make the scale bigger the closer the image is to the center (which means transformedPosition==.5f )
This too of course will be done with an AnimationCurve so that we can thinker with results without having to change the code.

What we did so far should look something like this:

[note to self: never delete a gif again or you’ll have to go through hundreds of tweets to get it back]

I hope this will be enough for now. I also hope you would like to read how to add the other stuff I mentioned before like inertia and snapping. If so, join my newsletter to know when the next part of this guide is ready! [edit: next part is here]

And yes, of course I know you want to copy-paste this, so here’s all in one place:

    public AnimationCurve positionCurve;
    public AnimationCurve zoomCurve;
    public RectTransform[] imgset;
    public float rotationSensibility = 1f; //how sensible is to swipe input
    protected float alphaStep = 1f; //will host the image-to-image distance.
    public float swipeFieldWidth = 300f;//Total distance from leftmost position to rightmost one
    [SerializeField]    protected float alpha; //the parameter around wich all things revolve
    protected float lastFrameAlpha; //last frame's alpha value
    public CatchSwipe inputSource; //A module to handle input that gives us the net x-axis swipe value
    Dictionary<RectTransform, float> alphaoff = new Dictionary<RectTransform, float>();//a dictionary to store all image's offsets

    public virtual void Start()
    {
        alphaInitialization();
        refreshImages();
    }

    protected virtual void Update()
    {
        updateAlpha();
        if (Mathf.Abs(lastFrameAlpha - alpha) > 0.001f)
        {
            refreshImages();
        }
        lastFrameAlpha = alpha;

    }

 protected virtual void alphaInitialization()
    {
        alpha = 1;

        if (imgset.Length > 1)
            alphaStep = 1f / (imgset.Length - 1f);

        float minAlpha = -(imgset.Length - 1f) * alphaStep / 2f;
        alphaoff.Clear();
        foreach (var item in imgset)
        {
            alphaoff.Add(item, minAlpha);
            minAlpha += alphaStep;
        }

        lastFrameAlpha = alpha;
    }


protected virtual void updateAlpha()
    {
        alpha = Mathf.Clamp(alpha + inputSource.NetSwipe.x * rotationSensibility, 0, 1 );
    }

    protected virtual void refreshImages()
    {
        foreach (var img in imgset)
        {
            float transformedPosition = positionCurve.Evaluate(alpha + alphaoff[img]);

            img.localPosition = Vector3.right * (transformedPosition * swipeFieldWidth - (swipeFieldWidth / 2f));

            img.localScale = Vector3.one * (zoomCurve.Evaluate(transformedPosition));
        }
    }

And here are the position curve:

position-curve

And zoom curve:

zoom-curve

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •