【Unity】吸い付くスクロールを実装してみた

メニューをスクロールしたとき中途半端な位置てスクロールを停止すると、近くの要素に位置が吸い付くように移動する動作をマグネットメニューとかマグネットスクロールと呼んだりするらしい?のですが、今回はその実装方法を紹介です。

サンプル動作

実際に動かすと以下のような動きになります。吸い付くように動きます。

f:id:Takachan:20210713232731g:plain

確認環境

今回の実装・確認環境は以下の通りです。

  • Unity 2020.3.12f1
  • VisualStudio 2019
  • Windows10(Editor上のみ)

3rdライブラリとして、「DOTween」を使用します。

実装方法

さっそく実装方法の説明です。

コンポーネント構成

まず、サンプルの表示を行うために以下のように並べます。

f:id:Takachan:20210713233342p:plain

ヒエラルキーの内訳は以下の通です。

+ Canvas
  + Menu   // タッチ & スワイプを検出する(RectTransform)
    + Item_{N}   // 個別の要素(Image)

まずは配置のみしておきます。

MagnetMenuクラス

マグネットメニュー動作を実現するためのスクリプトの本体です。やっていることは主に以下の2つです。

【その1】

「IDragHandler」「IBeginDragHandler」「IEndDragHandler」をそれぞれ継承することで実装してユーザーが画面を左右にドラッグ(スワイプ)する動作を自分の表示領域内で検出するようにします。これで自分の RectTransform の範囲内でユーザーの画面操作を拾って処理を行います。

【その2】

中途半端な位置に停止した場合リストの中から最も中央に近い方のオブジェクトを選択し、メニュー全体をマグネット動作で全体を移動する処理を記述します。位置が中途半端な位置から目標の位置への移動は DOTween のアニメーションに任せて自分では細かい処理を書かないようにしています。

using System.Collections.Generic;
using DG.Tweening;
using Takap.Utility;
using UnityEngine;
using UnityEngine.EventSystems;

public class MagnetMenu : MonoBehaviour, IDragHandler, IBeginDragHandler, IEndDragHandler
{
    // 吸いつく位置の中心座標
    [SerializeField] private Vector2 magnetPosition;
    // 等間隔に並べる要素と要素の間隔
    [SerializeField] private float itemDistance;
    // 制御対象の子要素
    [SerializeField] List<RectTransform> items;
    // 初期表示したときに中央に表示する値
    [SerializeField] int centerElemIndex;

    // アニメーション中かどうかのフラグ
    // true : 実行中 / false : それ以外
    private bool isDragging;

    private void Awake()
    {
        this.updateItemsScale();
    }

    private void OnDestroy()
    {
        this.tweenList.KillAllAndClear();
    }

    private void Update()
    {
        if (!this.isDragging)
        {
            return;
        }
        this.updateItemsScale();
    }

    private void updateItemsScale()
    {
        foreach (var item in this.items)
        {
            float distance = Mathf.Abs(item.GetAnchoredPosX());
            float scale = Mathf.Clamp(1.0f - distance / (170.0f * 4.0f), 0.65f, 1.0f);
            item.SetLocalScaleXY(scale);
        }
    }

    public void OnDrag(PointerEventData e)
    {
        // 操作量に応じてX方向に移動する
        float delta_x = e.delta.x;
        foreach (var item in this.items)
        {
            RectTransform rect = item;
            var pos = rect.anchoredPosition;
            pos.x += delta_x;
            rect.anchoredPosition = pos;
        }
    }

    public void OnBeginDrag(PointerEventData e)
    {
        this.isDragging = true;
        this.tweenList.KillAllAndClear();
    }

    public void OnEndDrag(PointerEventData e)
    {
        // 移動目標量を計算
        RectTransform rect = this.pickupNearestRect();
        float tartgetX = -rect.GetAnchoredPosX();

        for (int i = 0; i < this.items.Count; i++)
        {
            RectTransform item = this.items[i];
            
            Tween t = 
                item.DOAnchorPosX(item.GetAnchoredPosX()
                    + tartgetX, 0.075f).SetEase(Ease.OutSine);
            if (i <= this.items.Count)
            {
                Sequence seq = DOTween.Sequence();
                seq.Append(t);
                seq.AppendCallback(this.onCompleted);
                this.tweenList.Add(seq);
            }
            else
            {
                this.tweenList.Add(t);
            }
        }
    }

    private void onCompleted() => this.isDragging = false;

    private List<Tween> tweenList = new List<Tween>();

    // マグネット中心に最も近い要素を選択する
    private RectTransform pickupNearestRect()
    {
        RectTransform nearestRect = null;
        foreach (var rect in this.items)
        {
            if (nearestRect == null)
            {
                nearestRect = rect; // 初回選択
            }
            else
            {
                if (Mathf.Abs(rect.GetAnchoredPosX()) 
                    < Mathf.Abs(nearestRect.GetAnchoredPosX()))
                {
                    nearestRect = rect; // より中心に近いほうを選択
                }
            }
        }
        return nearestRect;
    }
}

MagnetItemクラス

MagnetMenu が制御する子要素を表すクラスです。MagnetMenu はこのコンポーネントを持っているゲームオブジェクトを吸い付く動作の対象にします。

using UnityEngine;

// 制御対象の子要素を表すコンポーネント
public class MagnetItem : MonoBehaviour
{
    // あまり意味ないけど一応制御できる子要素を明示するために定義する
    public RectTransform RectTransform => this.transform as RectTransform;
}

その他のクラス

上記のスクリプト内で使用している便利メソッドの定義です。

List_Tween_Extensionクラス

List に対する便利操作を実装します。

using System.Collections.Generic;
using DG.Tweening;

public static class List_Tween_Extension
{
    // リスト内のすべてのアニメーションを停止します
    public static void KillAllAndClear(this List<Tween> self)
    {
        self.ForEach(tween => tween.Kill());
        self.Clear();
    }
}
RectTransformExtensionクラス

RectTransform に対する便利操作を実装します

public static class RectTransformExtension
{
    // Xのアンカー位置を取得する
    public static float GetAnchoredPosX(this RectTransform self)
    {
        return self.anchoredPosition.x;
    }
    // オブジェクトの拡大率の設定
    public static void SetLocalScaleXY(this Transform self, float xy)
    {
        Vector3 scale = self.localScale;
        scale.x = xy;
        scale.y = xy;
        self.localScale = scale;
    }
}

コンポーネントにスクリプト追加

上記のスクリプトを以下のゲームオブジェクトに追加します。

+ Canvas
  + Menu  // これにMagnetMenuを追加する
    + Item_{N}   // それぞれMagnetItemを追加する

次に以下のように MagnetMenu の List に MagnetItem を追加していきます。

f:id:Takachan:20210713234148p:plain

こうすることで画面を操作したときにリスト内の要素が追従して動くようになります。

最後に

あんまりたくさん要素があると割と問題が起きるかもしれませんが子要素が50個程度なら特に問題なく動くと思います。最小限の実装しかしていませんが、実際はマグネットフィットが完了したときに音を鳴らすためにイベントを上げたりいろいろこれをベースに改造してもよいかもしれません。

関連リンク

リングコマンドの実装方法はこちら

takap-tech.com