UnityのUGUI ScrollRectコンポーネントは基本的なリスト表示に優れていますが、高度なUIデザインを求められる場合は拡張が必要です。本記事では、スケーリングエフェクト、無限ループ、大量データの最適化、ドラッグ&ドロップの統合、自動吸着(スナップ)機能といった高度な機能の実装手法を解説します。これらの実装により、パフォーマンスを維持しつつ、リッチなユーザー体験を提供することが可能です。
実装する機能の概要
- スクロールに連動したアイテムのスケーリング(拡大・縮小)
- ループ構造と動的ロードの併用
- 大量データにおけるオブジェクトプール(Item复用)による最適化
- マスクを使用しない無限スクロール
- 不規則なサイズを持つ子オブジェクトの動的ロード
- スクロール操作とドラッグ操作の競合回避
- ドラッグ後の自動位置補正(スナップ機能)
1. スケーリングカルーセルの実装
標準のScrollRectを使用せず、AnimationCurveを用いてアイテムの位置とスケールを制御することで、中心に近づくほど拡大されるエフェクトを実現します。
using UnityEngine;
using System.Collections.Generic;
using UnityEngine.UI;
public class ZoomCarouselController : MonoBehaviour
{
[Header("Settings")]
public AnimationCurve scaleCurve;
public AnimationCurve positionCurve;
public float curveScaleFactor = 500f;
public float fixedYPosition = 0f;
public float spacingFactor = 0.2f;
private List<CarouselItem> items = new List<CarouselItem>();
private List<Graphic> graphicTargets = new List<Graphic>();
private CarouselItem focusedItem;
private CarouselItem previousFocusedItem;
private float scrollValue = 0f;
private float targetScrollValue = 0f;
private float currentAnimTime = 0f;
public float animDuration = 0.2f;
private bool isTransitioning = false;
public static ZoomCarouselController Instance { get; private set; }
void Awake()
{
Instance = this;
}
void Start()
{
InitializeItems();
}
void InitializeItems()
{
int count = items.Count;
if (count % 2 == 0)
{
Debug.LogWarning("Item count should ideally be odd for symmetrical layout.");
}
int centerIndex = count / 2;
for (int i = 0; i < count; i++)
{
items[i].Index = i;
var graphic = items[i].GetComponent<Graphic>();
if (graphic != null) graphicTargets.Add(graphic);
items[i].SetHighlight(false);
}
focusedItem = items[centerIndex];
}
void Update()
{
if (!isTransitioning) return;
currentAnimTime += Time.deltaTime;
float progress = Mathf.Clamp01(currentAnimTime / animDuration);
scrollValue = Mathf.Lerp(scrollValue, targetScrollValue, progress);
UpdateLayout(scrollValue);
UpdateSortingOrder();
if (progress >= 1f)
{
isTransitioning = false;
if (focusedItem) focusedItem.SetHighlight(true);
if (previousFocusedItem) previousFocusedItem.SetHighlight(false);
}
}
private void UpdateLayout(float value)
{
int centerIdx = items.Count / 2;
for (int i = 0; i < items.Count; i++)
{
float offset = spacingFactor * (centerIdx - i);
float curveInput = value + offset;
float x = positionCurve.Evaluate(curveInput) * curveScaleFactor;
float scale = scaleCurve.Evaluate(curveInput);
items[i].Transform.localPosition = new Vector3(x, fixedYPosition, 0f);
items[i].Transform.localScale = Vector3.one * scale;
}
}
private void UpdateSortingOrder()
{
// Scale determines depth: larger scale = higher depth (rendered on top)
graphicTargets.Sort((a, b) => a.transform.localScale.x.CompareTo(b.transform.localScale.x));
for (int i = 0; i < graphicTargets.Count; i++)
{
graphicTargets[i].transform.SetSiblingIndex(i);
}
}
public void SelectItem(int index)
{
if (isTransitioning || index < 0 || index >= items.Count) return;
var target = items[index];
if (target == focusedItem) return;
isTransitioning = true;
previousFocusedItem = focusedItem;
focusedItem = target;
// Calculate direction to shift the curve
float centerX = positionCurve.Evaluate(0.5f) * curveScaleFactor;
bool isRight = target.Transform.localPosition.x > centerX;
// Simple logic: move one 'spacingFactor' step towards the target
// Note: For precise arbitrary selection, more complex math mapping index to value is needed.
// Here we assume adjacent scrolling logic for simplicity in this snippet.
float delta = isRight ? -spacingFactor : spacingFactor;
targetScrollValue += delta;
currentAnimTime = 0f;
}
}
public abstract class CarouselItem : MonoBehaviour
{
public int Index { get; set; }
public Transform Transform { get { return transform; } }
public abstract void SetHighlight(bool isActive);
}
2. 大量データの非同期ロード(コルーチンによる分散生成)
一度に大量のアイテムをインスタンス化するとフレームレートが大幅に低下します。コルーチンを使用して、数個ずつフレームを跨いで生成することで、フリーズを防ぎます。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class AsyncListLoader : MonoBehaviour
{
public RectTransform contentPanel;
public GameObject itemPrefab;
private List<ListItemWidget> activeWidgets = new List<ListItemWidget>();
private List<DataItem> dataList = new List<DataItem>();
public void RefreshList(List<DataItem> newData)
{
dataList = newData;
// Remove excess items
while (activeWidgets.Count > dataList.Count)
{
DestroyImmediate(activeWidgets[0].gameObject);
activeWidgets.RemoveAt(0);
}
StartCoroutine(BuildListRoutine());
}
private IEnumerator BuildListRoutine()
{
int batchSize = 10; // Items per frame
int currentCount = activeWidgets.Count;
for (int i = 0; i < dataList.Count; i++)
{
CreateOrUpdateWidget(i);
// Yield every N items to keep framerate smooth
if (i % batchSize == 0 && i > 0)
{
yield return null;
}
}
}
private void CreateOrUpdateWidget(int index)
{
ListItemWidget widget = null;
if (index < activeWidgets.Count)
{
widget = activeWidgets[index];
}
else
{
var newObj = Instantiate(itemPrefab, contentPanel);
newObj.transform.localScale = Vector3.one;
widget = newObj.GetComponent<ListItemWidget>();
activeWidgets.Add(widget);
}
if (widget != null)
{
widget.Setup(dataList[index]);
}
}
}
// Helper classes
public class DataItem { public string Name; }
public class ListItemWidget : MonoBehaviour
{
public void Setup(DataItem data) { /* Implementation */ }
}
3. スクロールとドラッグの競合処理
リスト内のアイテムをドラッグ&ドロップ可能にする場合、ScrollRectのスクロール操作と競合します。入力ベクトルの方向を判定し、操作意図を分離させます。
using UnityEngine;
using UnityEngine.EventSystems;
public class SmartDragHandler : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
{
public ScrollRect parentScrollRect;
public bool isVerticalScroll = false; // True if parent scrolls vertically
public float dragThreshold = 0.5f;
private bool isDraggingItem = false;
private GameObject dragGhost;
private RectTransform dragPlane;
public void OnBeginDrag(PointerEventData eventData)
{
Vector2 delta = eventData.delta;
// Determine intent based on drag direction vs scroll direction
bool isIntendingToDrag = isVerticalScroll ?
Mathf.Abs(delta.x) > Mathf.Abs(delta.y) * dragThreshold :
Mathf.Abs(delta.y) > Mathf.Abs(delta.x) * dragThreshold;
if (isIntendingToDrag)
{
isDraggingItem = true;
StartItemDrag(eventData);
// Disable scrolling on parent if we are dragging an item out
if(parentScrollRect != null) parentScrollRect.OnBeginDrag(eventData); // Pass through or block depending on needs
}
else
{
isDraggingItem = false;
if(parentScrollRect != null) parentScrollRect.OnBeginDrag(eventData);
}
}
public void OnDrag(PointerEventData eventData)
{
if (isDraggingItem)
{
UpdateItemDrag(eventData);
}
else
{
if(parentScrollRect != null) parentScrollRect.OnDrag(eventData);
}
}
public void OnEndDrag(PointerEventData eventData)
{
if (isDraggingItem)
{
EndItemDrag();
}
else
{
if(parentScrollRect != null) parentScrollRect.OnEndDrag(eventData);
}
isDraggingItem = false;
}
private void StartItemDrag(PointerEventData eventData)
{
// Create a visual representation for dragging
dragGhost = Instantiate(this.gameObject);
dragGhost.transform.SetParent(FindInParents<Canvas>(this.gameObject).transform, true);
dragGhost.transform.SetAsLastSibling();
dragPlane = dragGhost.GetComponent<RectTransform>();
// Configure drag ghost visuals here (remove colliders, etc.)
}
private void UpdateItemDrag(PointerEventData eventData)
{
if (dragGhost != null && dragPlane != null)
{
Vector3 globalPos;
if (RectTransformUtility.ScreenPointToWorldPointInRectangle(dragPlane, eventData.position, eventData.pressEventCamera, out globalPos))
{
dragPlane.position = globalPos;
}
}
}
private void EndItemDrag()
{
Destroy(dragGhost);
// Handle drop logic here
}
static T FindInParents<T>(GameObject go) where T : Component
{
if (go == null) return null;
var comp = go.GetComponent<T>();
if (comp != null) return comp;
Transform t = go.transform.parent;
while (t != null && comp == null)
{
comp = t.gameObject.GetComponent<T>();
t = t.parent;
}
return comp;
}
}
4. ドロップゾーンの検出
アイテムをドロップする対象エリアに対し、`IDropHandler`を実装して受け入れ処理を行います。
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
public class DropZone : MonoBehaviour, IDropHandler, IPointerEnterHandler, IPointerExitHandler
{
public Image zoneHighlight;
public Color normalColor = Color.white;
public Color hoverColor = Color.yellow;
public void OnPointerEnter(PointerEventData eventData)
{
if (zoneHighlight != null && IsDragValid(eventData))
{
zoneHighlight.color = hoverColor;
}
}
public void OnPointerExit(PointerEventData eventData)
{
if (zoneHighlight != null)
{
zoneHighlight.color = normalColor;
}
}
public void OnDrop(PointerEventData eventData)
{
if (zoneHighlight != null)
{
zoneHighlight.color = normalColor;
}
if (IsDragValid(eventData))
{
// Process the drop (e.g., swap data, equip item)
Debug.Log("Item dropped here.");
}
}
private bool IsDragValid(PointerEventData eventData)
{
// Check if the dragged object has the required component or tag
return eventData.pointerDrag != null;
}
}
5. グリッドスナップ機能
ドラッグ終了時に、最も近いセル位置に自動的に移動させる機能です。DOTweenなどのTweenライブラリを使用するとスムーズな実装が可能です。
using UnityEngine;
using UnityEngine.EventSystems;
using DG.Tweening; // Requires DOTween
public class GridSnapScroller : MonoBehaviour, IEndDragHandler
{
public ScrollRect scrollRect;
public RectTransform content;
public float cellSize = 100f;
public float spacing = 10f;
private float totalCellSize;
void Start()
{
totalCellSize = cellSize + spacing;
}
public void OnEndDrag(PointerEventData eventData)
{
// Stop inertial scrolling
scrollRect.StopMovement();
float currentPos = content.anchoredPosition.x;
// Calculate nearest index
// Note: This logic assumes horizontal layout starting at 0
float indexFloat = Mathf.Abs(currentPos) / totalCellSize;
int targetIndex = Mathf.RoundToInt(indexFloat);
// Clamp index
// (Add logic to calculate max visible index based on content width here)
float targetX = -(targetIndex * totalCellSize);
// Animate to snap position
content.DOAnchorPosX(targetX, 0.3f).SetEase(Ease.OutQuad);
}
}
以上の手法を組み合わせることで、アプリケーションの要件に合わせた柔軟かつ高パフォーマンスなスクロールUIを構築できます。