Unity UGUI ScrollRectの高度な機能実装と最適化テクニック

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を構築できます。

5月17日 10:09 投稿