LoginSignup
15
15

【Unity】汎用的に扱えるボタンの個人的な実装アプローチの紹介

Last updated at Posted at 2023-12-01

本記事は、Unityゲーム開発者ギルド Advent Calendar 2023 2日目の記事です。
Unityゲーム開発者ギルド(以下、UGDG)については、下記リンクをご覧ください。UGDGはいいぞ!

1. はじめに

皆さんはボタン周りの実装を行う際、どのようなアプローチを取っていますか?

既存のButton(UI)を使ってInspector上でクリック時の呼び出しを設定したり、
スクリプトからイベント購読したりと方法は多岐にわたると思います。

本記事では、Unityにおいて汎用的に扱えるボタンの個人的な実装アプローチを紹介します。
ゲームの仕様によっては上手く当てはまらない場合や、より良いアプローチがある可能性があるため、記事の内容を鵜呑みにせず参考にして頂けると幸いです。

なお、UIパーツに関する用語については以下記事を参考にしています。

今回作るもの

今回は以下3種類のUIパーツの実装アプローチを紹介します。

  • 汎用ボタン
  • 汎用ラジオボタン(選択肢のいずれか1つを選択出来る機能)
  • 汎用チェックボックス(選択肢を複数選択出来る機能)

1つ目の「汎用ボタン」がメインで、残りの2つはおまけです。

最終的に出来上がるクラス図

最終的には以下のような構成になります。
一部publicレベルとすべてのprivateレベルの内容は隠している不完全なクラス図です。

コードは全て記事後半にて紹介しているのでここはさらっと流して問題ありません。

CommonViewParts.png

(これで理解出来てしまったら多分もうこの記事から得られるものはありません...)

環境

2. 実装方針

大まかな方針としては、ボタンを扱うクラスを入力・イベント発行部分見た目・演出部分に分けて実装します。ボタンも既存のものではなく、Imageを使用します。

button.drawio.png

入力・イベント発行部分には、

  • ボタンを押下などのプレイヤーからの入力受け取り
  • ボタンに対してどのような操作が行われたかをイベントとして発行する機能

見た目・演出部分には、

  • 入力・イベント発行部分が発行するイベントを購読して見た目を切り替えたり、演出を与えたりする機能(ロジックには影響を与えない)

を持たせます。

なお外部からは、同様にボタンの入力イベントを購読することで、ボタンを押した時のロジック処理を各々実装していく形になります。

なぜ機能(責務)を分けるのか

どうしてわざわざボタンの役割を2つに分離したのかについて軽く話します。

このアプローチには少なくとも2つのメリットがあります。
それは、

  • ボタンの見た目や演出を変えたくなった時に柔軟に切り替えられる
  • 仮置きのボタンでもボタン使用側は気にせず実装を進められる

ところにあります。

1つにまとめる欠点

基本的にボタンが押されたときの演出は同じであることが多いですが、一部特殊な場面で異なった演出を行いたいときなど、ボタン押下に対して複数種類の演出を与えたいこともあります。

演出Aでは、ボタン押下時にボタンUIが小さくなる
演出Bでは、ボタン押下時にボタンUIを爆発させる  などなど...

しかしそのような時、ボタンに関する機能を1つにまとめてしまっていると、
演出の種類ごとにクラスを切り分ける必要ができ、外部から使用するときにボタンのクラスを使い分けなければならないという問題が発生します。

button_func_only_1.png

これはつまり、ボタンを使う側(外部側)がどの演出のボタンであるかを意識する必要があるということでもあります。

「ボタンの演出を後から切り替えたい!」となった時にこの実装だと、使う側のクラスの中身を書き替える必要があり、あまり好ましい実装とは言えません。

機能を分離して扱いやすく

そこで、見た目・演出部分を分離させました。

ボタンを使う側にとっては、ボタンがクリックされたかといったイベントを受け取ることが出来れば十分です。つまり、外部にはそのイベント情報のみが分かればよいのです。

button_func_2.png

これにより、外部からはどの演出のボタンであるかといった意識をする必要は無くなり、仮置きのボタンUIでも気にせず外部の実装を進めることが出来ます。

また、ボタンを見た目や演出を差し替えるときも演出を管理するクラスを切り替えるだけで済むようになります。

3. 汎用ボタンの実装

まずはボタン入力周りのイベントを公開するクラスを作成します。
これが、2. 実装方針における入力・イベント発行部分にあたる実装です。

また、ライブラリとしてUniRxを導入しています。

CustomButton.cs
using System;
using UniRx;
using UniRx.Triggers;
using UnityEngine;

namespace CommonViewParts
{
    /// <summary>
    /// 汎用ボタン
    /// </summary>
    [RequireComponent(typeof(ObservableEventTrigger))]
    public class CustomButton : MonoBehaviour
    {
        private ObservableEventTrigger _observableEventTrigger;

        /// <summary>
        /// ボタンクリック時
        /// </summary>
        public IObservable<Unit> OnButtonClicked => _observableEventTrigger
            .OnPointerClickAsObservable().AsUnitObservable().Where(_ => _isActiveRP.Value);
        
        /// <summary>
        /// ボタンを押した時
        /// </summary>
        public IObservable<Unit> OnButtonPressed => _observableEventTrigger
            .OnPointerDownAsObservable().AsUnitObservable().Where(_ => _isActiveRP.Value);
        
        /// <summary>
        /// ボタンを離した時
        /// </summary>
        public IObservable<Unit> OnButtonReleased => _observableEventTrigger
            .OnPointerUpAsObservable().AsUnitObservable().Where(_ => _isActiveRP.Value);
        
        /// <summary>
        /// ボタンの領域にカーソルが入った時
        /// </summary>
        public IObservable<Unit> OnButtonEntered => _observableEventTrigger
            .OnPointerEnterAsObservable().AsUnitObservable().Where(_ => _isActiveRP.Value);
        
        /// <summary>
        /// ボタンの領域からカーソルが出た時
        /// </summary>
        public IObservable<Unit> OnButtonExited => _observableEventTrigger
            .OnPointerExitAsObservable().AsUnitObservable().Where(_ => _isActiveRP.Value);

        /// <summary>
        /// ボタンのアクティブ状態を保持するReactiveProperty
        /// </summary>
        public IReadOnlyReactiveProperty<bool> IsActiveRP => _isActiveRP;
        
        private readonly ReactiveProperty<bool> _isActiveRP = new(true);

        protected virtual void OnDestroy()
        {
            _isActiveRP.Dispose();
        }

        protected virtual void Awake()
        {
            _observableEventTrigger = GetComponent<ObservableEventTrigger>();
        }

        /// <summary>
        /// ボタンのアクティブ状態を取得する
        /// </summary>
        public bool GetIsActive() => _isActiveRP.Value;

        /// <summary>
        /// アクティブ状態を変更する
        /// </summary>
        public void SetActive(bool isActive)
        {
            _isActiveRP.Value = isActive;
        }
    }
}

長く書いてますが簡潔に説明すると、汎用ボタンでは、

  • ボタンクリック時
  • ボタン押下・解放時
  • ボタン領域へ入った・出た時
  • ボタンのアクティブ状態が変化した時

以上のタイミングでそれぞれのイベントを発行しています。
非アクティブ時にはイベントを発行しません。

これらを外部から購読することでボタンクリック処理を実装します。

ボタンクリック処理の実装例

ここでは例として2つのボタンを用意し、
クリックしたらそれぞれのDebug.Log()が呼び出される仕組みを作ります。

HogeWindowView.cs
using CommonViewParts;
using UniRx;
using UnityEngine;

namespace Debugs
{
    /// <summary>
    /// 何かのウィンドウのViewクラスがあったとする
    /// そして2つのボタンを持っていたとする
    /// </summary>
    public class HogeWindowView : MonoBehaviour
    {
        [SerializeField] private CustomButton _startButton;
        [SerializeField] private CustomButton _optionButton;
        
        private void Start()
        {
            _startButton.OnButtonClicked
                .Subscribe(_ => Debug.Log("Start!"))
                .AddTo(this.gameObject);
            
            _optionButton.OnButtonClicked
                .Subscribe(_ => Debug.Log("Option!"))
                .AddTo(this.gameObject);
        }
    }
}

Start()内にて、それぞれのCustomButtonがクリックされたときのイベントを購読しています。


また、クリックやタッチの入力検知のためにUniRx.TriggersObservableEventTriggerを使用して入力イベントを取得しています。

このクラスはUnityEngine.EventSystemsIPointerClickHandlerなどのインターフェースを実装しています。

これらインターフェースに関する詳細は公式ドキュメントをご覧ください。

見た目・演出部分

続いて、見た目・演出部分にあたるクラスを作成します。
演出の与え方が複数あるのであれば、それぞれでクラスを用意します。

今回は以下の仕様に沿ったボタンUIを作ってみます。

  • ボタン押下時にボタンUIのScale0.9倍
  • ボタン解放時にScaleは元に戻る
  • ボタン無効時には不透明度(alpha)が 50% になる
ScaledCommonButtonView.cs
using UniRx;
using UnityEngine;
using UnityEngine.UI;

namespace CommonViewParts
{
    /// <summary>
    /// 選択すると拡縮するようなボタンView
    /// </summary>
    [RequireComponent(typeof(CustomButton))]
    public class ScaledCommonButtonView : MonoBehaviour
    {
        private const float DefaultScale = 1f;
        private const float PressedScale = 0.9f;
        
        private const float ActiveImageAlpha = 1f;
        private const float InactiveImageAlpha = 0.5f;
        
        private CustomButton _button;
        [SerializeField] private Image _image;

        private void Start()
        {
            _button = GetComponent<CustomButton>();
            
            _button.OnButtonPressed
                .Subscribe(_ => SetScale(PressedScale))
                .AddTo(this.gameObject);

            _button.OnButtonReleased
                .Subscribe(_ => SetScale(DefaultScale))
                .AddTo(this.gameObject);
            
            _button.IsActiveRP
                .Subscribe(SetButtonActive)
                .AddTo(this.gameObject);
        }

        private void SetScale(float scale)
        {
            _image.rectTransform.localScale = Vector3.one * scale;
        }

        private void SetButtonActive(bool isActive)
        {
            float alpha = isActive ? ActiveImageAlpha : InactiveImageAlpha;
            _image.color = new Color(1, 1, 1, alpha);
        }
    }
}

Start()に直書きでCustomButtonのイベントのうち「押下時」「解放時」「アクティブ状態変化時」の3種類を購読し、その際に行う内容をSubscribe()の中に記述しています。

また、このクラスを使うにあたってCustomButtonは必須であるため、RequireComponent属性を用いてコンポーネント追加時にCustomButtonコンポーネントが同時に追加されるようにしています。

今回はこの3種類ですが、カーソルがUIに入った時に演出を加えたいのであれば_button.OnButtonEntered_button.OnButtonExitedのイベントを購読し、その時の演出を追加実装してあげると良いでしょう。

もし、ここからさらにAnimatorを用いてボタンUIをアニメーションさせたいという要望が出た場合でも、それ用のクラスを別で用意すれば良いですし、演出クラスを差し替えても特にCustomButtonを使う外部に影響を与えることも無く済みます。


最後に、このCustomButtonScaledCommonButtonViewImageコンポーネントを持つGameObjectに追加し、コンポーネントのアタッチをすれば、以下動画のようなものが出来上がります。

ezgif.com-video-to-gif (1).gif

(左:非アクティブ 右:アクティブ)(素材は過去に作ったもの)

これで、汎用的に扱えるボタンの実装が出来ました。

4. 汎用ラジオボタンの実装

ここからは、汎用ボタンの応用した扱い方を紹介するおまけです。

今回は前節で作成したCustomButtonを用いてラジオボタンを実装します。
ラジオボタンとは、複数の選択肢の中から1つだけを選択出来るようなUIパーツです。

そのため、ボタンに新たに「選択されているか」という状態を持たせます。
しかし、通常のボタンに選択状態は不要な機能なので、CustomButtonを継承した選択可能なボタンクラスを新たに作成します。

CustomButtonを継承するのではなく、新しいクラスに保持させるのも良いと思いますが、
今回は継承を用いたアプローチで紹介します。

CustomSelectableButton.cs
using UniRx;

namespace CommonViewParts
{
    /// <summary>
    /// 選択状態を保持できるボタン
    /// </summary>
    public class CustomSelectableButton : CustomButton
    {
        /// <summary>
        /// 選択状態かどうかを示すReactiveProperty
        /// </summary>
        public IReadOnlyReactiveProperty<bool> IsSelectedRP => _isSelectedRP;
        private readonly ReactiveProperty<bool> _isSelectedRP = new();

        protected override void OnDestroy()
        {
            base.OnDestroy();
            _isSelectedRP.Dispose();
        }

        /// <summary>
        /// 選択状態をセットする
        /// </summary>
        public void SetSelected(bool isSelected)
        {
            _isSelectedRP.Value = isSelected;
        }
    }
}

CustomButtonを継承して、新たにIsSelectedRPという選択状態が変化した時にイベントが発行される機能を追加しました。

見た目・演出部分の実装について

前節にて、CustomButtonを用いる際の見た目・演出部分の実装としてScaledCommonButtonViewを作成しましたが、ここではそのCustomSelectableButton版を作成します。

以下は、選択時に色が赤色に変化するだけの処理を行っています。

CommonSelectableButtonView.cs
using UniRx;
using UnityEngine;
using UnityEngine.UI;

namespace CommonViewParts
{
    /// <summary>
    /// 選択可能なボタンのView
    /// </summary>
    [RequireComponent(typeof(CustomSelectableButton))]
    public class CommonSelectableButtonView : MonoBehaviour
    {
        private static readonly Color s_selectedColor = Color.red;
        private static readonly Color s_unselectedColor = Color.white;
        
        private CustomSelectableButton _button;
        [SerializeField] private Image _image;

        private void Start()
        {
            _button = GetComponent<CustomSelectableButton>();
            
            // 今回は選択時に色を変えてみる
            _button.IsSelectedRP
                .Subscribe(SetColor)
                .AddTo(this.gameObject);
        }
        
        private void SetColor(bool isActive)
        {
            Color color = isActive ? s_selectedColor : s_unselectedColor;
            _image.color = color;
        }
    }
}

これらを用いてラジオボタンの実装を行います。
なお、ラジオボタンのコンポーネントをアタッチするGameObjectの親子関係は以下のようになります。

  • ラジオボタン - CustomRadioButton
    • 選択肢ボタンA - CustomSelectableButton
    • 選択肢ボタンB - CustomSelectableButton
    • 選択肢ボタンC - CustomSelectableButton
CustomRadioButton.cs
using System;
using UniRx;
using UnityEngine;

namespace CommonViewParts
{
    /// <summary>
    /// 複数のボタンから1つだけを選択できるもの(ラジオボタン)
    /// </summary>
    public class CustomRadioButton : MonoBehaviour
    {
        [Header("それぞれの選択肢ボタン")]
        [SerializeField] private CustomSelectableButton[] _buttons = Array.Empty<CustomSelectableButton>();

        private int _beforeSelectedButtonIndex;
        
        /// <summary>
        /// ボタンがアクティブ状態になった時に対象indexを返す
        /// </summary>
        public IObservable<int> OnButtonActivated => _buttonActivatedSubject;
        private readonly Subject<int> _buttonActivatedSubject = new();

        private void OnDestroy()
        {
            _buttonActivatedSubject.Dispose();
        }

        /// <summary>
        /// 初期化処理を実行する
        /// </summary>
        public void Initialize(int startIndex)
        {
            if (startIndex >= _buttons.Length)
            {
                throw new IndexOutOfRangeException();
            }
            
            Activate(startIndex);
            _beforeSelectedButtonIndex = startIndex;
            
            SetEvent();
        }

        /// <summary>
        /// スクリプトからボタン情報を設定するときに使用する初期化処理
        /// </summary>
        public void Initialize(CustomSelectableButton[] buttons, int startIndex)
        {
            _buttons = buttons;
            Initialize(startIndex);
        }
        
        /// <summary>
        /// 対象のボタンが選択されているかどうか
        /// </summary>
        public bool GetIsSelected(int targetIndex)
        {
            return _buttons[targetIndex].IsSelectedRP.Value;
        }
        
        /// <summary>
        /// 現在選択されているボタンのindexを取得する
        /// </summary>
        public int GetSelectedButtonIndex()
        {
            return _beforeSelectedButtonIndex;
        }

        private void SetEvent()
        {
            for (int i = 0; i < _buttons.Length; i++)
            {
                int index = i;
                _buttons[index].OnButtonClicked
                    .Subscribe(_ => OnAnyButtonSelected(index))
                    .AddTo(this.gameObject);
            }
        }

        private void OnAnyButtonSelected(int selectedIndex)
        {
            Deactivate(_beforeSelectedButtonIndex);
            Activate(selectedIndex);
            _beforeSelectedButtonIndex = selectedIndex;
        }

        private void Activate(int targetIndex)
        {
            _buttons[targetIndex].SetSelected(true);
            _buttonActivatedSubject?.OnNext(targetIndex);
        }

        private void Deactivate(int targetIndex)
        {
            _buttons[targetIndex].SetSelected(false);
        }

#if UNITY_EDITOR
        private void OnValidate()
        {
            if (_buttons.Length == 0)
            {
                _buttons = transform.GetComponentsInChildren<CustomSelectableButton>();
            }
        }
#endif
    }
}

このクラスは使用する前に初期化処理としてInitialize()を呼び出す必要があります。

CustomSelectableButtonの配列を用いてボタンの管理を行い、OnValidate()で選択肢ボタンのコンポーネントを自動的にアタッチさせます。

そして選択されていないボタンがクリックされると、そのindex値を引数に持ったイベントが発行されます。また、対象のindex値のボタンが選択されているか、あるいは今選択されているボタンのindexは何かを取得することも出来ます。

これで以下GIF動画のようにボタンを選択出来るようになりました。

ezgif.com-video-to-gif (2).gif

indexで選択情報を返すことについて

indexでどれが選択されたかを管理しているという点については、コードが読みづらくなるという観点から、自分でも完全に納得は出来ていません。(誰か良案があれば教えて頂けると幸いです!)

一応はInitialize()を用いて初期化する際に、オーバーロードとしてCustomSelectableButtonの配列を引数に与えることでボタンの種類の管理を外部に譲るという方法を取れるような実装にしてあります。

また、別のアプローチとして、以下のようにenumを定義して各indexと関連付けすることで、外部から利用する際に各選択肢をenumで管理出来るようにする方法もあります。

private void Start()
{
    _radioButton.OnButtonActivated
        .Subscribe(index => LogFruitType((FruitType) index))
        .AddTo(this.gameObject);
}

private void LogFruitType(FruitType fruitType)
{
    Debug.Log($"{fruitType}が選択されました");
}

private enum FruitType
{
    Apple = 0,
    Orange = 1,
    Lemon = 2,
}

index = 1なら、Orangeが選択されましたと出力されます)

5. 汎用チェックボックスの実装

最後はチェックボックスの機能を実装します。
チェックボックスとは、複数の選択肢から複数を選択出来るUIパーツです。

ラジオボタンと同様に、CustomSelectableButtonを用いて実装します。
また、GameObjectの親子関係も同様になります。

  • チェックボックス - CustomCheckbox
    • 選択肢ボタンA - CustomSelectableButton
    • 選択肢ボタンB - CustomSelectableButton
    • 選択肢ボタンC - CustomSelectableButton
CustomCheckbox.cs
using System;
using System.Collections.Generic;
using UniRx;
using UnityEngine;

namespace CommonViewParts
{
    /// <summary>
    /// 複数のボタンから複数選択できるもの(チェックボックス)
    /// </summary>
    public class CustomCheckbox : MonoBehaviour
    {
        [Header("それぞれの選択肢ボタン")]
        [SerializeField] private CustomSelectableButton[] _buttons = Array.Empty<CustomSelectableButton>();

        private bool[] _activeIndexArray;

        /// <summary>
        /// ボタンがアクティブ状態になった時に対象indexを返す
        /// </summary>
        public IObservable<int> OnButtonActivated => _buttonActivatedSubject;
        
        /// <summary>
        /// ボタンが非アクティブ状態になった時に対象indexを返す
        /// </summary>
        public IObservable<int> OnButtonDeactivated => _buttonDeactivatedSubject;
        
        private readonly Subject<int> _buttonActivatedSubject = new();
        private readonly Subject<int> _buttonDeactivatedSubject = new();

        private void OnDestroy()
        {
            _buttonActivatedSubject.Dispose();
            _buttonDeactivatedSubject.Dispose();
        }

        /// <summary>
        /// 初期化処理を実行する
        /// </summary>
        public void Initialize()
        {
            _activeIndexArray = new bool[_buttons.Length];
            SetEvent();
        }

        /// <summary>
        /// スクリプトからボタン情報を設定するときに使用する初期化処理
        /// </summary>
        public void Initialize(CustomSelectableButton[] buttons)
        {
            if (buttons.Length == 0)
            {
                throw new ArgumentOutOfRangeException();
            }
        
            _buttons = buttons;
            Initialize();
        }

        /// <summary>
        /// 対象のボタンのアクティブ状態をセットする
        /// </summary>
        public void SetButtonActive(int targetIndex, bool isActive)
        {
            _activeIndexArray[targetIndex] = isActive;
            _buttons[targetIndex].SetSelected(isActive);

            if (isActive)
            {
                _buttonActivatedSubject?.OnNext(targetIndex);
            }
            else
            {
                _buttonDeactivatedSubject?.OnNext(targetIndex);
            }
        }
        
        /// <summary>
        /// それぞれの選択肢が選択されているかのリストを返す
        /// </summary>
        public IReadOnlyList<bool> GetIsButtonSelectedList()
        {
            return Array.AsReadOnly(_activeIndexArray);
        }

        /// <summary>
        /// 対象のボタンが選択されているかどうか
        /// </summary>
        public bool GetIsSelected(int targetIndex)
        {
            return _activeIndexArray[targetIndex];
        }

        private void SetEvent()
        {
            for (int i = 0; i < _buttons.Length; i++)
            {
                int index = i;
                _buttons[index].OnButtonClicked
                    .Subscribe(_ => SwitchButtonActive(index))
                    .AddTo(this.gameObject);
            }
        }

        private void SwitchButtonActive(int targetIndex)
        {
            SetButtonActive(targetIndex, !_activeIndexArray[targetIndex]);
        }
        
#if UNITY_EDITOR
        private void OnValidate()
        {
            if (_buttons.Length == 0)
            {
                _buttons = transform.GetComponentsInChildren<CustomSelectableButton>();
            }
        }
#endif
    }
}

このクラスも使用する前に初期化処理としてInitialize()を呼び出す必要があります。

CustomCheckboxでは、「ボタンが選択された時」と「ボタンの選択が解除された時」にイベントを発行させます。また、現在選択されているボタンのindexのリスト、対象indexが選択されているかを取得することが出来ます。

内部で行っている処理としては、選択されたボタンの選択状態を反転させ、イベントを発行しているのみです。

これで、以下GIF動画のようなものが出来上がります。

ezgif.com-video-to-gif (3).gif

6. まとめ

本記事では、

  • 汎用ボタン - CustomButton
  • 汎用ラジオボタン - CustomRadioButton
  • 汎用チェックボックス - CustomCheckbox

の個人的な実装アプローチを紹介しました。

ボタンを実装するにあたって、以下の2つに機能(責務)を分けました。

  • ボタンとしての機能を持つ入力・イベント発行部分
  • 見た目を管理する見た目・演出部分

これにより、演出を差し替えてもボタンを扱う外部は全く気にせず実装を進めることが出来るようになりました。

例えばUnity1Weekなど開発期間が短い場合は特に、自分用のライブラリとして用意しておくことで、ボタン周りの実装を効率的に行えるようになります。

他にも、チーム開発でボタンUI素材がまだ来ていない場合でも、仮置きで気にせず進められるので、手を止めることなく開発を進めることが出来ます。

良かったら試してみてね!

15
15
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
15