"ゲームなどでよく見る「長押しのボタン」のUIをWEBで表現してみた" を Blazor で実装してみた

## プロローグ

先日、Qiita で以下の記事を見ました。

https://qiita.com/ishi720/items/7eb6195f5972deb8c221

自分は、CSS の `background-image` で `conic-gradient` という指定を使うと、このように円グラフ状の Progress 表示が実現できるとは知らず、それまで SVG を使った実装しか使ったことがなく、とても勉強になりました。

さて自分は、何か Web アプリケーションを作るとなると、いちばん好きなフレームワークは、C# で実装できる Blazor なので、この「長押しボタン」を Blazor のコンポーネントとして実装してみました。

## Blazor プロジェクトを新規作成し、メーター部分の HTML & CSS を記述

まずは新規に Blazor WebAssembly プロジェクトを作成します (もちろん、Blazor Server や、Interactive Mode に None 以外を指定した Blazor Web App でも構いません)。

プロジェクトを作成したら、`LongPressButton.razor` ファイルと、`LongPressButton.razor.css` ファイルを新規作成します。

まずは `LongPressButton.razor` ファイルに、["ゲームなどでよく見る「長押しのボタン」のUIをWEBで表現してみた"](https://qiita.com/ishi720/items/7eb6195f5972deb8c221) の記事からのコピペで、以下のように HTML をマークアップします。これが "長押しボタン" の表示要素となります。なお、元記事では id 属性で各 `<div>` 要素を識別していましたが、今回は class 属性で識別するようにしました。

```html:LongPressButton.razor
<div class="outer_circle">
    <div class="inner_circle"></div>
</div>
```

続けて、同じくコピペで、`LongPressButton.razor.css` に CSS を記述します。なお、こちらも id セレクタを class セレクタに変更しました。

```css:LongPressButton.razor.css
.outer_circle {
    position: relative;
    margin-right: auto;
    margin-left: auto;
    width: 100px;
    height: 100px;
    border-radius: 50%;
    background-image: conic-gradient(#d5525f 0% 0%, #d9d9d9 0% 100%);
}

.inner_circle {
    text-align: center;
    background-color: #676767;
    width: 80px;
    height: 80px;
    border-radius: 50%;
    position: relative;
    top: 10px;
    left: 10px;
}
```

これで、"長押しボタン" を実装したコンポーネント、`LongPressButton` の最低限の見た目が実装できましたので、`App.razor` を以下のように書き換えて実行してみます。

```html:App.razor
<LongPressButton />
```

すると無事、最初の表示が再現されました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58450/7029bf6b-4a2b-1826-9f99-de5c796ffafc.png)

## ロジック部分の実装

### コンポーネントで公開するパラメーターの用意

続けて `LongPressButton.razor` 内に `@code` コードブロックを設け、C# でロジック部分を実装します。

まず、長押しの秒数をコンポーネント外から指定できるよう、および、その秒数長押しし続けたら発生させるイベントコールバックを、この `LongPressButton` コンポーネントのパラメーターとして用意します。長押しの秒数は、とくに指定がない場合は 3 秒を既定値としました。

```cs:LongPressButton.razor
...
@code
{
    [Parameter]
    public int Second { get; set; } = 3;

    [Parameter]
    public EventCallback OnLongPress { get; set; }
}
```

### タイマーおよびタイマーイベントの発生回数を数えるカウンター変数を用意

続けて、長押しが開始されて以降の経過時間、というかタイマーイベントの発生回数を数えるカウンター変数と、定期的にイベントを発生させるためのタイマーを、フィールド変数に用意します。Blazor なので、JavaScript にある `setInterval` や `setTimeout` といった JavaScript API ではなく、.NET の `System.Timers.Timer` クラスを使います。タイマーのインターバルは 10 ミリ秒とします。

```cs:LongPressButton.razor
...
@code
{
    ...
    private int _count = 0;

    private readonly System.Timers.Timer _timer = new(interval: 10);
}
```

### タイマーイベント発生時にカウンターを加算する処理を実装

タイマーが用意できたので、タイマーのインターバルごとに発生する `Elapsed` イベント用のハンドラメソッドを実装して、そこでカウンター変数をインクリメントします。また、コンポーネント初期化のタイミングで呼び出される `OnInitialized` ライフサイクルメソッドで、タイマーの `Elapsed` イベントにこのイベントハンドラメソッドを購読追加します。

なお、タイマーの `Elapsed` イベント発生では、これは `EventCallback<T>` ではないことから、暗黙の `StateHasChanged()` は発生しません。すなわち、`Elapsed` イベントハンドラ内で何か状態変更 (ここではカウンター変数のインクリメント) が発生しても、表示に反映されません。なので、`Elapsed` イベントハンドラ内で明示的に `StateHasChanged()` メソッドを呼び出して、Blazor に再描画を指示するようにします。

```cs:LongPressButton.razor
...
@code
{
    ...
    override protected void OnInitialized()
    {
        _timer.Elapsed += Timer_Elapsed;
    }

    private async void Timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e)
    {
        _count++;
        StateHasChanged();
    }
}
```

さてこのままでは、この `LongPressButton` コンポーネントが破棄されるときに、タイマーが起動しっぱなしになるなどの恐れがあります。そこで、`LongPressButton` コンポーネントで `IDisposable` インターフェースを実装し、タイマーの破棄処理を実装します。念のため、`Elapsed` イベントの購読解除も行なっておきましょう。

```cs:LongPressButton.razor
@implements IDisposable
...
@code
{
    ...
    public void Dispose()
    {
        _timer.Dispose();
        _timer.Elapsed -= Timer_Elapsed;
    }
}
```

### ボタンが押されたとき/離されたときの処理

ボタンが押されたとき/離されたときの処理も実装していきます。それぞれ `Start` および `End` というメソッドで実装します。それぞれ、タイマーの開始/終了と、カウンター変数のリセットを実装します。また、これらメソッドを後ほど、表示要素の ponterdown/up イベントハンドラとして登録していきます。

```cs:LongPressButton.razor
...
@code {
    ...
    private void Start()
    {
        _timer.Start();
    }

    private void End()
    {
        _timer.Stop();
        _count = 0;
    }
    ...
}
```

それでは、上記で実装した `Start` および `End` メソッドを、表示要素の pointerdown/up イベントハンドラとして登録します。これらメソッドは C# 側のメソッドなので、`@` + イベント名の属性で、HTML 上でマークアップします。

```html:LongPressButton.razor
...
<div class="outer_circle"
     @onpointerdown="Start"
     @onpointerup="End">
     ...
</div>
...
```

### 長押しの経過時間を表示に反映

引き続きは、長押しの時間経過に伴って、CSS のグラデーション定義によって、進捗状況を表示していきますが、そのために、まずは CSS に仕込みをします。進捗状況のパーセント値を、CSS カスタムプロパティ (俗に言う CSS 変数) で指定するように `LongPressButton.razor.css` 内の定義を書き換えます。参照する CSS カスタムプロパティ名は、`--long-press-progress` としてみました。

```css:LongPressButton.razor.css
.outer_circle {
    ...
    background-image: conic-gradient(#d5525f 0% var(--long-press-progress, 0%), #d9d9d9 0% 100%);
}
...
```

`LongPressButton.razor` に戻り、タイマーイベント発生のたびにインクリメントするカウンター変数 (`_counter`) と、長押しの秒数パラメーター (`Second`) を使って、進捗状況のパーセンテージを計算し、それをメーター部分の要素のスタイル指定で CSS カスタムプロパティ `--long-press-progress` に設定するようにします。

```html:LongPressButton.razor
...
<div class="outer_circle"
     ...
     style="@($"--long-press-progress: {_count/this.Second}%")">
     ...
</div>
...
```

これで、ボタンを押す/離すで、押している時間に従って、進捗状況がされるところまでできました。

### 長押し秒数が経過したらイベント発火

もう完成間近です。タイマーイベントのハンドラにて、`Second` パラメーターに指定された秒数が経過したらタイマーを止めて `OnLongPress` イベントコールバックを呼び出す処理を追加します。

```cs:LongPressButton.razor
...
@code {
    ...
    private async void Timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e)
    {
        _count++;
        StateHasChanged();

        if (_count / 100 == this.Second)
        {
            _timer.Stop();
            await OnLongPress.InvokeAsync();

            _count = 0;
            StateHasChanged();
        }
    }
    ...
}
```

## App.razor で動作確認

これで基本形は完成です。`App.razor` 側で、この `LongRessButton` コンポーネントの `OnLongPress` イベントコールバックをハンドルして alert メッセージを表示するように実装し、動作を確認してみます。

```cs:App.razor
@inject IJSRuntime JSRuntime

<div>
    <LongPressButton OnLongPress="OnLongPress">
    </LongPressButton>
</div>

@code
{
    private async Task OnLongPress()
    {
        await this.JSRuntime.InvokeVoidAsync("alert", $"ボタンを長押ししました！");
    }
}
```

無事動作しました!

![movie-000.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58450/7db27edc-aa5a-1831-9318-387b9972b8d2.gif)

## 仕上げ

最後に、[元記事の「4. バグについて」](https://qiita.com/ishi720/items/7eb6195f5972deb8c221#4-%E3%83%90%E3%82%B0%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6)の記述に倣って、「長押し中に右クリック」を阻止したり、「長押し中にマウスのポインタが外れる」といったケースで長押しを中断するよう、イベントハンドリングを追加します。oncontextmenu イベントの動作阻止だけは Blazor 側では対処でいないため (※ Blazor Server での動作を想定すると、DOM イベントの処理はどうしても非同期処理とならざるを得ず、非同期処理では、イベントの阻止は間に合わないため)、インラインで `return false;` するという JavaScript 実装になっている点はご注意ください。

```html:LongPressButton.razor
...
<div class="outer_circle"
     ...
     @onpointerout="End"
     @onpointercancel="End"
     @onpointerleave="End"
     oncontextmenu="return false;"
     ...>
    ...
</div>
...
```

## おわりに

以上で[元記事](https://qiita.com/ishi720/items/7eb6195f5972deb8c221)にて紹介されていた「長押しのボタン」を Blazor 上で Razor コンポーネントとして実装することができました。

[元記事](https://qiita.com/ishi720/items/7eb6195f5972deb8c221)で紹介されている GitHub リポジトリでは、さらに、色やサイズもカスタマイズできるように実装が進んでいます。今回本記事で紹介した Blazor 版も、色やサイズを指定できるパラメーターを増設してさらに再利用しやすくできると思います。

さらには、この `LongPressButton` コンポーネントを独立した NuGet パッケージに切り出して、パッケージ参照するだけで他の様々な Blazor プロジェクトから再利用できるようにもできますね。コンポーネントを NuGet パッケージにする方法については、2019 年の Blazor Advent Calendar に投稿した下記の記事を参照ください。

https://qiita.com/jsakamoto/items/4c4520a0b73d3f30d95a

Happy Coding! :)