ScrollView using controller/arrowKeys

I’m pretty new both to this forum and to unity, so i hope not to make any mistake.
I am fairly new to unity, so i have been doing some “training” projects for a while. Right now i’m making one, but i’m struggling with my menus.
I have created a menu system where items appear in a scroll view and if you select them (they are buttons) their name and description is showcased.
However, i want to be ablo to scroll this with a controller or with keyboard keys, as i don’t want to rely on my mouse for this game. I have been searching for a while, but every piece of tutorial or documentation that i find is either outdated or too complex for my currect skillset.
I would appreciate any kind of help that you could give me. Also forgive any spelling mistakes as english is not my native language.
Thanks in advance !!!

This might be helpful:

https://docs.unity3d.com/Packages/com.unity.ugui@1.0/manual/script-ScrollRect.html

“A Scroll Rect can be used when content that takes up a lot of space needs to be displayed in a small area. The Scroll Rect provides functionality to scroll over this content.”

Usually a Scroll Rect is combined with a Mask in order to create a scroll view, where only the scrollable content inside the Scroll Rect is visible. It can also additionally be combined with one or two Scrollbars that can be dragged to scroll horizontally or vertically."

You could use the new input system to detect input from a controller/arrow key presses and use that to control the scrolling.

The New UI System
https://docs.unity3d.com/Packages/com.unity.inputsystem@1.0/manual/QuickStartGuide.html

or you could try something slightly more complex:

https://bitbucket.org/UnityUIExtensions/unity-ui-extensions/src/88c43891c0bf44f136e3021ad6c89d704dfebc83/Scripts/Utilities/UIScrollToSelection.cs?at=master&fileviewer=file-view-default

A set of scripts built up from many examples for the Unity UI system. Can be used either on their own or in collaboration with the new Unity UI system’s source. https://bitbucket.org/Unity-Technologies/ui

Also some other stuff I found that might be useful instead

Example 1

using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using System.Collections;
public class ScrollRectPosition : MonoBehaviour {
    RectTransform scrollRectTransform;
    RectTransform contentPanel;
    RectTransform selectedRectTransform;
    GameObject lastSelected;
    void Start() {
        scrollRectTransform = GetComponent<RectTransform>();
        contentPanel = GetComponent<ScrollRect>().content;
    }
    void Update() {
        // Get the currently selected UI element from the event system.
        GameObject selected = EventSystem.current.currentSelectedGameObject;
        // Return if there are none.
        if (selected == null) {
            return;
        }
        // Return if the selected game object is not inside the scroll rect.
        if (selected.transform.parent != contentPanel.transform) {
            return;
        }
        // Return if the selected game object is the same as it was last frame,
        // meaning we haven't moved.
        if (selected == lastSelected) {
            return;
        }
        // Get the rect tranform for the selected game object.
        selectedRectTransform = selected.GetComponent<RectTransform>();
        // The position of the selected UI element is the absolute anchor position,
        // ie. the local position within the scroll rect + its height if we're
        // scrolling down. If we're scrolling up it's just the absolute anchor position.
        float selectedPositionY = Mathf.Abs(selectedRectTransform.anchoredPosition.y) + selectedRectTransform.rect.height;
        // The upper bound of the scroll view is the anchor position of the content we're scrolling.
        float scrollViewMinY = contentPanel.anchoredPosition.y;
        // The lower bound is the anchor position + the height of the scroll rect.
        float scrollViewMaxY = contentPanel.anchoredPosition.y + scrollRectTransform.rect.height;
        // If the selected position is below the current lower bound of the scroll view we scroll down.
        if (selectedPositionY > scrollViewMaxY) {
            float newY = selectedPositionY - scrollRectTransform.rect.height;
            contentPanel.anchoredPosition = new Vector2(contentPanel.anchoredPosition.x, newY);
        }
        // If the selected position is above the current upper bound of the scroll view we scroll up.
        else if (Mathf.Abs(selectedRectTransform.anchoredPosition.y) < scrollViewMinY) {
            contentPanel.anchoredPosition = new Vector2(contentPanel.anchoredPosition.x, Mathf.Abs(selectedRectTransform.anchoredPosition.y));
        }
        lastSelected = selected;
    }
}

Example 2

using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using System.Collections;
namespace Menu {
    // Detects if an immediate child is selected and out of the scroll view. If so it auto-scrolls everything into view.
    public class AutoScroll : MonoBehaviour {
        [SerializeField] bool debug;
        [SerializeField] ScrollRect scrollRect;
        [SerializeField] Scrollbar scrollbar;
        [SerializeField] float scrollPadding = 20f;
        void Start () {
            StartCoroutine(DetectScroll());
        }
        IEnumerator DetectScroll () {
            GameObject current;
            GameObject prevGo = null;
            Rect currentRect = new Rect();
            Rect viewRect = new Rect();
            RectTransform view = scrollRect.GetComponent<RectTransform>();
          
            while (true) {
                current = EventSystem.current.currentSelectedGameObject;
                if (current != null && current.transform.parent == transform) {
                    // Get a cached instance of the RectTransform
                    if (current != prevGo) {
                        RectTransform rt = current.GetComponent<RectTransform>();
                      
                        // Create rectangles for comparison
                        currentRect = GetRect(current.transform.position, rt.rect, Vector2.zero);
                        viewRect = GetRect(scrollRect.transform.position, view.rect, view.offsetMax);
                        Vector2 heading = currentRect.center - viewRect.center;
                        if (heading.y > 0f && !viewRect.Contains(currentRect.max)) {
                            float distance = Mathf.Abs(currentRect.max.y - viewRect.max.y) + scrollPadding;
                            view.anchoredPosition = new Vector2(view.anchoredPosition.x, view.anchoredPosition.y - distance);
                            if (debug) Debug.LogFormat("Scroll up {0}", distance); // Decrease y value
                        } else if (heading.y < 0f && !viewRect.Contains(currentRect.min)) {
                            float distance = Mathf.Abs(currentRect.min.y - viewRect.min.y) + scrollPadding;
                            view.anchoredPosition = new Vector2(view.anchoredPosition.x, view.anchoredPosition.y + distance);
                            if (debug) Debug.LogFormat("Scroll down {0}", distance); // Increase y value
                        }
                        // Get adjusted rectangle positions
                        currentRect = GetRect(current.transform.position, rt.rect, Vector2.zero);
                        viewRect = GetRect(scrollRect.transform.position, view.rect, view.offsetMax);
                    }
                }
                prevGo = current;
              
                if (debug) {
                    DrawBoundary(viewRect, Color.cyan);
                    DrawBoundary(currentRect, Color.green);
                }
                yield return null;
            }
        }
        static Rect GetRect (Vector3 pos, Rect rect, Vector2 offset) {
            float x = pos.x + rect.xMin - offset.x;
            float y = pos.y + rect.yMin - offset.y;
            Vector2 xy = new Vector2(x, y);
          
            return new Rect(xy, rect.size);
        }
        public static void DrawBoundary (Rect rect, Color color) {
            Vector2 topLeft = new Vector2(rect.xMin, rect.yMax);
            Vector2 bottomRight = new Vector2(rect.xMax, rect.yMin);
          
            Debug.DrawLine(rect.min, topLeft, color); // Top
            Debug.DrawLine(rect.max, topLeft, color); // Left
            Debug.DrawLine(rect.min, bottomRight, color); // Bottom
            Debug.DrawLine(rect.max, bottomRight, color); // Right
        }
    }
}
3 Likes

I didn’t know about the new UI system, i’ll definitely chek it out, as the other solution is still to complex for me i think. Thank you a lot :slight_smile:

I have just implemented it and it works just fine. The problem is that as i’m fairly new, i don’t know how all the components and their properties/methods work, but to my surprise i understood the first script and was able to modify it to fit my needs.
Huge thanks and i hope you have a great week

Below works beautifully!
this function runs in update, but now takes parameters, depending which ScrollRect you want to work with.

    void ScrollWithSelection(RectTransform _scrollRect, RectTransform _content)
    {
        GameObject selected = myEventSystem.currentSelectedGameObject;
        if (selected == null || selected == previouslySelected) return;
        if (selected.transform.parent != _content.transform) return;
        RectTransform selectedRectTransform = selected.GetComponent<RectTransform>();


        float scrollViewMinY = _content.anchoredPosition.y;
        float scrollViewMaxY = _content.anchoredPosition.y + _scrollRect.rect.height;


        float selectedPositionY = Mathf.Abs(selectedRectTransform.anchoredPosition.y) + (selectedRectTransform.rect.height / 2);

        // If selection below scroll view
        if (selectedPositionY > scrollViewMaxY)
        {
            float newY = selectedPositionY - _scrollRect.rect.height;
            _content.anchoredPosition = new Vector2(_content.anchoredPosition.x, newY);
        }


        // If selection above scroll view
        else if (Mathf.Abs(selectedRectTransform.anchoredPosition.y) < scrollViewMinY)
        {
            _content.anchoredPosition =
                new Vector2(_content.anchoredPosition.x, Mathf.Abs(selectedRectTransform.anchoredPosition.y)
                - (selectedRectTransform.rect.height / 2));
        }
    }
3 Likes

You could post the full code, I couldn’t make it work at all with just that part. Thank you very much.

I know this thread is old but if anyone comes across this and is having this issue here’s a script to handle this.

using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

// Add the script to your Dropdown Menu Template Object via (Your Dropdown Button > Template)

[RequireComponent(typeof(ScrollRect))]
public class AutoScrollRect : MonoBehaviour
{
    // Sets the speed to move the scrollbar
    public float scrollSpeed = 10f;

    // Set as Template Object via (Your Dropdown Button > Template)
    public ScrollRect m_templateScrollRect;

    // Set as Template Viewport Object via (Your Dropdown Button > Template > Viewport)
    public RectTransform m_templateViewportTransform;

    // Set as Template Content Object via (Your Dropdown Button > Template > Viewport > Content)
    public RectTransform m_ContentRectTransform;

    private RectTransform m_SelectedRectTransform;

    void Update()
    {
        UpdateScrollToSelected(m_templateScrollRect, m_ContentRectTransform, m_templateViewportTransform);
    }

    void UpdateScrollToSelected(ScrollRect scrollRect, RectTransform contentRectTransform, RectTransform viewportRectTransform)
    {
        // Get the current selected option from the eventsystem.
        GameObject selected = EventSystem.current.currentSelectedGameObject;

        if (selected == null)
        {
            return;
        }
        if (selected.transform.parent != contentRectTransform.transform)
        {
            return;
        }

        m_SelectedRectTransform = selected.GetComponent<RectTransform>();

        // Math stuff
        Vector3 selectedDifference = viewportRectTransform.localPosition - m_SelectedRectTransform.localPosition;
        float contentHeightDifference = (contentRectTransform.rect.height - viewportRectTransform.rect.height);

        float selectedPosition = (contentRectTransform.rect.height - selectedDifference.y);
        float currentScrollRectPosition = scrollRect.normalizedPosition.y * contentHeightDifference;
        float above = currentScrollRectPosition - (m_SelectedRectTransform.rect.height / 2) + viewportRectTransform.rect.height;
        float below = currentScrollRectPosition + (m_SelectedRectTransform.rect.height / 2);

        // Check if selected option is out of bounds.
        if (selectedPosition > above)
        {
            float step = selectedPosition - above;
            float newY = currentScrollRectPosition + step;
            float newNormalizedY = newY / contentHeightDifference;
            scrollRect.normalizedPosition = Vector2.Lerp(scrollRect.normalizedPosition, new Vector2(0, newNormalizedY), scrollSpeed * Time.deltaTime);
        }
        else if (selectedPosition < below)
        {
            float step = selectedPosition - below;
            float newY = currentScrollRectPosition + step;
            float newNormalizedY = newY / contentHeightDifference;
            scrollRect.normalizedPosition = Vector2.Lerp(scrollRect.normalizedPosition, new Vector2(0, newNormalizedY), scrollSpeed * Time.deltaTime);
        }
    }
}
17 Likes

THIS WORKS!!! 2022.05.04
Just in my case the speed works a lot better when i set it above 80 (presently 100)

1 Like

Works perfectly !

!

Thank you!

Initially I had trouble with this script as my Viewport Pivot was at 0.5,0.5 instead of 0, 1. But setting it to 0,1 fixed it. Works great.

This has just saved my Ass. Thank you!!!

I had the same issue and needed to support things nested more deeply than being direct children of the content rect. Here’s my implementation:

/*
MIT License

Copyright (c) 2023 Martin Jonasson

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/

using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

[RequireComponent(typeof(ScrollRect))]
public class AutoScrollRect : MonoBehaviour {

    public ScrollRect    scrollRect;
    public RectTransform viewportRectTransform;
    public RectTransform contentRectTransform;

    RectTransform selectedRectTransform;

    void Update() {
        var selected = EventSystem.current.currentSelectedGameObject;
        // nothing is selected, bail
        if (selected == null) return;

        // whatever is selected isn't a descendant of the scroll rect, we can ignore it
        if (!selected.transform.IsChildOf(contentRectTransform)) return;

        selectedRectTransform = selected.GetComponent<RectTransform>();
        var viewportRect = viewportRectTransform.rect;
       
        // transform the selected rect from its local space to the content rect space
        var selectedRect         = selectedRectTransform.rect;
        var selectedRectWorld    = selectedRect.Transform(selectedRectTransform);
        var selectedRectViewport = selectedRectWorld.InverseTransform(viewportRectTransform);
       
        // now we can calculate if we're outside the viewport either on top or on the bottom
        var outsideOnTop    = selectedRectViewport.yMax - viewportRect.yMax;
        var outsideOnBottom = viewportRect.yMin - selectedRectViewport.yMin;
       
        // if these values are positive, we're outside the viewport
        // if they are negative, we're inside, i zero any "inside" values here to keep things easier to reason about
        if (outsideOnTop < 0) outsideOnTop       = 0;
        if (outsideOnBottom < 0) outsideOnBottom = 0;
       
        // pick the direction to scroll
        // if the selection is big it could possibly be outside on both ends, i prioritize the top here
        var delta = outsideOnTop > 0 ? outsideOnTop : -outsideOnBottom;
       
        // if no scroll, we bail
        if (delta == 0) return;
       
        // now we transform the content rect into the viewport space
        var contentRect         = contentRectTransform.rect;
        var contentRectWorld    = contentRect.Transform(contentRectTransform);
        var contentRectViewport = contentRectWorld.InverseTransform(viewportRectTransform);

        // using this we can calculate how much of the content extends past the viewport
        var overflow = contentRectViewport.height - viewportRect.height;

        // now we can use the overflow from earlier to work out how many units the normalized scroll will move us, so
        // we can scroll exactly to where we need to
        var unitsToNormalized = 1 / overflow;
        scrollRect.verticalNormalizedPosition += delta * unitsToNormalized;
    }   
}

internal static class RectExtensions {
    /// <summary>
    /// Transforms a rect from the transform local space to world space.
    /// </summary>
    public static Rect Transform(this Rect r, Transform transform) {
        return new Rect {
            min = transform.TransformPoint(r.min),
            max = transform.TransformPoint(r.max),
        };
    }
   
    /// <summary>
    /// Transforms a rect from world space to the transform local space
    /// </summary>
    public static Rect InverseTransform(this Rect r, Transform transform) {
        return new Rect {
            min = transform.InverseTransformPoint(r.min),
            max = transform.InverseTransformPoint(r.max),
        };
    }
}

It only supports vertical scrolling, but should be easy to extend to work for horizontal too if necessary.

6 Likes

Worked great for me. Thank you guys for the code!

Great stuff thanks. I had to update it a little bit to get it working for world space ui as well, and I extended it to support horizontal scrolling as well. In case anyone is looking for either of those use cases, here you go:

To support world space ui, just updated the extension methods:

/// <summary>
/// Transforms a rect from the transform local space to world space.
/// </summary>
public static (Vector3 min, Vector3 max) Transform(this Rect r, Transform transform)
{
    return (transform.TransformPoint(r.min), transform.TransformPoint(r.max));
}

/// <summary>
/// Transforms a rect from world space to the transform local space
/// </summary>
public static Rect InverseTransform(this (Vector3 min, Vector3 max) r, Transform transform)
{
    return new Rect { min = transform.InverseTransformPoint(r.min), max = transform.InverseTransformPoint(r.max) };
}

And for horizontal scrolling, just throw these in where it makes sense. (It is basically exactly the same as for vertical):

var outsideOnRight = selectedRectViewport.xMax - viewportRect.xMax;
var outsideOnLeft = viewportRect.xMin - selectedRectViewport.xMin;


if (outsideOnLeft < 0) outsideOnLeft = 0;
if (outsideOnRight < 0) outsideOnRight = 0;


var horizontalDelta = outsideOnRight > 0 ? outsideOnRight : -outsideOnLeft;


// if no scroll, we bail
if (verticalDelta != 0 || horizontalDelta != 0)
{
    // now we transform the content rect into the viewport space
    var contentRect = contentRectTransform.rect;
    // these extension methods don't work in world space
    var contentRectWorld = contentRect.Transform(contentRectTransform);
    var contentRectViewport = contentRectWorld.InverseTransform(viewportRectTransform);

    if (verticalDelta != 0)
    {
        // using this we can calculate how much of the content extends past the viewport
        var overflow = contentRectViewport.height - viewportRect.height;

        // now we can use the overflow from earlier to work out how many units the normalized scroll will move us, so
        // we can scroll exactly to where we need to
        var unitsToNormalized = 1 / overflow;
        scrollRect.verticalNormalizedPosition += verticalDelta * unitsToNormalized;
    }

    if (horizontalDelta != 0)
    {
        // using this we can calculate how much of the content extends past the viewport
        var overflow = contentRectViewport.width - viewportRect.width;

        // now we can use the overflow from earlier to work out how many units the normalized scroll will move us, so
        // we can scroll exactly to where we need to
        var unitsToNormalized = 1 / overflow;
        scrollRect.horizontalNormalizedPosition += horizontalDelta * unitsToNormalized;
    }
}
3 Likes

This is all really great, thanks for sharing!

Unfortunately, this doesn’t seem to work with Sprite Swap buttons. When I set my buttons to Sprite Swap, the scrollbar appears to set its position correctly for one frame, and then reset its position back to 0 the next frame. If I set the position on a frame delay, the position will be saved and displayed correctly for the given selection, but then the delta calculations will still all be treated as though the start position is 0 (e.g. for a horizontal list, you can scroll to the right, but when you start selecting back to the left, the selection will immediately start scrolling leftward, causing your selection to hug the right hand side).

If anyone has suggestions for why Sprite Swap buttons might be different from Color Tint for the purposes of scrolling in a list, and how one might adapt these scripts for Sprite Swap buttons, that would be much appreciated!

This still works, thanks! One note though: Your item has to have the same height as the Content GameObject in the dropdown template, otherwise positions will be off while scrolling