How to scroll to a specific element in ScrollRect with Unity UI?

96.4k views Asked by At

I built a registration form for a mobile game using Unity 5.1. To do that, I use Unity UI components: ScrollRect + Autolayout (Vertical layout) + Text (labels) + Input Field. This part works fine.

But, when keyboard is opened, the selected field is under keyboard. Is there a way to programmatically scroll the form to bring the selected field into view?

I have tried using ScrollRect.verticalNormalizedPosition and it works fine to scroll some, however I am not able to make selected field appear where I want.

Thanks for your help !

14

There are 14 answers

5
maraaaaaaaa On BEST ANSWER

I am going to give you a code snippet of mine because I feel like being helpful. Hope this helps!

protected ScrollRect scrollRect;
protected RectTransform contentPanel;

public void SnapTo(RectTransform target)
{
    Canvas.ForceUpdateCanvases();

    contentPanel.anchoredPosition =
            (Vector2)scrollRect.transform.InverseTransformPoint(contentPanel.position)
            - (Vector2)scrollRect.transform.InverseTransformPoint(target.position);
}
0
Bryan Legend On

Here's what I created to solve this problem. Place this behavior on each button that can be selected via controller UI navigation. It supports fully nested children objects. It only supports vertical scrollrects, but can be easily adapted to do horizontal.

    using UnityEngine;
    using System;
    using UnityEngine.EventSystems;
    using UnityEngine.UI;
    
    namespace Legend
    {
        public class ScrollToOnSelect: MonoBehaviour, ISelectHandler
        {
            ScrollRect scrollRect;
            RectTransform target;
    
            void Start()
            {
                scrollRect = GetComponentInParent<ScrollRect>();
                target = (RectTransform)this.transform;
            }
    
            Vector3 LocalPositionWithinAncestor(Transform ancestor, Transform target)
            {
                var result = Vector3.zero;
                while (ancestor != target && target != null)
                {
                    result += target.localPosition;
                    target = target.parent;
                }
                return result;
            }
    
            public void EnsureScrollVisible()
            {
                Canvas.ForceUpdateCanvases();
    
                var targetPosition = LocalPositionWithinAncestor(scrollRect.content, target);
                var top = (-targetPosition.y) - target.rect.height / 2;
                var bottom = (-targetPosition.y) + target.rect.height / 2;
    
                var topMargin = 100; // this is here because there are headers over the buttons sometimes
    
                var result = scrollRect.content.anchoredPosition;
                if (result.y > top - topMargin)
                    result.y = top - topMargin;
                if (result.y + scrollRect.viewport.rect.height < bottom)
                    result.y = bottom - scrollRect.viewport.rect.height;
    
                //Debug.Log($"{targetPosition} {target.rect.size} {top} {bottom} {scrollRect.content.anchoredPosition}->{result}");
    
                scrollRect.content.anchoredPosition = result;
            }
    
            public void OnSelect(BaseEventData eventData)
            {
                if (scrollRect != null)
                    EnsureScrollVisible();
            }
        }
    }
0
Sudhir Kotila On

Yes,this is possible using coding to scroll vertically, please try this code :

//Set Scrollbar Value - For Displaying last message of content
Canvas.ForceUpdateCanvases ();
verticleScrollbar.value = 0f;
Canvas.ForceUpdateCanvases ();

This code working fine for me ,when i developed chat functionality.

0
Петър Петров On

Let me add my thing too, only for vertical scrolls:

    public static void FitScrollAreaToChildVertically(this ScrollRect sr, RectTransform selected, float scrollAreaUpperBoundMargin = -10, float scrollAreaLowerBoundMargin = -10)
    {
        var selectedRectTransform = selected;
        Canvas.ForceUpdateCanvases();

        var objPosition = (Vector2)sr.transform.InverseTransformPoint(selectedRectTransform.position);
        var scrollHeight = sr.GetComponent<RectTransform>().rect.height;
        var objHeight = selectedRectTransform.rect.height;

        float ubound = scrollHeight / 2 - scrollAreaUpperBoundMargin;
        float dbound = -scrollHeight / 2 + scrollAreaLowerBoundMargin;

        float itemdbound = objPosition.y - objHeight / 2;
        float itemubound = objPosition.y + objHeight / 2;

        if (itemdbound < dbound)
            sr.content.anchoredPosition += new Vector2(0, dbound - itemdbound);
        else if (itemubound > ubound)
            sr.content.anchoredPosition += new Vector2(0, -(itemubound - ubound));
    }
0
v01pe On

The preconditions for my version of this problem:

  • The element I want to scroll to should be fully visible (with minimal clearance)
  • The element is a direct child of the scrollRect's content
  • Keep scoll position if element is already fully visible
  • I only care about the vertical dimension

This is what worked best for me (thanks for the other inspirations):

// ScrollRect scrollRect;
// RectTransform element;
// Fully show `element` inside `scrollRect` with at least 25px clearance
scrollArea.EnsureVisibility(element, 25);

Using this extension method:

public static void EnsureVisibility(this ScrollRect scrollRect, RectTransform child, float padding=0)
{
    Debug.Assert(child.parent == scrollRect.content,
        "EnsureVisibility assumes that 'child' is directly nested in the content of 'scrollRect'");

    float viewportHeight = scrollRect.viewport.rect.height;
    Vector2 scrollPosition = scrollRect.content.anchoredPosition;

    float elementTop = child.anchoredPosition.y;
    float elementBottom = elementTop - child.rect.height;

    float visibleContentTop = -scrollPosition.y - padding;
    float visibleContentBottom = -scrollPosition.y - viewportHeight + padding;

    float scrollDelta =
        elementTop > visibleContentTop ? visibleContentTop - elementTop :
        elementBottom < visibleContentBottom ? visibleContentBottom - elementBottom :
        0f;

    scrollPosition.y += scrollDelta;
    scrollRect.content.anchoredPosition = scrollPosition;
}
0
geoathome On

A variant of Peter Morris answer for ScrollRects which have movement type "elastic". It bothered me that the scroll rect kept animating for edge cases (first or last few elements). Hope it's useful:

/// <summary>
/// Thanks to https://stackoverflow.com/a/50191835
/// </summary>
/// <param name="instance"></param>
/// <param name="child"></param>
/// <returns></returns>
public static IEnumerator BringChildIntoView(this UnityEngine.UI.ScrollRect instance, RectTransform child)
{
    Canvas.ForceUpdateCanvases();
    Vector2 viewportLocalPosition = instance.viewport.localPosition;
    Vector2 childLocalPosition = child.localPosition;
    Vector2 result = new Vector2(
        0 - (viewportLocalPosition.x + childLocalPosition.x),
        0 - (viewportLocalPosition.y + childLocalPosition.y)
    );
    instance.content.localPosition = result;

    yield return new WaitForUpdate();

    instance.horizontalNormalizedPosition = Mathf.Clamp(instance.horizontalNormalizedPosition, 0f, 1f);
    instance.verticalNormalizedPosition = Mathf.Clamp(instance.verticalNormalizedPosition, 0f, 1f);
}

It introduces a one frame delay though.

UPDATE: Here is a version which does not introduce a frame delay and it also takes scaling of the content into account:

/// <summary>
/// Based on https://stackoverflow.com/a/50191835
/// </summary>
/// <param name="instance"></param>
/// <param name="child"></param>
/// <returns></returns>
public static void BringChildIntoView(this UnityEngine.UI.ScrollRect instance, RectTransform child)
{
    instance.content.ForceUpdateRectTransforms();
    instance.viewport.ForceUpdateRectTransforms();

    // now takes scaling into account
    Vector2 viewportLocalPosition = instance.viewport.localPosition;
    Vector2 childLocalPosition = child.localPosition;
    Vector2 newContentPosition = new Vector2(
        0 - ((viewportLocalPosition.x * instance.viewport.localScale.x) + (childLocalPosition.x * instance.content.localScale.x)),
        0 - ((viewportLocalPosition.y * instance.viewport.localScale.y) + (childLocalPosition.y * instance.content.localScale.y))
    );

    // clamp positions
    instance.content.localPosition = newContentPosition;
    Rect contentRectInViewport = TransformRectFromTo(instance.content.transform, instance.viewport);
    float deltaXMin = contentRectInViewport.xMin - instance.viewport.rect.xMin;
    if(deltaXMin > 0) // clamp to <= 0
    {
        newContentPosition.x -= deltaXMin;
    }
    float deltaXMax = contentRectInViewport.xMax - instance.viewport.rect.xMax;
    if (deltaXMax < 0) // clamp to >= 0
    {
        newContentPosition.x -= deltaXMax;
    }
    float deltaYMin = contentRectInViewport.yMin - instance.viewport.rect.yMin;
    if (deltaYMin > 0) // clamp to <= 0
    {
        newContentPosition.y -= deltaYMin;
    }
    float deltaYMax = contentRectInViewport.yMax - instance.viewport.rect.yMax;
    if (deltaYMax < 0) // clamp to >= 0
    {
        newContentPosition.y -= deltaYMax;
    }

    // apply final position
    instance.content.localPosition = newContentPosition;
    instance.content.ForceUpdateRectTransforms();
}

/// <summary>
/// Converts a Rect from one RectTransfrom to another RectTransfrom.
/// Hint: use the root Canvas Transform as "to" to get the reference pixel positions.
/// </summary>
/// <param name="from"></param>
/// <param name="to"></param>
/// <returns></returns>
public static Rect TransformRectFromTo(Transform from, Transform to)
{
    RectTransform fromRectTrans = from.GetComponent<RectTransform>();
    RectTransform toRectTrans = to.GetComponent<RectTransform>();

    if (fromRectTrans != null && toRectTrans != null)
    {
        Vector3[] fromWorldCorners = new Vector3[4];
        Vector3[] toLocalCorners = new Vector3[4];
        Matrix4x4 toLocal = to.worldToLocalMatrix;
        fromRectTrans.GetWorldCorners(fromWorldCorners);
        for (int i = 0; i < 4; i++)
        {
            toLocalCorners[i] = toLocal.MultiplyPoint3x4(fromWorldCorners[i]);
        }

        return new Rect(toLocalCorners[0].x, toLocalCorners[0].y, toLocalCorners[2].x - toLocalCorners[1].x, toLocalCorners[1].y - toLocalCorners[0].y);
    }

    return default(Rect);
}
1
FzJaa On

simple and works perfectly

            var pos = 1 - ((content.rect.height / 2 - target.localPosition.y) / content.rect.height);
            scrollRect.normalizedPosition = new Vector2(0, pos);
2
Peter Morris On

None of the suggestions worked for me, the following code did

Here is the extension

using UnityEngine;
using UnityEngine.UI;

namespace BlinkTalk
{
    public static class ScrollRectExtensions
    {
        public static Vector2 GetSnapToPositionToBringChildIntoView(this ScrollRect instance, RectTransform child)
        {
            Canvas.ForceUpdateCanvases();
            Vector2 viewportLocalPosition = instance.viewport.localPosition;
            Vector2 childLocalPosition   = child.localPosition;
            Vector2 result = new Vector2(
                0 - (viewportLocalPosition.x + childLocalPosition.x),
                0 - (viewportLocalPosition.y + childLocalPosition.y)
            );
            return result;
        }
    }
}

And here is how I used it to scroll a direct child of the content into view

    private void Update()
    {
        MyScrollRect.content.localPosition = MyScrollRect.GetSnapToPositionToBringChildIntoView(someChild);
    }
5
vedranm On

Although @maksymiuk's answer is the most correct one, as it properly takes into account anchors, pivot and all the rest thanks to InverseTransformPoint() function, it still didn't work out-of-box for me - for vertical scroller, it was changing its X position too. So I just made change to check if vertical or horizontal scroll is enabled, and not change their axis if they aren't.

public static void SnapTo( this ScrollRect scroller, RectTransform child )
{
    Canvas.ForceUpdateCanvases();

    var contentPos = (Vector2)scroller.transform.InverseTransformPoint( scroller.content.position );
    var childPos = (Vector2)scroller.transform.InverseTransformPoint( child.position );
    var endPos = contentPos - childPos;
    // If no horizontal scroll, then don't change contentPos.x
    if( !scroller.horizontal ) endPos.x = contentPos.x;
    // If no vertical scroll, then don't change contentPos.y
    if( !scroller.vertical ) endPos.y = contentPos.y;
    scroller.content.anchoredPosition = endPos;
}
0
Serg On

Vertical adjustment:

[SerializeField]
private ScrollRect _scrollRect;

private void ScrollToCurrentElement()
{
    var siblingIndex = _currentListItem.transform.GetSiblingIndex();

    float pos = 1f - (float)siblingIndex / _scrollRect.content.transform.childCount;

    if (pos < 0.4)
    {
        float correction = 1f / _scrollRect.content.transform.childCount;
        pos -= correction;
    }

    _scrollRect.verticalNormalizedPosition = pos;
} 
1
NibbleByte On

I had issues with the provided answers so I wrote my own, that doesn't care about your scroll setup (anchors, pivots, horizontal, vertical, targets not being direct child of the content transform, etc.). I do this by converting the target child rect in local space to the viewport and check if it sticks out the viewport. If it does, move the content in that direction enough so the target child is completely visible. This is better than some answers that always snap the target to the top of the scroll.

public static void KeepChildInScrollViewPort(ScrollRect scrollRect, RectTransform child, Vector2 margin)
{
    Canvas.ForceUpdateCanvases();

    // Get min and max of the viewport and child in local space to the viewport so we can compare them.
    // NOTE: use viewport instead of the scrollRect as viewport doesn't include the scrollbars in it.
    Vector2 viewPosMin = scrollRect.viewport.rect.min;
    Vector2 viewPosMax = scrollRect.viewport.rect.max;

    Vector2 childPosMin = scrollRect.viewport.InverseTransformPoint(child.TransformPoint(child.rect.min));
    Vector2 childPosMax = scrollRect.viewport.InverseTransformPoint(child.TransformPoint(child.rect.max));

    childPosMin -= margin;
    childPosMax += margin;

    Vector2 move = Vector2.zero;

    // Check if one (or more) of the child bounding edges goes outside the viewport and
    // calculate move vector for the content rect so it can keep it visible.
    if (childPosMax.y > viewPosMax.y) {
        move.y = childPosMax.y - viewPosMax.y;
    }
    if (childPosMin.x < viewPosMin.x) {
        move.x = childPosMin.x - viewPosMin.x;
    }
    if (childPosMax.x > viewPosMax.x) {
        move.x = childPosMax.x - viewPosMax.x;
    }
    if (childPosMin.y < viewPosMin.y) {
        move.y = childPosMin.y - viewPosMin.y;
    }

    // Transform the move vector to world space, then to content local space (in case of scaling or rotation?) and apply it.
    Vector3 worldMove = scrollRect.viewport.TransformDirection(move);
    scrollRect.content.localPosition -= scrollRect.content.InverseTransformDirection(worldMove);
}
0
parminder On

width signifiy the width of childern in scroll rect (assuming that all childerns width is same), spacing signifies the space between childerns, index signifies the target element you want to reach

public float getSpecificItem (float pWidth, float pSpacing,int pIndex) {
    return (pIndex * pWidth) - pWidth + ((pIndex - 1) * pSpacing);
}
2
Александр Шмидтке On

here's the way I clamped selected object into ScrollRect

private ScrollRect scrollRect;
private RectTransform contentPanel;

public void ScrollReposition(RectTransform obj)
{
    var objPosition = (Vector2)scrollRect.transform.InverseTransformPoint(obj.position);
    var scrollHeight = scrollRect.GetComponent<RectTransform>().rect.height;
    var objHeight = obj.rect.height;

    if (objPosition.y > scrollHeight / 2)
    {
        contentPanel.localPosition = new Vector2(contentPanel.localPosition.x,
            contentPanel.localPosition.y - objHeight - Padding.top);
    }

    if (objPosition.y < -scrollHeight / 2)
    {
        contentPanel.localPosition = new Vector2(contentPanel.localPosition.x,
contentPanel.localPosition.y + objHeight + Padding.bottom);
    }
}
0
Soorya On

In case anyone looking for a smooth scroll (using lerp).

[SerializeField]
private ScrollRect _scrollRectComponent; //your scroll rect component
[SerializeField]
RectTransform _container; //content transform of the scrollrect
private IEnumerator LerpToChild(RectTransform target)
{
    Vector2 _lerpTo = (Vector2)_scrollRectComponent.transform.InverseTransformPoint(_container.position) - (Vector2)_scrollRectComponent.transform.InverseTransformPoint(target.position);
    bool _lerp = true;
    Canvas.ForceUpdateCanvases();

    while(_lerp)
    {
        float decelerate = Mathf.Min(10f * Time.deltaTime, 1f);
        _container.anchoredPosition = Vector2.Lerp(_scrollRectComponent.transform.InverseTransformPoint(_container.position), _lerpTo, decelerate);
        if (Vector2.SqrMagnitude((Vector2)_scrollRectComponent.transform.InverseTransformPoint(_container.position) - _lerpTo) < 0.25f)
        {
            _container.anchoredPosition = _lerpTo;
            _lerp = false;
        }
        yield return null;
    }
}