uGUI のボタンを長押し対応(＋α)させた話

# はじめに
uGUI で UI を実装していると、「なんでこのイベントとれないの?」と言いたくなることがたくさんあると思います。現在作っているアプリで、ボタンの長押しも使いそうな感じだったので対応させてみました。
そのついでに、`OnPointerUp` `OnPointerDown` でボタンのイベントを取る際に起こるつらい仕様をなるべくオーバーラップしました。

なお、こちらのページではソースコードをすべて書くのも可読性悪いのかなーと思いましたので、ソースコード一式は github の公開リポジトリにて参照してください。

PC上と実機(iPhone)で動作確認はしていますが、ひょっとしたらバグはまだ何かしら残っているかもしれませんのでそこは自己責任で・・・

## 開発環境
- Unity 2017.4 系
- Mac, Windows, iOS
- .NET 4.6

## ソースコード公開先
Clione/UI (MIT License)
https://github.com/MasaKoha/Clione/tree/develop/Assets/Clione/UI

# 実装した内容
今回実装したボタンで取れるイベントは以下の内容です。

```C#:ButtonEventType.cs
[Flags]
public enum ButtonEventType
{
    None = 0, // なにもないとき
    ClickDown = 1, // 押したとき
    StartLongTap = 1 << 1, // 長押しのはじめ
    LongTap = 1 << 2, // 長押し中
    EndLongTap = 1 << 3, // 長押しのおわり
    Decide = 1 << 4, // ボタンを押したことが決定されたとき
    ClickUp = 1 << 5,  // ボタンを離したとき
}
```
## uGUI のボタンの長押し対応
uGUI のボタンの範囲を決まった時間長押しすると、長押しのイベントが取れるようにしました。
`StartLongTap` と `EndLongTap` は 1 フレームだけ呼ばれ、`LongTap`は毎フレーム呼ばれます。

## ボタンの押す・離す対応
uGUI の Button.onClick は、ボタンが押されたときだけにしか呼ばれません。
本実装では、押したとき、離したときのイベントが取れるようになっているので、それぞれ別々の動作をさせることが可能です。(コード上だけで DOTween などを使用してアニメーションを書くことが容易)
Decide が呼ばれるのは、ClickUp と **ほぼ** 同じタイミングで呼ばれます(理由は後述)

## ボタンを離したときに必ずボタンを押したことを決定しないようにした
ClickUp と Decide を別々にしました。
別々にした理由は、CilckDown 時にボタンを押したままボタン外でClickUpするパターンもあります。
このときに Decide までするのはボタンの挙動としてどうなの?と思うところもありましたので別々にしています。

## ボタンの各イベントが同フレームに呼ばれないようにした
`Button.OnPointerDown` と `Button.OnPointerUp` をそのまま使うと、同フレームに2つのイベントが呼ばれます。
この2つのイベントに対してアニメーションを記述すると、どちらのアニメーションが実行されるのかわかりません。

```
例)
OnPointerDown でボタンのサイズが小さくなるアニメーション
OnPointerUp でボタンのサイズが元にもどるアニメーション
これらが同フレームに呼ばれると、OnPointerDown のアニメーションが優先されて、ボタンが小さくなったままになることがある
```

例のような意図しないバグが発生する(した)ので、このボタンでは、各イベント同フレームで起こったとしても、必ず1フレーム遅延させてイベントを発行しています。
以下の画像は、ボタンを1F以内に押して離したときの挙動です。(数字はそのイベントが起こったときの `Time.frameCount` )
<img width="297" alt="スクリーンショット 2018-07-15 14.54.14.png" src="https://qiita-image-store.s3.amazonaws.com/0/151719/3a96e272-150a-b29f-1f3a-a311e5b73c3d.png">


## 実装(ソースコード一部抜粋)
```C#:ClioneButtonBase.cs
public abstract class ClioneButtonBase : UnityEngine.UI.Button
{
    public override void OnPointerExit(PointerEventData eventData)
    {
        base.OnPointerExit(eventData);

        _longTapTime = 0;
        _longTapFirstFrame = true;
        _isDetermining = false;
        if (_clickState == ButtonState.None)
        {
            return;
        }

        if (_clickState == ButtonState.ClickDown)
        {
            OnClickUp();
            _clickState = ButtonState.None;
            return;
        }

        if (_clickState == ButtonState.LongClick)
        {
            OnEndLongTap();
            OnClickUp();
        }

        _clickState = ButtonState.ClickUp;
    }

    public override void OnPointerDown(PointerEventData eventData)
    {
        base.OnPointerDown(eventData);
        // OnPointerDown は Button.interactable とは関係無い
        // これを忘れると、interactable = false でもクリックできてしまう
        if (!interactable)
        {
            return;
        }

        _clickState = ButtonState.ClickDown;
        _isDetermining = true;
        OnClickDown();
    }

    public override void OnPointerUp(PointerEventData eventData)
    {
        base.OnPointerUp(eventData);

        if (_clickState == ButtonState.LongClick)
        {
            OnEndLongTap();
        }

        if (_clickState != ButtonState.None)
        {
            _longTapTime = 0;
            _clickState = ButtonState.None;
            OnClickUp();
        }

        if (_isDetermining)
        {
            _isDetermining = false;
            OnDecide();
        }
    }

    protected void Update()
    {
        if (_clickState == ButtonState.None)
        {
            return;
        }

        if (_clickState == ButtonState.ClickUp)
        {
            _clickState = ButtonState.None;
            return;
        }

        _longTapTime += Time.deltaTime;

        if (_longTapTime > _startLongTapDuration)
        {
            if (_clickState == ButtonState.ClickUp)
            {
                OnEndLongTap();
                _longTapTime = 0;
            }

            if (_longTapFirstFrame)
            {
                OnStartLongTap();
                _longTapFirstFrame = false;
                _clickState = ButtonState.LongClick;
            }
            else
            {
                OnLongTap();
            }
        }
    }
}
```

```C#:ClioneButton.cs
    public class ClioneButtonEvent : UnityEvent<ButtonEventType>
    {
    }

    public class ClioneButton : ClioneButtonBase
    {
        public UnityEvent<ButtonEventType> ButtonEvent = new ClioneButtonEvent();

        private ButtonEventType _eventBitCode = ButtonEventType.None;

        protected new void Update()
        {
            base.Update();

            if (_eventBitCode == ButtonEventType.None)
            {
                return;
            }

	// ここで ButtonEventType の各フラグを見て、どのイベントを投げるのか決定している。
	// 全て if-elseif にしているのは、イベントを投げるときに 1フレームに1イベントだけ投げるようにするため
            if ((_eventBitCode & ButtonEventType.ClickDown) == ButtonEventType.ClickDown)
            {
                // ClickDown 時に interactable を false にして、入力が2つ(スマホだと指2本で1つのボタンをタップするとか)あっても 2個目以上は弾くようにしている
                interactable = false;
                _eventBitCode &= ~ButtonEventType.ClickDown;
                ButtonEvent.Invoke(ButtonEventType.ClickDown);
            }
            else if ((_eventBitCode & ButtonEventType.StartLongTap) == ButtonEventType.StartLongTap)
            {
                _eventBitCode &= ~ButtonEventType.StartLongTap;
                ButtonEvent.Invoke(ButtonEventType.StartLongTap);
            }
            else if ((_eventBitCode & ButtonEventType.LongTap) == ButtonEventType.LongTap)
            {
                _eventBitCode &= ~ButtonEventType.LongTap;
                ButtonEvent.Invoke(ButtonEventType.LongTap);
            }
            else if ((_eventBitCode & ButtonEventType.EndLongTap) == ButtonEventType.EndLongTap)
            {
                _eventBitCode &= ~ButtonEventType.EndLongTap;
                ButtonEvent.Invoke(ButtonEventType.EndLongTap);
            }
            else if ((_eventBitCode & ButtonEventType.Decide) == ButtonEventType.Decide)
            {
                _eventBitCode &= ~ButtonEventType.Decide;
                ButtonEvent.Invoke(ButtonEventType.Decide);
            }
            else if ((_eventBitCode & ButtonEventType.ClickUp) == ButtonEventType.ClickUp)
            {
                // ClickUp したら、ボタンが再度 Click できるようにする。
                interactable = true;
                _eventBitCode &= ~ButtonEventType.ClickUp;
                ButtonEvent.Invoke(ButtonEventType.ClickUp);
            }
        }

        protected override void OnClickDown()
        {
            _eventBitCode |= ButtonEventType.ClickDown;
        }

        protected override void OnClickUp()
        {
            _eventBitCode |= ButtonEventType.ClickUp;
        }

        protected override void OnDecide()
        {
            _eventBitCode |= ButtonEventType.Decide;
        }

        protected override void OnStartLongTap()
        {
            _eventBitCode |= ButtonEventType.StartLongTap;
        }

        protected override void OnLongTap()
        {
            _eventBitCode |= ButtonEventType.LongTap;
        }

        protected override void OnEndLongTap()
        {
            _eventBitCode |= ButtonEventType.EndLongTap;
        }
    }
```
# 使い方
`Button.onClick` と同様に、AddLister すればイベントを取ることができます。

```C#:SampleCode
        [SerializeField] private ClioneButton _button;

        private void Start()
        {
            _button.ButtonEvent.AddListener(state => { Debug.Log($"{state.ToString()} : {Time.frameCount}"); });
        }
```