Enhanced Scroller × MVPパターン × UniRxを使ってUnityで高機能なScrollerをいい感じに実装する

こんにちは、エンジニアリングマネージャーの渡辺(@mochi_neko_7)です。

現在Synamonが開発しているサービスのSYNMNでは、2DUI(uGUI)のScrollerの実装にはUnity標準のScrollRectではなく、有料アセットのEnhanced Scrollerを使用しています。*1

Scrollerはサーバーから可変長の要素を取得して表示する際など割と高頻度で使うUIです。

Enhanced Scrollerは便利なアセットなのですが、意外と日本語の記事などが少ない印象です。

ちょうど最近自分がEnhanced Scrollerを色々触っていたので、意識すべき設計の一例としてMVPパターン、UniRxを組み合わせた高機能なScrollerの快適な実装例を紹介します。

こんな方におすすめの記事となります。

  • 高機能なScrollerのアセットの導入を検討している方
  • Enhanced Scrollerを使用したサンプルコードを見たい方
  • Enhanced ScrollerとUniRxを組み合わせて便利に使い回すプラクティスが見たい方

*Enhanced Scrollerの宣伝みたいな書き方になっていますが、そういう訳ではありません...!

本記事内のサンプルコードの使用環境は下記になります。

  • Unity 2021.3.0f1
  • Enhanced Scroller 2.33
  • UniRx 7.1.0

注意点としてサンプルコードのイベント処理でUniRxを多用しますが、そこは本質ではありませんのであまり解説はしません。

UniRxの使い方などは別の解説記事を参考にしてください。

qiita.com

それでは早速本題に入りましょう。

Enhanced Scrollerの簡単な紹介

Enhanced ScrollerはUnity Asset Storeで購入可能な高機能Scrollerのアセットの一つです。

assetstore.unity.com

Enhanced Scrollerのメリット、デメリットを簡潔にまとめると下記になります。

メリット

  • Scroll内のUIの表示要素(View)を内部でPoolingしてくれるため、要素数が増えてもパフォーマンスが落ちにくい
  • Pull to RefreshやPaginationなど、よくある直感的なUXが簡単に実装できる

デメリット

  • Scriptでのセットアップが必要なため、デザイナーだけで作成するのは少しハードルが高い

基本的な使い方やサンプルは既に紹介記事があるため、こちらを参考にしてください。

light11.hatenadiary.com

light11.hatenadiary.com

この記事では基本的な使い方からもう一歩踏み込んで、実際にEnhanced Scrollerを組み込む際に意識すると良い設計、使い勝手が更に良くなる点を紹介します。

Enhanced ScrollerとMVPパターンの組み合わせ

Enhanced Scrollerはメリットに

Scroll内のUIの表示要素(View)を内部でPoolingしてくれるため、要素数が増えてもパフォーマンスが落ちにくい

と挙げたように、表示要素を使い回して最適化をしています。

そのため、UI(View)とデータ(Model)は一対一の対応関係にないため、UIとデータを明確に切り分けて管理する必要があります。

これはMVPパターンを始めとする、View(UI)とModel(データやロジック)を分離する設計方針と相性が良いです。

言葉で説明するだけだと少し分かりにくいかもしれませんので、実装例を見てみましょう。

Enhanced Scrollerのシンプルな使用例として、下記を考えます。

  • Scrollの要素
    • Button
    • そのButtonを押した回数の表示
  • 要素数は可変
  • 要素の高さは固定

ここではMVPパターンの使用例として、UniRxを用いたMV(R)Pパターンを採用します。

qiita.com

まずはパターンに則って、ModelとViewを作成しましょう。

今回の使用例のModelはシンプルです。

  • Countを増やす操作ができる
  • CountをPropertyとして保持している
using UniRx;

public sealed class CounterModel
{
    private readonly ReactiveProperty<int> count = new();
    public IReadOnlyReactiveProperty<int> Count => count;

    public void CountUp()
    {
        count.Value++;
    }
}

MonoBehaviour を継承する理由もないのでシンプルなclassとして実装します。

Viewの要件もシンプルです。

  • Countを増やす操作のためのButtonを持っていて、Buttonが押されたイベントを提供する
  • Countを表示するText(TextMeshPro)を持っていて、表示処理を提供する
using System;
using TMPro;
using UniRx;
using UnityEngine;
using UnityEngine.Assertions;
using UnityEngine.UI;

public sealed class CounterView : EnhancedScrollerCellView
{
    [SerializeField] private Button countUpButton = null;
    [SerializeField] private TMP_Text countText = null;

    private readonly Subject<Unit> onCountUpButtonClicked = new();
    public IObservable<Unit> OnCountUpButtonClicked => onCountUpButtonClicked;

    private void Awake()
    {
        Assert.IsNotNull(countUpButton);
        Assert.IsNotNull(countText);

        countUpButton
            .OnClickAsObservable()
            .Subscribe(onCountUpButtonClicked)
            .AddTo(this);
    }

    public void DisplayCount(int count)
    {
        countText.text = count.ToString();
    }
}

Enhanced Scrollerの仕様のために EnhancedScrollerCellView を継承します。

MVPアーキテクチャに則ってModelとViewがお互いのことを知らずに完結していることが分かると思います。

最後に本題であるEnhanced Scrollerを使用したModelとViewの繋ぎ込みとScrollerの実装を見ていきましょう。

Modelをどこでいくつ生成するかは実装側で任意に選択できるのですが、今回は雑に Start() の中で適当な数だけ生成します。

using System.Collections.Generic;
using EnhancedUI.EnhancedScroller;
using UniRx;
using UnityEngine;
using UnityEngine.Assertions;

public sealed class CounterScrollPresenter : MonoBehaviour, IEnhancedScrollerDelegate
{
    [SerializeField] private EnhancedScroller enhancedScroller = null;
    [SerializeField] private CounterView viewPrefab = null;

    private readonly List<CounterModel> models = new();
    private readonly List<CompositeDisposable> eventSubscriptions = new();

    int IEnhancedScrollerDelegate.GetNumberOfCells(EnhancedScroller scroller)
        => models.Count;

    private float? cellViewSize = null;

    float IEnhancedScrollerDelegate.GetCellViewSize(EnhancedScroller scroller, int dataIndex)
    {
        if (cellViewSize == null)
        {
            cellViewSize = viewPrefab.GetComponent<RectTransform>().rect.height;
        }

        return cellViewSize.Value;
    }

    EnhancedScrollerCellView IEnhancedScrollerDelegate.GetCellView(EnhancedScroller scroller, int dataIndex, int cellIndex)
        => scroller.GetCellView(viewPrefab);

    private void Start()
    {
        Assert.IsNotNull(enhancedScroller);
        Assert.IsNotNull(viewPrefab);

        // Modelのリストの生成
        for (var i = 0; i <= 10; i++)
        {
            models.Add(new CounterModel());
            eventSubscriptions.Add(new CompositeDisposable());
        }

        // Enhanced Scrollerのセットアップ
        enhancedScroller.Delegate = this;

        enhancedScroller
            .CellViewVisibilityChangedAsObservable<CounterView>()
            .Where(view => view.active)
            .Subscribe(ActivateCell)
            .AddTo(this);

        enhancedScroller
            .CellViewWillRecycleAsObservable<CounterView>()
            .Subscribe(DeactivateCell)
            .AddTo(this);

        enhancedScroller.ReloadData();
    }

    private void OnDestroy()
    {
        models.Clear();

        foreach (var eventSubscription in eventSubscriptions)
        {
            eventSubscription.Dispose();
        }

        eventSubscriptions.Clear();
    }

    private void ActivateCell(CounterView view)
    {
        var model = models[view.dataIndex];
        var eventSubscription = eventSubscriptions[view.dataIndex];
        eventSubscription.Clear();

        // View -> Modelの繋ぎ込み
        view
            .OnCountUpButtonClicked
            .Subscribe(_ => model.CountUp())
            .AddTo(eventSubscription);

        // Model -> Viewの繋ぎ込み
        model
            .Count
            .Subscribe(view.DisplayCount)
            .AddTo(eventSubscription);
    }

    private void DeactivateCell(CounterView view)
    {
        eventSubscriptions[view.dataIndex].Clear();
    }
}

見た目は適当ですが実際に動かしてみるとこのような形になります。

ソースコードはちょっと長いですが、ポイントをいくつか挙げます。

  • ActivateCell() でCell(Scrollの一要素)に対応するModelとViewの繋ぎ込みをしていますが、ここがPresenterとしての主な役割です
  • DeactivateCell() でCellのイベント登録を解除しています
  • 上記の操作をEnhanced Scrollerのイベントのそれぞれ、CellViewVisibilityChangedCellViewWillRecycle で実行しています
  • 上のソースコードではObservableに変換する拡張メソッドを使用していますが、次の章で解説します

特にEnhanced Scrollerのイベントを利用する部分が重要です。

前述した通りEnhanced ScrollerはViewを使い回す(CellViewのオブジェクトをPoolingする)ため、ModelとViewのインスタンスは一対一対応ではありません。

そのため、CellがActiveになったタイミングでViewとModelの繋ぎ込みをし、Cellが再使用されたタイミングでその繋ぎ込みを解除しています。

この処理を適切に行わないと、1つのViewに対して複数のModelが繋ぎ込まれて1つのButtonを触っただけで2つ以上のModelにイベントが飛んでしまう、といったことが起こり得ますので注意してください。

他のコードは通常通りのEnhanced Scroller、UniRxの使用方法通りですので、ModelやViewのロジックが多少変わってもほぼ同様の流れで実装ができるかと思います。

必ずしもMVPアーキテクチャである必要はありませんが、ModelとViewの分離を意識した設計をすると見通しの良い画面の実装ができるため、Enhanced Scrollerを使用する際は意識してみると良いかもしれません。

Enhanced ScrollerのイベントをUniRxで扱う

先ほど挙げたサンプルコードの中の、Enhanced Scrollerのイベントを利用する部分に関して補足をします。

Enhanced Scrollerの提供するイベントはどれも delegete 型で、そのままではUniRxのStreamとしては扱うことができません。

ですがMV(R)PでUniRxを活かした実装をする時には、これらのイベントもStreamとして扱いたくなります。

この場面で便利なのが、UniRxの Observable.FromEvent です。

実際に EnhancedScroller.CellViewVisibilityChanged のイベントを IObservable<T> に変換する拡張メソッドとして定義したものがこちらになります。

// CellViewVisibilityChangedDelegate -> IObservable<TCellView>
public static IObservable<TCellView> CellViewVisibilityChangedAsObservable<TCellView>(this EnhancedUI.EnhancedScroller.EnhancedScroller scroller)
            where TCellView : EnhancedScrollerCellView
    => Observable
            .FromEvent<CellViewVisibilityChangedDelegate, TCellView>(
                conversion:h => cellView => h.Invoke(cellView as TCellView),
                addHandler:h => scroller.cellViewVisibilityChanged += h,
                removeHandler:h => scroller.cellViewVisibilityChanged -= h);

パラメータの指定部分が型パズルみたいなことになっていてやや複雑ではあるのですが、FromEvent<T1, T2> の型引数がそれぞれ元の delegate、変換後のStreamの扱う型に対応していることが分かります。

Enhanced Scrollerの他のイベントも同様の実装が可能です。

Push to Refresh、Paginationをするときに便利な拡張メソッドも紹介します。

// ScrollerScrolledDelegate -> IObservable<(Vector2 value, float scrollPosition)>
public static IObservable<(Vector2 value, float scrollPosition)> ScrollerScrolledAsObservable(this EnhancedUI.EnhancedScroller.EnhancedScroller scroller)
    => Observable
        .FromEvent<ScrollerScrolledDelegate, (Vector2 value, float scrollPosition)>(
            conversion:h => (enhancedScroller, value, scrollPosition) => h.Invoke((value, scrollPosition)),
            addHandler:h => scroller.scrollerScrolled += h,
            removeHandler:h => scroller.scrollerScrolled -= h);

// Scrollの上端に到達した時にイベントを発行する
public static IObservable<Unit> ScrollerReachedUpperLimit(this EnhancedScroller scroller)
    => scroller
        .ScrollerScrolledAsObservable()
        .Where(value => value.value.y >= 1f) // 0(下) <= value.y <= 1(上)が通常のScrollの可動域
        .Select(_ => Unit.Default);

// Scrollの下端に到達した時にイベントを発行する
public static IObservable<Unit> ScrollerReachedLowerLimit(this EnhancedScroller scroller)
    => scroller
        .ScrollerScrolledAsObservable()
        .Where(value => value.value.y <= 0f)
        .Select(_ => Unit.Default);

// Scrollの上端より下に引っ張って放した時にイベントを発行する
public static IObservable<Unit> EndDraggedOverUpperLimit(this EnhancedScroller scroller)
{
    var endDragStream = GetOrAddComponent<ObservableEndDragTrigger>(scroller.gameObject)
        .OnEndDragAsObservable()
        .Select(_ => Unit.Default);

    return Observable
        .Zip(scroller.ScrollerReachedUpperLimit(), endDragStream) // Streamの合成をして、OnEndDragとScrollerReachedUpperLimitが両方呼ばれている時にイベントを発行する
        .Select(_ => Unit.Default);
}

Zipの挙動の把握があまり自信がないため、もし使い方が間違っていたらご指定いただけるとありがたいです。

これらのような拡張メソッドを用意しておくことで、Enhanced ScrollerとUniRxを組み合わせた際のイベント周りの実装がより快適になると思います。

おわりに

Enhanced Scrollerが便利なのはもとより、UniRxを使ったMV(R)Pパターンを用いることでデータやロジックの見通しが良くなり、イベントの取り回しも快適になることが伝わりましたでしょうか。

ModelのUnit Testの実装も容易なため、データやロジックが複雑な場面ではより効果が大きいと思います。

あくまで一実装例に過ぎませんが、Enhanced Scrollerの使用例、MV(R)Pパターンの応用例としても参考になれば幸いです。

*1:正確にはEnhanced Scrollerの内部ではScrollRectを使用しているのですが