Unityに関連する記事です

×

できること


 前回の記事の続きになります。

  → ボタン連打防止機能付き OnClick の拡張メソッド(R3・UniTask)



 この記事を活用することで、R3(UniRx) を活用したボタンの連打防止と、ボタン同時押し防止機能の両方を簡潔に記載することが可能です。

 OnClickExt メソッド内を修正して、ボタンの同時押しを防止する機能を追加します。

 そのため、実行時の処理は前回のものと変わりません。(ポイント)

[SerializeField] private Button normalButton;

// 通常(同期)のクリック処理(連打防止の時間は 1 秒に自動的に設定)
normalButton.OnClickExt(() => Debug.Log("通常ボタンクリック"), this);

// 連打防止時間を別途設定する場合
normalButton.OnClickExt(() => Debug.Log("通常ボタンクリック"), this, TimeSpan.FromMilliseconds(2000));

 それでは順番に解説していきます。


はじめに


 スマホにおける特徴的な挙動・機能の1つに、複数のボタンを同時にタップすることが可能であることがあげられます。

 例えば、ホーム画面などのフッター(下部)部分に複数のボタンが並んでいたり、あるいは、それらボタンがある状態で画面内に他のボタンが存在しているような状況において、
ユーザーは1つの指でタップすることだけではなく、2つ以上のボタンを複数の指を使ってタップすることができてしまいます。

 残念ながら Unity の標準機能では、このボタンの同時押しを防ぐ機能はありません。

 そこで前回作成したボタンの連打防止の拡張機能に、ボタンの同時押し防止機能を追加して対応していきたいと思います。



 この同時押し防止機能は、ゲーム内のボタンの押下処理を前回作成している連打防止機能用のメソッドで組み込んでいることが前提となります。
ButtonExtensions クラス内に記載している OnClickEx メソッドの中に『ボタン同時押しを防止する』機能を追加する形で対応するためです。
 
 このような形で修正を行うので、OnClickEx メソッドが実装されている連打防止のボタンについては、自動的に同時押し対策も施されることになります。
ボタンの処理を共通のメソッドを利用し実装しておくことで、このような恩恵も受けることが出来ます。

 そのためボタン1つといえども、設計部分からしっかりと検討しておくとよいことが実感できます。


実装のポイントと必要性


 Unity エディターでは気づきにくいですが、スマホではボタンが同時に押せてしまうため、複数のポップアップが同時に開いてしまったり
ガチャを引くボタンとゲームを進めるボタンを同時に押せてしまったりして不具合を誘発したりと、基本的にはよくない挙動ばかり発生します。

 初期段階からボタンの同時押しの防止については検討し、対策を講じておく必要があります。



 今回の実装においては、前回作成している ButtonExtensions クラス内の処理(OnClickEx メソッドなど)を修正して対策を行います。
よって、ボタン1つずつに対策していく方法ではなく、もっと根本的な部分から修正を行う形になりますので、
1つの修正をする → すべてのボタンに影響が及ぶ形になります。

 これは一見するととても良い修正ではありますが、逆に修正を間違えると、すべてのボタンで不具合が発生するとも言えます。
そのときにはすべてのボタンが動かなくなるので、修正には細心の注意が必要です。



 今回は新しく UIInputLock クラスを作成して、この機能を OnClickEx メソッドなどのボタンの処理に盛り込みます。
この機能により、ボタンの同時押しを防止することが可能です。


UIInputLock クラス


 ボタンの同時押しを制御するためのクラスです。
ゲーム内に登場するすべてのボタンを ButtonExtensions クラスの OnClickEx メソッドを利用することを前提に利用します。

UIInputLock.cs
using Cysharp.Threading.Tasks;
using System;

/// <summary>
/// 同時押しの制御クラス
/// </summary>
public static class UIInputLock {
    private static bool isLocked;
    private static readonly TimeSpan InputGuardTime =TimeSpan.FromMilliseconds(200);

    public static bool TryLock() {
        if (isLocked) {
            return false;
        }

        isLocked = true;
        return true;
    }

    public static async UniTask UnlockDelay() {
        await UniTask.Delay(InputGuardTime);
        isLocked = false;
    }
}

 InputGuardTime 変数はボタンが有効になるまでの待機時間です。
今回はこのクラスにて 0.2秒を設定して、すべてのボタンに共通で設定しています。
拡張方法として、UnlockDelay メソッドに引数を追加し、かつ、OnClickEx メソッドの引数を変更することで、各ボタンごとに時間を変えることも可能です。


ButtonExtensions クラス


 コード全文です。R3 ベースです。
もしも同名の拡張クラスがある場合には、namespace を活用したり、クラス名を変更して対応してください。

 作成したクラスは Editor 以外の通常のフォルダに配置してください。
Subscribe メソッド内の処理に先ほど作成した UIInputLock クラスの処理を追加して、ボタンの同時押しを防止しています。
防止する時間は UIInputLock クラスに依存していますが、UnlockDelay メソッドに引数を追加することで、各ボタンごとに制限する時間を変化させることも可能です。


ButtonExtensions.cs
using Cysharp.Threading.Tasks;
using Cysharp.Threading.Tasks.Linq;
using R3;
using System;
using System.Threading;
using UnityEngine;
using UnityEngine.UI;

public static class ButtonExtensions {

    /// <summary>
    /// OnClick の購読処理の拡張メソッド
    /// </summary>
    /// <param name="button"></param>
    /// <param name="onClick">ボタン押下時の処理</param>
    /// <param name="owner">AddTo に紐づけるコンポーネント</param>
    /// <param name="throttleTime">ボタンの再有効化までの待機時間。指定なければ1秒待機</param>
    /// <returns></returns>
    public static IDisposable OnClickExt(this Button button, Action onClick, Component owner, TimeSpan? throttleTime = null) {
        // ボタンの再有効化までの待機時間。引数指定がない場合には初期値(1秒)を設定
        TimeSpan time = throttleTime ?? TimeSpan.FromMilliseconds(1000);

        // 処理をまとめておく
        IDisposable subscription = button.OnClickAsObservable()
            .ThrottleFirst(time)
            .Subscribe(_ => {

                // UIInputLock を利用して、ボタンの同時押しを防止する
                if (!UIInputLock.TryLock()) {
                    return;
                }

                try {
                    onClick?.Invoke();
                } finally {
                    UIInputLock.UnlockDelay().Forget();
                }
            });

        // AddTo 用の紐づけ対象を受け取る(主に this が来る)
        if (owner != null) {
            subscription.AddTo(owner);
        }

        return subscription;
    }

    /// <summary>
    /// OnClick の非同期型の購読処理の拡張メソッド
    /// 外部クラスのキャンセルでは止められない
    /// </summary>
    /// <param name="button"></param>
    /// <param name="onClickAsync"></param>
    /// <param name="owner"></param>
    /// <param name="throttleTime"></param>
    /// <returns></returns>
    public static IDisposable OnClickExtAsync(this Button button, Func<UniTask> onClickAsync, Component owner, TimeSpan? throttleTime = null) {
        // ボタンの再有効化までの待機時間。引数指定がない場合には初期値(1秒)を設定
        TimeSpan time = throttleTime ?? TimeSpan.FromMilliseconds(1000);

        // 購読処理をまとめる
        IDisposable subscription = button.OnClickAsObservable()
            .ThrottleFirst(time)
            .SubscribeAwait(async (_, ct) => {

                // UIInputLock を利用して、ボタンの同時押しを防止する
                if (!UIInputLock.TryLock()) {
                    return;
                }

                try {
                    await onClickAsync();
                } finally {
                    UIInputLock.UnlockDelay().Forget();
                }
            });

        // AddTo 用の紐づけ対象を受け取る(主に this など。この情報が、上記の ct にも利用される)
        if (owner != null) {
            subscription.AddTo(owner);
        }

        return subscription;
    }

    /// <summary>
    /// 上記のオーバーロード
    /// OnClick の非同期型の購読処理の拡張メソッド
    /// 外部クラスからもキャンセルで止められる
    /// </summary>
    /// <param name="button"></param>
    /// <param name="onClickAsync"></param>
    /// <param name="externalToken"></param>
    /// <param name="owner"></param>
    /// <param name="throttleTime"></param>
    /// <returns></returns>
    public static IDisposable OnClickExtAsync(this Button button, Func<CancellationToken, UniTask> onClickAsync, CancellationToken externalToken, Component owner = null, TimeSpan? throttleTime = null) {
        // ボタンの再有効化までの待機時間。引数指定がない場合には初期値(1秒)を設定
        TimeSpan time = throttleTime ?? TimeSpan.FromMilliseconds(1000);

        // 処理をまとめておく
        IDisposable subscription = button.OnClickAsObservable()
            .ThrottleFirst(time)
            .SubscribeAwait(async (_, internalCt) => {

                // UIInputLock を利用して、ボタンの同時押しを防止する
                if (!UIInputLock.TryLock()) {
                    return;
                }

                try {
                    // CreateLinkedTokenSource を利用し、SubscribeAwait が内部的に持つ破棄(internalCt)と、外部から渡されるキャンセル(externalToken)制御を両立させたトークンを作成する
                    // いずれか発火時にタスクを止める
                    using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(internalCt, externalToken);
                    await onClickAsync(linkedCts.Token);
                } finally {
                    UIInputLock.UnlockDelay().Forget();
                }
            });

        // AddTo 用の紐づけ対象を受け取る(主に this が来る。この情報が、上記の token にも利用される)
        if (owner != null) {
            subscription.AddTo(owner);
        }

        return subscription;
    }
}



 各ボタンの処理内を修正し、すでにボタンが押されている場合には一定時間の間、他のボタンの入力を無視します。
これにより、ボタンの連打防止・ボタンの同時押しを合わせて防止する機能を実装で来ています。


使用例


 使用例は前回の記事と同じですが、一応載せておきます。


1.同期処理��


 サンプルコードです。記事の最初に提示したものと同じ内容です。
従来の OnClickAsObsevable、ThrottleFirst、Subscribe、AddTo を書かず、OnClickExt メソッド1つで代替しています。

using System;
using UnityEngine;
using UnityEngine.UI;

public class SampleUI : MonoBehaviour {

    [SerializeField] Button normalButton;

    void Start() {
        // 通常(同期)のクリック処理
        normalButton.OnClickExt(() => Debug.Log("通常ボタンクリック"), this, TimeSpan.FromMilliseconds(2000));
    }
}



 ボタンの購読・連打防止機能・購読解除に必要な処理が1行で記述できているのがわかります。
また using R3; の宣言も不要です。

 第1引数は () => Debug.Log("通常ボタンクリック") の部分です。
最初の () => のラムダ式によるデリゲート部分は共通仕様です。常にこの形で処理を書いてください。
その後、ボタンを押した際に実行したい処理やメソッドを書きます。

 第2引数は this の部分です。これは AddTo として利用される Component の情報です。

 第3引数は TimeSpan.FromMilliseconds(2000) の部分です。こちらは省略可能です。
省略した場合には初期値として設定してある 1000 が使用されます。using System の宣言も不要になります。
今回のように記述した場合のみ、ThrottleFirst の時間として指定されます。なお TimeSpan は TimeSpan.FromSeconds(2) でも指定可能です。

<第3引数省略の場合>
normalButton.OnClickExt(() => Debug.Log("通常ボタンクリック"), this);

<第3引数に TimeSpan.FromSeconds を利用した場合>
using System;

normalButton.OnClickExt(() => Debug.Log("通常ボタンクリック"), this, TimeSpan.FromSeconds(2));



 ボタンを押下した際に2つ以上の処理を実行したい場合には、下記のように { } で囲んでください(通常のデリゲート処理と同じです)。

        // 通常のクリック処理
        normalButton.OnClickExt(() => {
            Debug.Log("通常ボタンクリック_1");
            Debug.Log("通常ボタンクリック_2");
        },                                    // ここまでが第1引数              
        this);                                // 第3引数は省略可能

 非常にシンプルな形で、ボタンの購読処理を実装することができました。

 ボタンの購読処理については、このメソッドを利用する事で認識の共有化をしておくことにより、
複数人で作業する場合においても、すべてのボタンで漏れなく連打防止機能が自動的に実装されることもなり、安全性も高まります。


2.同期処理��


 AddTo のタイミングではなく、別のタイミングでの購読解除が必要なケースがあります。
例えばオブジェクトプールなどを利用している場合には、ゲームオブジェクトが破棄されませんので、AddTo による自動解除が行えません。

 今回用意している OnClickExt メソッドの戻り値も IDisposable 型です。
そのため、IDisposable 型の変数を用意しておき、OnClickExt メソッドの購読処理を保持することが可能です。

 保持しておくことにより、例えば OnDisable メソッドを用意したり、購読解除用のメソッドを用意することで、
ゲームオブジェクトの破棄のタイミングではなく、ゲームオブジェクトが非アクティブ状態になった際での購読解除が実装できます。


using UnityEngine;
using UnityEngine.UI;
using System;

public class SampleButtonHandler : MonoBehaviour
{
    [SerializeField] private Button sampleButton;

    private IDisposable clickSubscription;

    void Start() {

        // 購読処理を戻り値を受け取って変数で保持。拡張メソッドを使って購読開始(第1引数の処理が1行分であっても { } で囲んでよい)
        clickSubscription = sampleButton.OnClickExt(() => {
            Debug.Log("Button Clicked!");
        }, this, TimeSpan.FromMilliseconds(1500));
    }

    public void Dispose() {

        // 手動で購読解除する場合(AddTo が使えない状況など)
        clickSubscription?.Dispose();
    }

    void OnDisable() {

        // 非アクティブ状態のタイミングで忘れず Dispose 呼び出し
        Dispose();
    }
}

using System;

CompositeDisposable disposables;  // CompositeDisposable でも可能(その場合には初期化を忘れずに行ってください)

private void Start() {
    disposables = new();
    disposables = normalButton.OnClickExt(() => Debug.Log("通常ボタンクリック"), this);
}

private void OnDisable() {
    Release();
}

private void Release() {
    disposables?.Clear();   // Dispose を使う場合には、再度初期化が必要です。
}

 このように準備しておくことにより、様々な状況に対応した購読解除が可能になります。


3.非同期処理��


 ボタン押下時の処理を非同期処理で行いたい場合、R3 では SubscribeAwait メソッドが用意されているので対応が可能です。
そちらの拡張メソッドとして今回 OnClickExtAsync メソッドを用意してありますので、そちらで代替したケースです。

using UnityEngine;
using UnityEngine.UI;

public class SampleUIAsync : MonoBehaviour {

    [SerializeField] Button asyncButton;

    void Start() {

        // 非同期のクリック処理
        asyncButton.OnClickExtAsync(async (_, ct) => {
            await UniTask.Delay(1000);
            Debug.Log("非同期ボタンクリック完了");
        }, this);
    }
}



 なおこちらも、IDisposable 型の変数で保持しておくことで、任意のタイミングでの購読解除も可能です。

    private IDisposable clickSubscription;

    // Start メソッドなどで購読処理
  clickSubscription = asyncButton.OnClickExtAsync(
      async (_, ct) => {
          await UniTask.Delay(3000, cancellationToken: ct);
      }, this);

 引数の書き方が同期処理とは異なりますので注意してください。

 この処理の場合、非同期処理のキャンセルが this と紐づいているので、
ボタンの購読解除と同時に、非同期処理のキャンセルも一緒に行われるようになります。



 やはりこちらの場合も AddTo メソッド以外のタイミングでの購読解除を行いたいケースがあります。
そのため Dispose メソッドを用意しておくことで、任意のタイミングでボタンの購読解除と同時に非同期処理のキャンセルが行えます。

    public void Dispose() {

        // 手動で購読解除する場合(AddTo が使えない状況など)
        clickSubscription?.Dispose();
    }

 ただし、非同期処理の場合には、ボタンの購読解除のタイミングではなくて、外部クラスの非同期処理のキャンセル処理と連動させたいケースもありますので、
次はそちらのケースでの対応方法を記述しておきます。


4.非同期処理��


 外部クラスの非同期処理のキャンセル処理と連動させたいケースです。

 このサンプルコードではクラス内でキャンセルトークンを生成していますが、
こちらのキャンセルトークン情報を外部から受け取って引数に指定することで、そのキャンセルトークンと紐づくほかの処理と合わせて購読解除が行われます。

using UnityEngine;
using UnityEngine.UI;
using System;
using Cysharp.Threading.Tasks;
using System.Threading;

public class SampleButtonHandlerAsyncWithToken : MonoBehaviour, IDisposable
{
    [SerializeField] private Button sampleButton;

    private IDisposable clickSubscription;
    private CancellationTokenSource cts;      // 外部クラスからもらう形だが、今回はデバッグ用にここに記述

    // デバッグ用。実際には外部クラスで行う処理
    void Start() {

        cts = new CancellationTokenSource();

        SetupAsyncButton(cts.Token);
    }

    public void SetupAsyncButton(CancellationToken token) {

        clickSubscription = sampleButton.OnClickExtAsync(
            async () => {
                Debug.Log("Start async task...");
        
                try {
                    await UniTask.Delay(3000, cancellationToken: token);
                    Debug.Log("Async task finished.");
                } 
                catch (OperationCanceledException) {
                    Debug.Log("Async task was cancelled.");
                }
            },
            token);
    }

    public void Dispose()
    {
        clickSubscription?.Dispose();

        // ここは自クラス内でキャンセルトークンを生成している場合のみ記述する
        if (cts != null)
        {
            if (!cts.IsCancellationRequested)
            {
                cts.Cancel();
            }
            cts.Dispose();
            cts = null;
        }

        Debug.Log("Button subscription disposed manually.");
    }

    void OnDisable() {

        // 忘れず Dispose 呼び出し
        Dispose();
    }
}

 SubscribeAwait メソッドにより、onClickAsync() 内で使うキャンセルトークンが自前でコントロール可能になります。
こちらの場合、Unity ライフサイクルと切り離して制御できるため、より柔軟なボタンイベント管理ができます。


おわりに


 機能の拡張や応用を行う際には、どういった方法で実装を行っていけば効率がよいのか、また修正箇所を少なくできるのか、などを検討してから実装していく必要があります。
今回のように、新しい機能を追加するにあたり、一部の処理を修正することでも影響範囲がすべてのボタンに行き渡る、というケースもあります。

 設計の初期段階において、仕様の内容から可能な限り先を見通して、どのような機能が増えそうなのかイメージしておくことで拡張がスムースになります。

コメントをかく


「http://」を含む投稿は禁止されています。

利用規約をご確認のうえご記入下さい

Menu



基礎

技術/知識(実装例)

2Dおはじきゲーム(発展編)

2D強制横スクロールアクション(発展編)

3Dダイビングアクション(発展編)

2Dタップシューティング(拡張編)

レースゲーム(抜粋)

2D放置ゲーム(発展編)

3Dレールガンシューティング(応用編)

3D脱出ゲーム(抜粋)

2Dリアルタイムストラテジー

2Dトップビューアドベンチャー(宴アセット使用)

3Dタップアクション(NavMeshAgent 使用)

2Dトップビューアクション(カエルの為に〜、ボコスカウォーズ風)

VideoPlayer イベント連動の実装例

VideoPlayer リスト内からムービー再生の実装例(発展)

AR 画像付きオブジェクト生成の実装例

AR リスト内から生成の実装例(発展)

private



このサイト内の作品はユニティちゃんライセンス条項の元に提供されています。

管理人/副管理人のみ編集できます

広告募集中