【Unity入門】タブグループを作ろう！【UI】

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

今回は昨日に引き続き、2つの状態の見た目を切り替える機能を持ったトグルボタンを複数並べたタブグループを作成します。
トグルボタンについては下記をご覧ください！

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

また、明日はタブグループを使用したコンテンツの切り替え実装例を執筆予定なのでお楽しみに！



## タブグループについて
昨日は2つの状態の見た目を切り替える機能を持ったトグルボタンを作成しました。このトグルボタンを複数並べることによってタブの挙動を実装することができます。
タブの挙動に求められることは下記です。
- 選択したタブがアクティブな状態になる
- 前回選択していたタブが非アクティブな状態になる

これをトグルボタンのリストを管理するクラスを作成することで実現します。
今回はタブとしていますが、複数の選択肢から一つを選ぶようなラジオボタンも同様の挙動なので同じように実装することができます。

## 設計方針
前回作成したToggleButtonのリストを管理するToggleButtonGroupクラスを作成し、ToggleButtonGroupで管理されているToggleButtonの一つが選択状態になったら前回選択状態だったものを通常状態に戻すという処理を行います。
また、タブグループでは「選択状態のものをクリックして通常状態に戻す」という処理はないので、ToggleButtonに状態変化をさせないためのフラグを追加してToggleButtonGroupから設定できるようにします。
タブグループを扱いたい場合は各ToggleButtonの参照は持たず、ToggleButtonGroupだけ知っていれば良いようにします。そうすることで余計な参照を省くことができます。

## 実装
```c#:ToggleButton.cs
using System;
using UniRx;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

namespace Sample
{
    public class ToggleButton : Button
    {
        public enum State
        {
            Default,
            Selected,
        }
        
        [SerializeField] private GameObject _defaultObject;
        [SerializeField] private GameObject _selectedObject;
        
        private ReactiveProperty<State> _state = new();
        private Subject<State> _onStateChanged = new();

        // 追記部分
        public bool IsManaged { get; set; }

        /// <summary>
        /// ステート変更時発火するイベント
        /// </summary>
        public IObservable<State> OnStateChangedAsObservable()
        {
            return _onStateChanged.AsObservable();
        }
        
        private void Awake()
        {
            Initialize();
        }
        
        private void Initialize()
        {
            // ボタンの内部で状態変化時に行うこと
            _state.Subscribe(
                state =>
                {
                    _defaultObject.SetActive(state == State.Default);
                    _selectedObject.SetActive(state == State.Selected);
                    
                    _onStateChanged.OnNext(state);
                }).AddTo(this);

            _state.Value = State.Default;
        }
        
        public override void OnPointerClick(PointerEventData eventData)
        {
            base.OnPointerClick(eventData);

            SwitchToggleState();
        }
        
        public void SwitchToggleState()
        {
            // 追記部分
            if (interactable && !IsManaged)
            {
                switch (_state.Value)
                {
                    case State.Default:
                        _state.Value = State.Selected;
                        break;
                    case State.Selected:
                        _state.Value = State.Default;
                        break;
                }
            }
        }
    }
}
```

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

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

        private ToggleButton _selectedButton;

        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;
        }
    }
}
```

## 解説
処理は単純で各ToggleButtonの状態変化時のObservableで**選択状態になったら前の選択状態だったものを取り消す**というようにしています。
選択状態になったときに、「選択状態のものをクリックして通常状態に戻す」をさせないためにIsManagedのフラグを立ててクリックでの状態変化を止めています。

Hierarchyでの構成例は下記のようになります。
![スクリーンショット 2023-12-15 17.25.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20374/43ba5320-3a1e-e20d-63e5-286cef9059ec.png)
![スクリーンショット 2023-12-15 17.25.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20374/362947ab-223b-f29f-9391-07ab2a1fffc2.png)


クリックしてみるとタブが切り替わることがわかります。
![tab.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20374/fd46da6b-d77b-092f-b2a7-cae3d9c041da.gif)

使用箇所では最初にアクティブにするToggleButtonのインデックス設定のみを行っています。
```c#
using UniRx;
using UnityEngine;

namespace Sample
{
    public class ToggleSample : MonoBehaviour
    {
        [SerializeField]
        private ToggleButtonGroup _toggleButtonGroup;
        
        void Start()
        {
            _toggleButtonGroup.SelectToggleIndex(0);
        }
    }
}
```

## まとめ
前回作成したトグルボタンを使用した実装例として汎用的なUIであるタブグループを作成しました。管理するクラスを用いることでボタン自体は隠蔽することができ、想定外の処理を設定されるようなことを防ぐことができます。
明日は今回の実装を更に使った例としてタブグループによるコンテンツの切り替え処理を行うので是非ご覧ください！