Unity: Scroll Rect Controller Input

Have you ever wondered why controller input isn’t supported by the scroll rect? This is a question that crosses my mind every time I work on UI with controller support. Most of the UI components don’t support controller input apart from navigation. While Unity offers a lot of default functionality, there are instances where the basics seem to be missing.

 

To address this issue, I wrote a script. Whenever the event system selects a game object outside the viewport, the scroll rect scrolls (vertically) until it becomes visible. The script can easily be extended to accommodate horizontal scrolling.

 

Feel free to use or modify this version based on your preferences!

 

namespace AutoScroll
{
	using UnityEngine;
	using UnityEngine.EventSystems;
	using UnityEngine.UI;

	/// <summary>
	/// Setup: 
	/// Add the component next to the <see cref="ScrollRect"/>, 
	/// references will be gathered automatically.
	/// 
	/// Functionality: 
	/// When the <see cref="EventSystem"/> selects a game object outside the 
	/// <see cref="ScrollRect.viewport"/>, the <see cref="ScrollRect"/> 
	/// is scrolled to make it visible.
	/// 
	/// Constraints:
	/// Only supports vertical scrolling.
	/// </summary>
	public class AutoScrollOnSelect : MonoBehaviour
	{
		[SerializeField]
		private ScrollRect scrollRect;

		[SerializeField]
		private RectTransform viewport;

		private void Reset()
		{
			if (scrollRect == null)
				scrollRect = GetComponentInChildren<ScrollRect>();

			if (scrollRect != null && viewport == null)
				viewport = scrollRect.viewport.GetComponent<RectTransform>();
		}

		private void Update()
		{
			if (IsScrollRectValid(scrollRect) == false)
				return;

			RectTransform currentSelection = GetCurrentSelectedRectTransform();

			if (IsPartOfScrollRectContent(scrollRect, currentSelection))
				UpdateAutoScroll(currentSelection);
		}

		private void UpdateAutoScroll(RectTransform target)
		{
			RectBoundary boundaryInViewPortSpace =
				target.GetRectBounderyInTargetLocalSpace(viewport);

			bool outsideViewTop = boundaryInViewPortSpace.Max.y > viewport.rect.yMax;
			bool outsideViewBottom = boundaryInViewPortSpace.Min.y < viewport.rect.yMin;

			if (outsideViewTop)
				ScrollUp(scrollRect, viewport, boundaryInViewPortSpace);
			else if (outsideViewBottom)
				ScrollDown(scrollRect, viewport, boundaryInViewPortSpace);
		}

		/// <summary>
		/// The <paramref name="scrollTarget"/> is required to be in the local
		/// space of the <paramref name="viewport"/>.
		/// </summary>
		private static void ScrollUp(
			ScrollRect scrollRect,
			RectTransform viewport,
			RectBoundary scrollTarget)
		{
			float scrollUpDelta = scrollTarget.Max.y - viewport.rect.yMax;
			Scroll(scrollRect, viewport.rect.height, scrollUpDelta);
		}

		/// <summary>
		/// The <paramref name="scrollTarget"/> is required to be in the local
		/// space of the <paramref name="viewport"/>.
		/// </summary>
		private static void ScrollDown(
			ScrollRect scrollRect,
			RectTransform viewport,
			RectBoundary scrollTarget)
		{
			float scrollDownDelta = scrollTarget.Min.y - viewport.rect.yMin;
			Scroll(scrollRect, viewport.rect.height, scrollDownDelta);
		}

		/// <summary>
		/// The <paramref name="scrollDelta"/> is required to be in local
		/// viewport space.
		/// </summary>
		private static void Scroll(
			ScrollRect scrollRect,
			float viewPortHeight,
			float scrollDelta)
		{
			float contentHeight = scrollRect.content.rect.height;
			float overflow = contentHeight - viewPortHeight;
			float unitsToNormalize = 1f / overflow;

			scrollRect.verticalNormalizedPosition += scrollDelta * unitsToNormalize;
		}

		private static bool IsScrollRectValid(ScrollRect scrollRect)
		{
			return scrollRect != null && scrollRect.IsActive();
		}

		private static bool IsPartOfScrollRectContent(
			ScrollRect scrollRect,
			RectTransform rectTransform)
		{
			return
				rectTransform != null &&
				rectTransform.IsChildOf(scrollRect.content);
		}

		private static RectTransform GetCurrentSelectedRectTransform()
		{
			GameObject currentSelection =
				EventSystem.current.currentSelectedGameObject;

			if (currentSelection != null)
				return currentSelection.GetComponent<RectTransform>();
			else
				return null;
		}
	}

	public static class RectTransformExtension
	{
		public static RectBoundary GetRectBoundaryInWorldSpace(this RectTransform rectTransform)
		{
			Vector3 rectMin = rectTransform.TransformPoint(rectTransform.rect.min);
			Vector3 rectMax = rectTransform.TransformPoint(rectTransform.rect.max);
			return new RectBoundary(rectMin, rectMax);
		}

		public static RectBoundary GetRectBounderyInTargetLocalSpace(this RectTransform rectTransform, RectTransform target)
		{
			RectBoundary boundaryWorldSpace = rectTransform.GetRectBoundaryInWorldSpace();

			Vector3 minLocalSpace = target.InverseTransformPoint(boundaryWorldSpace.Min);
			Vector3 maxLocalSpace = target.InverseTransformPoint(boundaryWorldSpace.Max);

			return new RectBoundary(minLocalSpace, maxLocalSpace);
		}
	}

	public struct RectBoundary
	{
		public readonly Vector3 Min;
		public readonly Vector3 Max;

		public RectBoundary(Vector3 min, Vector3 max)
		{
			Min = min;
			Max = max;
		}
	}
}