💨

.NET 8 の ASP.NET Core Blazor で二度押しを抑止したい

2024/02/25に公開

ボタンの二度押しを禁止したい。
とてもよくある要望だと思います。ということでやっていきましょう。

シンプルなケース

Blazor で対応するには以下のような感じで実装すれば良いです。

@page "/"
@rendermode InteractiveServer

<PageTitle>Home</PageTitle>

<h1>カウンター</h1>

<p>Current count: @_currentCount</p>

@* _isProcessing が true の時は押せなくする *@
<button @onclick="IncrementAsync" disabled="@_isProcessing">Increment</button>

@code {
    private bool _isProcessing;
    private int _currentCount = 0;

    public async Task IncrementAsync()
    {
        // 処理中の時は何もしない
        if (_isProcessing) return;
        // 処理中のフラグを立てる
        _isProcessing = true;
        try
        {
            // API 呼び出しなどの処理をシミュレート
            await Task.Delay(2000);
            _currentCount++;
        }
        finally
        {
            // 処理中のフラグを解除
            _isProcessing = false;
        }
    }
}

これでもいいのですが、ボタンの数だけこれを実装するのは苦行です。なので、こんな部品を用意すれば少しは簡略化出来ます。

ProcessScope.cs
namespace BlazorApp19;

public class ProcessScope
{
    public bool IsProcessing { get; private set; }

    public IDisposable Create() => new ProcessSubscription(this);

    class ProcessSubscription : IDisposable
    {
        private readonly ProcessScope _processScope;

        public ProcessSubscription(ProcessScope processScope)
        {
            if (processScope.IsProcessing) throw new ArgumentException("この ProcessScope は既に実行中です。");
            _processScope = processScope;
            _processScope.IsProcessing = true;
        }
        public void Dispose() => _processScope.IsProcessing = false;
    }
}

これを使うと以下のように書けます。

@page "/"
@rendermode InteractiveServer

<PageTitle>Home</PageTitle>

<h1>カウンター</h1>

<p>Current count: @_currentCount</p>

@* 処理中の時は押せなくする *@
<button @onclick="IncrementAsync" disabled="@_processScope.IsProcessing">Increment</button>

@code {
    private ProcessScope _processScope = new();
    private int _currentCount = 0;

    public async Task IncrementAsync()
    {
        if (_processScope.IsProcessing) return;
        // 処理中フラグを立てる
        using var scope = _processScope.Create();
        // API 呼び出しなどの処理をシミュレート
        await Task.Delay(2000);
        _currentCount++;
    }
}

tryfinally で毎回括る必要がなくなったのでとってもスッキリしました。

ボタンが複数あり、複数のボタンで処理中のステータスを共有する時には以下のようになります。

@page "/"
@rendermode InteractiveServer

<PageTitle>Home</PageTitle>

<h1>カウンター</h1>

<p>Current count: @_currentCount</p>

@* 処理中の時は押せなくする *@
<button @onclick="IncrementAsync" disabled="@_processScope.IsProcessing">Increment</button>
<button @onclick="DecrementAsync" disabled="@_processScope.IsProcessing">Decrement</button>

@code {
    private ProcessScope _processScope = new();
    private int _currentCount = 0;

    public async Task IncrementAsync()
    {
        if (_processScope.IsProcessing) return;
        // 処理中フラグを立てる
        using var scope = _processScope.Create();
        // API 呼び出しなどの処理をシミュレート
        await Task.Delay(2000);
        _currentCount++;
    }

    public async Task DecrementAsync()
    {
        if (_processScope.IsProcessing) return;
        // 処理中フラグを立てる
        using var scope = _processScope.Create();
        // API 呼び出しなどの処理をシミュレート
        await Task.Delay(2000);
        _currentCount--;
    }
}

許容範囲かもしれませんが、全てのメソッドに同じような処理を書くのは面倒です。

二度押し防止ボタン

次はボタンをカスタマイズして二度押し防止をするようにしてみましょう。ProcessScope は、CascadingParameter で渡すことにしてみました。

CustomButton.razor
<button @onclick="OnClickHandler" disabled="@(ProcessScope?.IsProcessing ?? false)"
    @attributes="AdditionalAttributes">
    @ChildContent
</button>

@code {
    [CascadingParameter]
    public ProcessScope? ProcessScope { get; set; }

    [Parameter]
    public RenderFragment? ChildContent { get; set; }

    [Parameter]
    public EventCallback OnClick { get; set; }

    [Parameter(CaptureUnmatchedValues = true)]
    public Dictionary<string, object>? AdditionalAttributes { get; set; }

    private async Task OnClickHandler()
    {
        if (ProcessScope?.IsProcessing ?? false) return;
        using var scope = ProcessScope?.Create();
        await OnClick.InvokeAsync();
    }
}

ProcessScope に少し手を入れて IsProcessing の変更通知を行うようにします。

ProcessScope.cs
namespace BlazorApp19;

public class ProcessScope
{
    private bool _isProcessing;
    public bool IsProcessing
    {
        get => _isProcessing;
        set
        {
            if (_isProcessing == value) return;
            _isProcessing = value;
            IsProcessingChanged?.Invoke(this, EventArgs.Empty);
        }
    }

    internal event EventHandler? IsProcessingChanged;

    public IDisposable Create() => new ProcessSubscription(this);

    class ProcessSubscription : IDisposable
    {
        private readonly ProcessScope _processScope;

        public ProcessSubscription(ProcessScope processScope)
        {
            if (processScope.IsProcessing) throw new ArgumentException("この ProcessScope は既に実行中です。");
            _processScope = processScope;
            _processScope.IsProcessing = true;
        }
        public void Dispose() => _processScope.IsProcessing = false;
    }
}

そして CasadingParameter として ProcessScope を提供する ProcssScopeProvider を作ります。

ProcessScopeProvider.razor
@implements IDisposable

<CascadingValue Value="_processScope">
    @ChildContent?.Invoke(_processScope.IsProcessing)
</CascadingValue>


@code {
    [Parameter]
    public ProcessScope? ProcessScope { get; set; }

    [Parameter]
    public RenderFragment<bool>? ChildContent { get; set; }

    private ProcessScope _processScope = null!;

    protected override void OnParametersSet()
    {
        base.OnParametersSet();

        if (_processScope == null)
        {
            _processScope = ProcessScope ?? new();
            _processScope.IsProcessingChanged += IsProcessingChanged;
        }
        else
        {
            if (ProcessScope != null && _processScope != ProcessScope)
            {
                throw new InvalidOperationException("ProcessScope の変更はサポートされていません。");
            }
        }
    }

    public void Dispose() => _processScope.IsProcessingChanged -= IsProcessingChanged;

    private void IsProcessingChanged(object? sender, EventArgs args) => StateHasChanged();
}

ここまで下準備すると、利用者側では複数ボタンに跨る処理中のステータスを以下のようにして共有できるようになります。RenderFragment<bool> で処理中かどうかの boolcontext として利用できるようにしているので処理中かどうかを見て見た目を変えることもできます。

@page "/"
@rendermode InteractiveServer

<PageTitle>Home</PageTitle>

<h1>カウンター</h1>

<p>Current count: @_currentCount</p>

@* 処理中の時は押せなくする *@
<ProcessScopeProvider>
    @if (context)
    {
        <p>処理中...</p>
    }

    <CustomButton OnClick="IncrementAsync">Increment</CustomButton>
    <CustomButton OnClick="DecrementAsync">Decrement</CustomButton>
</ProcessScopeProvider>

@code {
    private int _currentCount = 0;

    public async Task IncrementAsync()
    {
        // API 呼び出しなどの処理をシミュレート
        await Task.Delay(2000);
        _currentCount++;
    }

    public async Task DecrementAsync()
    {
        // API 呼び出しなどの処理をシミュレート
        await Task.Delay(2000);
        _currentCount--;
    }
}

二度押し防止フォーム

フォームも同じ要領で作れます。EditForm をラップするようなフォームを作れば OK です。
ちょっと難しいポイントとして OnSubmit を設定している状態で OnValidSubmitOnInvalidSubmit を設定すると EditForm は例外を投げるので、それに対応するためにちょっと複雑な処理をしています。

CustomEditForm.razor
<EditForm FormName="@FormName"
    EditContext="_editContext"
    OnSubmit="OnSubmitForEditForm"
    OnValidSubmit="OnValidSubmitForEditForm"
    OnInvalidSubmit="OnInvalidSubmitForEditForm">
    @ChildContent
</EditForm>

@code {
    [CascadingParameter]
    public ProcessScope? ProcessScope { get; set; }

    [Parameter]
    public RenderFragment? ChildContent { get; set; }

    // プロパティ
    [Parameter]
    public object? Model { get; set; }
    [Parameter]
    public EditContext? EditContext { get; set; }
    [Parameter]
    public string FormName { get; set; } = "";

    // イベント
    [Parameter]
    public EventCallback<EditContext> OnSubmit { get; set; }
    [Parameter]
    public EventCallback<EditContext> OnValidSubmit { get; set; }
    [Parameter]
    public EventCallback<EditContext> OnInvalidSubmit { get; set; }

    // EditForm に渡す各種パラメーター
    private EditContext _editContext = null!;
    // OnSubmit, OnValidSubmit, OnInvalidSubmit は設定されていないときには何も渡さないようにする必要があるのでちょっと複雑に…
    private EventCallback<EditContext> OnSubmitForEditForm =>
        OnSubmit.HasDelegate ? EventCallback.Factory.Create<EditContext>(this, HandleSubmit) : default;
    private EventCallback<EditContext> OnValidSubmitForEditForm =>
        OnValidSubmit.HasDelegate ? EventCallback.Factory.Create<EditContext>(this, HandleValidSubmit) : default;
    private EventCallback<EditContext> OnInvalidSubmitForEditForm =>
        OnInvalidSubmit.HasDelegate ? EventCallback.Factory.Create<EditContext>(this, HandleInvalidSubmit) : default;

    protected override void OnParametersSet()
    {
        base.OnParametersSet();

        if (_editContext == null)
        {
            // EditForm に渡す EditContext を作成
            _editContext = EditContext ?? new(Model ?? throw new InvalidOperationException("EditContext か Model プロパティのどちらかは必須です。"));
        }
        else
        {
            // プロパティの変更に応じて EditContext を更新
            if (EditContext != null && _editContext != EditContext)
            {
                _editContext = EditContext;
            }
            else if (Model != null && _editContext.Model != Model)
            {
                _editContext = new(Model);
            }
        }
    }

    private async Task HandleSubmit(EditContext editContext)
    {
        if (ProcessScope?.IsProcessing ?? false) return;
        using var scope = ProcessScope?.Create();
        await OnSubmit.InvokeAsync(editContext);
    }

    private async Task HandleValidSubmit(EditContext editContext)
    {
        if (ProcessScope?.IsProcessing ?? false) return;
        using var scope = ProcessScope?.Create();
        await OnValidSubmit.InvokeAsync(editContext);
    }

    private async Task HandleInvalidSubmit(EditContext editContext)
    {
        if (ProcessScope?.IsProcessing ?? false) return;
        using var scope = ProcessScope?.Create();
        await OnInvalidSubmit.InvokeAsync(editContext);
    }
}

早速ページに組み込んでみましょう。

@page "/"
@using System.ComponentModel.DataAnnotations
@rendermode InteractiveServer

<PageTitle>Home</PageTitle>

<h1>カウンター</h1>

<p>Current count: @_currentCount</p>

@* 処理中の時は押せなくする *@
<ProcessScopeProvider>
    @if (context)
    {
        <p>処理中...</p>
    }

    <CustomButton OnClick="IncrementAsync">Increment</CustomButton>
    <CustomButton OnClick="DecrementAsync">Decrement</CustomButton>

    <CustomEditForm Model="Model"
        OnValidSubmit="OnValidSubmit">
        <DataAnnotationsValidator />
        @if (!string.IsNullOrEmpty(SystemMessage))
        {
            <p>@SystemMessage</p>
        }
        <ValidationSummary />
        <InputText @bind-Value="Model.Name" />
        <CustomButton type="submit">Submit</CustomButton>
    </CustomEditForm>
</ProcessScopeProvider>

@code {
    private int _currentCount = 0;
    private MyFormItem Model { get; set; } = new();
    private string SystemMessage { get; set; } = "";

    public async Task IncrementAsync()
    {
        // API 呼び出しなどの処理をシミュレート
        await Task.Delay(2000);
        _currentCount++;
    }

    public async Task DecrementAsync()
    {
        // API 呼び出しなどの処理をシミュレート
        await Task.Delay(2000);
        _currentCount--;
    }

    public async Task OnValidSubmit()
    {
        // API 呼び出しなどの処理をシミュレート
        await Task.Delay(2000);
        SystemMessage = $"Submitted: {Model.Name}";
        Model = new();
    }

    class MyFormItem
    {
        [Required]
        public string Name { get; set; } = "";
    }
}

実行結果は以下のようになります。ちゃんと全体で実行中の状態共有がうまくいってますね。

コンポーネント カスタマイズのポイント

本題とはちょっとずれますが Blazor でコンポーネントをカスタマイズするときのポイントがあります。
Blazor で一般の人が使うコンポーネントは、あまり継承して拡張することを意図して作られていません。そのため、多くのケースではデフォルトの ComponentBase を継承するか、EditForm と連携する入力コンポーネントの場合は InputBase<T> を継承して作ることが殆どだと思います。

例えば EditForm 自体は、ほとんど継承した先に対してカスタマイズ可能なポイントを提供していません。そのため今回の例ではフォームを拡張したいというときに EditForm を継承するのではなく EditForm をラップするようなコンポーネントを作りました。

まとめ

ということで、二度押し防止のためのコンポーネントを作ってみました。もっといい方法があったら教えてください。

Microsoft (有志)

Discussion