【Unity入門】タブグループを使ったコンテンツの切り替えを実装しよう！【UI】

**[グレンジ Advent Calendar 2023](https://qiita.com/advent-calendar/2023/grenge)** 21日目担当のmikuri8です。
グレンジではUIの設計、基盤実装などをメインに担当しています。

今回は昨日の記事で作成したタブグループを用いてタブ操作によるコンテンツの切り替え実装を行います。
トグルボタンの実装はこちら

https://qiita.com/mikuri8/items/a546adb2451140167ce5




タブグループの実装はこちら



https://qiita.com/mikuri8/items/cb8318622e5e67607e23



## タブ操作によるコンテンツ切り替えについて
タブ操作によるコンテンツ切り替えについては昨日作成したタブグループと同じように下記の動作が求められます。
- 選択したタブと紐づくコンテンツがアクティブな状態になる
- 前回選択していたタブに紐づくコンテンツが非アクティブな状態になる

しかし、コンテンツの表示切り替えという点においては汎用的なクラスを作成するのが難しい場面が多いです。具体的には、コンテンツのアクティブ、非アクティブな状態が単純な表示、非表示ではない場合などです。このような場面に対応できるように切り替えという動作が行われたときに発火するコールバックを扱うようなクラスを作成します。

## 設計方針
前回作成したToggleButtonGroupで切り替え処理が行われたときに発火するコールバックを持ったTabContentsChangerクラスを作成します。
TabContentsChangerはジェネリック型のクラスとし、MonoBehaviourを継承したViewのアクティブと非アクティブを切り替え処理にアクセスできるようにします。このようにしてアクティブ切り替えの処理の実態が何であれ汎用的に対応することが可能です。

## 実装
```c#:ToggleButtonGroup.cs
using System;
using System.Collections.Generic;
using UniRx;
using UnityEngine;

namespace Sample
{
    public class ToggleButtonGroup : MonoBehaviour
    {
        [SerializeField] private List<ToggleButton> _toggleButtons = new();

        private ToggleButton _selectedButton;

        // 追記部分
        public IObservable<Unit> OnClickAsObservable(int index)
        {
            if (index >= _toggleButtons.Count)
            {
                return Observable.Empty<Unit>();
            }
            return _toggleButtons[index].OnClickAsObservable();
        }
        
        private void Awake()
        {
            Initialize();
        }

        private void Initialize()
        {
            foreach (var one in _toggleButtons)
            {
                one.OnStateChangedAsObservable().Subscribe(
                    state =>
                    {
                        if (state == ToggleButton.State.Selected)
                        {
                            if (_selectedButton != null)
                            {
                                _selectedButton.IsManaged = false;
                                _selectedButton.SwitchToggleState();
                            }

                            one.IsManaged = true;
                            _selectedButton = one;
                        }
                    }).AddTo(this);
            }
        }
        
        public void SelectToggleIndex(int index)
        {
            if (0 <= index && _toggleButtons.Count > index)
            {
                _toggleButtons[index].SwitchToggleState();
            }
        }

        public int GetSelectedIndex()
        {
            int index = 0;
            for (var i = 0; i < _toggleButtons.Count; i++)
            {
                if (_toggleButtons[i] == _selectedButton)
                {
                    index = i;
                    break;
                }
            }

            return index;
        }
    }
}
```

```c#:TabContentsChanger.cs
using System;
using System.Collections.Generic;
using UniRx;
using UnityEngine;

namespace Sample
{
    public class TabContentsChanger<TView, TKind>
        where TView : MonoBehaviour
        where TKind : Enum 
    {
        [Serializable]
        public class ContentHolder
        {
            public TView View;
            public TKind Kind;

            public bool IsSelect { get; private set; }

            public void SetSelect(bool select)
            {
                IsSelect = select;
            }
        }
        
        public List<ContentHolder> Contents;
        [SerializeField]
        private ToggleButtonGroup _toggleButtonGroup;

        private Subject<(ContentHolder, bool)> _onChangeStateSubject = new();
        public IObservable<(ContentHolder content, bool active)> OnChangeStateAsObservable => _onChangeStateSubject;
        
        public void Initialize()
        {
            for (int i = 0; i < Contents.Count; i++)
            {
                var content = Contents[i];
                _toggleButtonGroup.OnClickAsObservable(i)
                                  .Subscribe(
                                      _ =>
                                      {
                                          foreach (var one in Contents)
                                          {
                                              bool active = one.Kind.Equals(content.Kind);
                                              _onChangeStateSubject.OnNext((one, active));
                                              one.SetSelect(active);
                                          }
                                      }).AddTo(content.View);
            }
        }

        /// <summary>
        /// 指定インデックスの選択
        /// </summary>
        public void Select(int index)
        {
            _toggleButtonGroup.SelectToggleIndex(index);
            for (int i = 0; i < Contents.Count; i++)
            {
                Contents[i].SetSelect(i == index);
                _onChangeStateSubject.OnNext((Contents[i], i == index));   
            }
        }

        /// <summary>
        /// 現在アクティブなContentHolderの取得
        /// </summary>
        public ContentHolder GetActiveContent()
        {
            return Contents.Find(content => content.IsSelect);
        }
    }
}
```

## 解説
まずは使用箇所での実装例を記載します。
```c#
using System;
using UniRx;
using UnityEngine;

namespace Sample
{
    public class ToggleSample : MonoBehaviour
    {
        public enum TabContentsKind
        {
            A,
            B,
            C
        }

        [Serializable]
        public class SampleTabContentsChanger : TabContentsChanger<TabContentsSample, TabContentsKind>
        {
        }
        
        [SerializeField]
        private SampleTabContentsChanger _tabContentsChanger;
        
        void Start()
        {
            _tabContentsChanger.Initialize();
            _tabContentsChanger.OnChangeStateAsObservable
                               .Subscribe(
                                   value =>
                                   {
                                       value.content.View.SetActive(value.active);
                                   }).AddTo(this);
            _tabContentsChanger.Select(0);
        }
    }
}
```

TabContentsChangerはジェネリッククラスなのでシリアライズ用にSampleTabContentsChangerとしてクラスを作成しています。ここで扱うViewとそれに紐づくenumを定義します。今回ViewとしたものはSetActiveメソッドでアクティブの切り替えを行うTabContentsSampleクラスです。

```c#:TabContentsSample.cs
using UnityEngine;

namespace Sample
{
    public class TabContentsSample : MonoBehaviour
    {
        public void SetActive(bool isActive)
        {
            gameObject.SetActive(isActive);
        }
    }
}
```

SampleTabContentsChangerクラスを作成したことでSerializeFieldとしてInspectorから対象の設定ができるようになります。このときToggleButtonGroupで設定したインデックスとTabContentsChangerでのインデックスがずれないように注意してください。

![スクリーンショット 2023-12-18 16.40.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20374/c7c7f139-7fd0-9f07-09c5-375b9595dd3c.png)
![スクリーンショット 2023-12-18 16.41.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20374/2bbe8a9b-cb5a-b11c-566d-d2bddfea8c04.png)

コンテンツとenumを紐づけているのはUI作成時に表示する種別とenumを紐付ける場合が多いからです。スマートフォンゲームでよくある装備画面で一覧と強化と売却を切り替えるようなときに内部的に現在どの状態かを保持するためにenumを使っているようなパターンを想像してもらえるといいと思います。

今回、ToggleButtonGroupに`OnClickAsObservable(int index)`というメソッドを追加しました。TabContentsChangerに作成したContentHolder内にToggleButtonの参照を持たせるようなパターンもよく見かけますが、ToggleButtonGroupという管理クラスを飛び越えて管理されているToggleButtonの参照を持つことになってしまいます。こうなると、ToggleButtonGroupで自分の知らないところからToggleButtonを操作されてしまい、ToggleButtonGroupの思い通りに動作しないようなバグが発生する恐れがあります。

クリックしてみるとタブとコンテンツが切り替わることがわかります。
![contents.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20374/37aabd7b-a0a2-794d-e00b-38e638cffd10.gif)

## まとめ
前回作成したToggleButtonGroupを用いてコンテンツの切り替えを実装しました。ジェネリッククラスを使うことで汎用的に使用できることもイメージできたかなと思います。TabContentsChangerがコンポーネントではない点も特殊な扱い方かなと感じますが、UI操作の一つの解として参考にしていただければと思います。3日間にわたりありがとうございました！