【UnityAsset】SnapScroll – iPhoneのホーム画面のようなスナップスクロールを作る

f:id:okamura0510:20180516220626g:plain:w320
【GitHub】SnapScroll

今までアプリで何度となく作ってきたページ単位でスクロールするいわゆるスナップスクロールというもの。
少しずつ改善を重ね、ようやく良い感じのものが出来たのでAsset化しました☆

試しにiPhoneのホーム画面を作ってみた(笑)

SnapScrollとは

一定の表示領域(ページ)単位でピタっと止まるスクロールをするUI。
uGUIにはScrollViewはあるけど、スナップスクロールの機能はないので、独自に実装する必要がある。uGUIのScrollViewを実現させているScrollRectを拡張して作っているので、ScrollViewと同じ使用感で使えます♪

動作環境

Unity2017.1.0+
iOS 7.0+
Android 4.1+

使用方法

通常の手順でScrollViewを追加する。
f:id:okamura0510:20180517105005p:plain:h350
f:id:okamura0510:20180517105950p:plain:h320

ScrollRectを削除してSnapScrollViewを追加する。ContentやViewport等、外れた参照は再度設定しておく。
f:id:okamura0510:20180517111012p:plain:h320

インスペクターオプション(▼三)からDebugを選択し、スナップスクロールに関するプロパティを編集。もしくはスクリプトからプロパティを編集してもOK。
※本当はDebugモードにせずにプロパティを編集したかったが、どうやらScrollRect自体がエディタ拡張を行ってるようで、直接プロパティを表示できなかった
f:id:okamura0510:20180517112757p:plain:h170
f:id:okamura0510:20180517115745p:plain:h150

using UnityEngine;
using UnityEngine.UI;
using SnapScroll;

public class Demo : MonoBehaviour
{
    [SerializeField] SnapScrollView scrollView;
    [SerializeField] Image[] indicators;

    void Start()
    {
        scrollView.MaxPage        = 1;
        scrollView.PageSize       = 1080;
        scrollView.OnPageChanged += OnIndicatorUpdate;
        scrollView.RefreshPage();
    }

    void OnIndicatorUpdate()
    {
        for(var i = 0; i < indicators.Length; i++)
        {
            var a = (i == scrollView.Page) ? 1 : 0.5f;
            indicators[i].color = new Color(1, 1, 1, a);
        }
    }
}
パラメーター 説明
Page 現在ページ(0〜)
MaxPage 最大ページ(0〜)
PageSize ページサイズ
ScrollableDistance スクロール可能な判定距離(フリックのしやすさ)
Tween アンカーポジション移動Tween。基本、編集の必要はないが、Tweenの設定をいじれば動き方を変えることも出来る。
OnPageChanged ページ変化イベント。このイベントでインジケーターの更新等を行う。
RefreshPage(bool isPlayAnimation = true) ページリフレッシュメソッド。isPlayAnimationをfalseにすればアニメーションを再生せずにポジション移動が可能(初期ページが0以外で初期化する時とか)。

プログラム

SnapScrollView.cs
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using System;

namespace SnapScroll
{
    /// <summary>
    /// スナップスクロールビュー
    /// </summary>
    [AddComponentMenu("UI/SnapScrollView", 100)]
    public class SnapScrollView : ScrollRect
    {
        /// <summary>
        /// 現在ページ(0〜)
        /// </summary>
        [SerializeField] int page;
        /// <summary>
        /// 最大ページ(0〜)
        /// </summary>
        [SerializeField] int maxPage;
        /// <summary>
        /// ページサイズ
        /// </summary>
        [SerializeField] float pageSize;
        /// <summary>
        /// スクロール可能な判定距離(フリックのしやすさ)
        /// </summary>
        [SerializeField] float scrollableDistance = 2;
        /// <summary>
        /// アンカーポジション移動Tween
        /// </summary>
        [SerializeField] TweenAnchorPosition tween;
        /// <summary>
        /// 現在のドラッグポジション
        /// </summary>
        Vector3 dragPos;
        /// <summary>
        /// 前回のドラッグポジション
        /// </summary>
        Vector3 prevDragPos;

        /// <summary>
        /// 現在ページ(0〜)
        /// </summary>
        public int Page { get { return page; } set { page = value; } }
        /// <summary>
        /// 最大ページ(0〜)
        /// </summary>
        public int MaxPage { get { return maxPage; } set { maxPage = value; } }
        /// <summary>
        /// ページサイズ
        /// </summary>
        public float PageSize { get { return pageSize; } set { pageSize = value; } }
        /// <summary>
        /// スクロール可能な判定距離(フリックのしやすさ)
        /// </summary>
        public float ScrollableDistance { get { return scrollableDistance; } set { scrollableDistance = value; } }
        /// <summary>
        /// アンカーポジション移動Tween
        /// </summary>
        public TweenAnchorPosition Tween { get { return tween; } }
        /// <summary>
        /// ページ変化イベント
        /// </summary>
        public event Action OnPageChanged;

        void Update()
        {
            if(tween.IsRunning) tween.Update();
        }

        public override void OnBeginDrag(PointerEventData eventData)
        {
            base.OnBeginDrag(eventData);
        
            tween.Stop();
            dragPos     = content.position;
            prevDragPos = Vector3.zero;
        }
        
        public override void OnDrag(PointerEventData eventData)
        {
            base.OnDrag(eventData);
        
            prevDragPos = dragPos;
            dragPos     = content.position;
        }
        
        public override void OnEndDrag(PointerEventData eventData)
        {
            base.OnEndDrag(eventData);
        
            StopMovement();
        
            // 「ページ内移動量」「最終フレームドラッグ量(フリック量)」でスクロール可能か判定
            var pageDx       = -pageSize * page - content.localPosition.x;
            var dragDx       = prevDragPos.x    - content.position.x;
            var pageDistance = Mathf.Abs(pageDx);
            var dragDistance = Math.Abs(dragDx);
            var isScrollable = false;
            var isRight      = false;
            if(pageDistance >= pageSize / 2)
            {
                // ページ半分以上動かしていたら強制的に次ページへ
                isScrollable = true;
                isRight      = (pageDx >= 0);
            }
            else if(dragDistance >= scrollableDistance)
            {
                // スクロール可能な距離以上動かしていたら次ページへ
                isScrollable = true;
                isRight      = (dragDx >= 0);
            }
        
            if(isScrollable)
            {
                // 最大・最小ページを超えないようにページ更新
                if((isRight && page < maxPage) || (!isRight && page >= 1))
                {
                    page += isRight ? 1 : -1;
                }
            }
        
            RefreshPage();
        }
        
        /// <summary>
        /// ページリフレッシュ
        /// </summary>
        /// <param name="isPlayAnimation">アニメーションを再生させるか</param>
        public void RefreshPage(bool isPlayAnimation = true)
        {
            var movePos = content.anchoredPosition;
            movePos.x   = -pageSize * page;
            if(isPlayAnimation)
            {
                tween.Run(content, movePos);
            }
            else
            {
                content.anchoredPosition = movePos;
            }
            
            if(OnPageChanged != null) OnPageChanged();
        }
    }
}

ScrollRectのドラッグイベントでドラッグポジションを取って、ドラッグ終了時に最終フレームドラッグ量(フリック量)でスクロール可能か判定してる。
何気にTweenがお手製w(外部公開するに当たって他のAssetを含めたくなかったので自作した)

最後に

結構こういうちょっとしたUIやツールとか作るの好きなんですよね〜☆以前、まんまRPGツクールのようなエディタ作ったこともあったり♪
UnityとC#でのモノづくりはホント楽しいなぁ。