65.9K
CodeProject 正在变化。 阅读更多。
Home

简单的 Blazor 模态对话框实现

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.28/5 (10投票s)

2020年11月19日

CPOL

3分钟阅读

viewsIcon

46013

如何为 Blazor 构建模态对话框

代码仓库

代码仓库在此:模态对话框仓库

实现

概述

该实现包含两个接口、四个类和一个 enum

  1. IModalOptions
  2. IModalDialogContext
  3. ModalOptions
  4. ModalResult
  5. ModalResultType
  6. ModalDialogContext
  7. ModalDialogBase

示例代码使用标准的Blazor模板,展示了如何在FetchData中的WeatherForecast列表中打开一个编辑表单组件。

下面的代码演示了基本操作:如何在一个模态对话框中打开一个WeatherEditForm。该方法构建一个包含记录UidIModalOptions对象。它调用ShowAsync<WeatherForm>(options),指定要显示的表单组件以及该表单的选项,并等待返回的TaskTask直到模态框关闭才会完成。

private async Task EditAsync(Guid uid)
{
    if (_modal is not null)
    {
        var options = new BsModalOptions();
        options.ControlParameters.Add("Uid", uid);

        var result = await _modal.Context.ShowAsync<WeatherEditForm>(options);
        // Code to run after the Dialog closes 
    }
}

表单调用Close(modal result),这将完成Task,并且EditAsync将运行完毕。

private void Close()
{
    this.Modal?.Close(ModalResult.OK());
}

IModalOptions

IModalOptions定义了三种向对话框传递数据的方式。模态对话框实现可以使用通用的ModalOptions,也可以定义特定的IModalOptions

public interface IModalOptions 
{
    public Dictionary<string, object> ControlParameters { get; }
    public Dictionary<string, object> OptionsList { get; }
    public object Data { get; }
}

ModalOptions

IModalOptions的基本实现。

public class ModalOptions: IModalOptions
{
    public Dictionary<string, object> 
           ControlParameters { get; } = new Dictionary<string, object>();
    public Dictionary<string, object> OptionsList { get; } = 
                      new Dictionary<string, object>();
    public object Data { get; set; } = new();
}

ModalResult

ModalResult是一个返回记录,它向调用者提供状态和数据。

public sealed record ModalResult
{
    public ModalResultType ResultType { get; private set; } = ModalResultType.NoSet;
    public object? Data { get; set; } = null;
    public static ModalResult OK() => new ModalResult() 
                  { ResultType = ModalResultType.OK };

    //... lots of static constructors
}

以及ModalResultType

public enum ModalResultType { NoSet, OK, Cancel, Exit }

IModalDialogContext

ModalDialogContext在一个上下文类中封装了模态对话框组件的状态和状态管理。

IModalDialogContext定义了接口。

public interface IModalDialogContext
{
    public IModalOptions? Options { get; }
    public bool Display { get; }
    public bool IsActive { get; }
    public Type? ModalContentType { get; }
    public Action? NotifyRenderRequired { get; set; }

    public Task<ModalResult> ShowAsync<TModal>(IModalOptions options) 
           where TModal : IComponent;
    public Task<ModalResult> ShowAsync(Type control, IModalOptions options);
    public bool Switch<TModal>(IModalOptions options) where TModal : IComponent;
    public bool Switch(Type control, IModalOptions options);
    public void Update(IModalOptions? options = null);
    public void Dismiss();
    public void Close(ModalResult result);
}

ModalDialogContext

ModalDialogContext实现了IModalDialogContext,为模态对话框实现提供了样板代码。

它包含用于维护状态的属性以及显示、隐藏、切换和重置组件内容的各种方法。

显示:

  1. 确保传入的类型是组件,即实现了IComponent
  2. 设置状态。
  3. 调用回调函数通知组件进行渲染:这将显示对话框框架并创建内容组件。
  4. 使用TaskCompletionSource构建一个手动激活的Task,并将该Task返回给调用者进行await
protected TaskCompletionSource<ModalResult> _ModalTask 
    { get; set; } = new TaskCompletionSource<ModalResult>();

private Task<ModalResult> ShowModalAsync(Type control, IModalOptions options)
{
    if (!(typeof(IComponent).IsAssignableFrom(control)))
        throw new InvalidOperationException
        ("Passed control must implement IComponent");

    this.Options = options;
    this.ModalContentType = control;
    this.Display = true;
    this.NotifyRenderRequired?.Invoke();
    this._ModalTask = new TaskCompletionSource<ModalResult>();
    return this._ModalTask.Task;
}

Close:

  1. 清除状态。
  2. 调用回调函数通知组件进行渲染:这将隐藏对话框框架并销毁内容组件。
  3. 设置TaskCompletionSource以完成。如果调用者await了Show,则调用方法现在将运行完毕。
private void CloseModal(ModalResult result)
{
    this.Display = false;
    this.ModalContentType = null;
    this.NotifyRenderRequired?.Invoke();
    _ = this._ModalTask.TrySetResult(result);
}

Switch:

  1. 设置状态。
  2. 调用回调函数通知组件进行渲染:这将显示对话框框架以及新的内容组件。
private async Task<bool> SwitchModalAsync(Type control, IModalOptions options)
{
    if (!(typeof(IComponent).IsAssignableFrom(control)))
        throw new InvalidOperationException("Passed control must implement IComponent");

    this.ModalContentType = control;
    this.Options = options;
    await this.InvokeAsync(StateHasChanged);
    return true;
}

完整的类

public class ModalDialogContext : IModalDialogContext
{
    public IModalOptions? Options { get; protected set; }
    public bool Display { get; protected set; }
    public bool IsActive => this.ModalContentType is not null;
    public Action? NotifyRenderRequired { get; set; }
    private TaskCompletionSource<ModalResult> _ModalTask 
            { get; set; } = new TaskCompletionSource<ModalResult>();
    public Type? ModalContentType {get; private set;} = null;

    public Task<ModalResult> ShowAsync<TModal>(IModalOptions options) 
           where TModal : IComponent
        => this.ShowModalAsync(typeof(TModal), options);

    public Task<ModalResult> ShowAsync(Type control, IModalOptions options)
        => this.ShowModalAsync(control, options);

    public bool Switch<TModal>(IModalOptions options) where TModal : IComponent
        => this.SwitchModal(typeof(TModal), options);

    public bool Switch(Type control, IModalOptions options)
        => this.SwitchModal(control, options);

    public void Update(IModalOptions? options = null)
    {
        this.Options = options ?? this.Options;
        this.NotifyRenderRequired?.Invoke();
    }

    public void Dismiss()
        => this.CloseModal(ModalResult.Cancel());

    public void Close(ModalResult result)
        => this.CloseModal(result);

    private Task<ModalResult> ShowModalAsync(Type control, IModalOptions options)
    {
        if (!(typeof(IComponent).IsAssignableFrom(control)))
            throw new InvalidOperationException
                  ("Passed control must implement IComponent");

        this.Options = options;
        this.ModalContentType = control;
        this.Display = true;
        this.NotifyRenderRequired?.Invoke();
        this._ModalTask = new TaskCompletionSource<ModalResult>();
        return this._ModalTask.Task;
    }

    private bool SwitchModal(Type control, IModalOptions options)
    {
        if (!(typeof(IComponent).IsAssignableFrom(control)))
            throw new InvalidOperationException
                  ("Passed control must implement IComponent");

        this.ModalContentType = control;
        this.Options = options;
        this.NotifyRenderRequired?.Invoke();
        return true;
    }

    private void CloseModal(ModalResult result)
    {
        this.Display = false;
        this.ModalContentType = null;
        this.NotifyRenderRequired?.Invoke();
        _ = this._ModalTask.TrySetResult(result);
    }
}

ModalDialogBase

ModalDialogBase实现了模态对话框组件的样板代码。

它创建ModalDialogContext的一个实例,并在SetParametersAsync中设置回调:这确保了继承类不会无意中覆盖它。

public abstract class ModalDialogBase : ComponentBase
{
    public readonly IModalDialogContext Context = new ModalDialogContext();

    public override Task SetParametersAsync(ParameterView parameters)
    {
        parameters.SetParameterProperties(this);
        this.Context.NotifyRenderRequired = this.OnRenderRequested;
        return base.SetParametersAsync(ParameterView.Empty);
    }

    private void OnRenderRequested()
        => StateHasChanged();
}

VanillaModalDialog

VanillaModalDialog提供了一个基本的CSS样式模态对话框组件包装器。它具有

  1. 一个可点击的背景
  2. 可配置的宽度
  3. 使用DynamicComponent渲染请求的组件

VanillaModalDialog.razor

@namespace Blazr.ModalDialog.Components
@inherits ModalDialogBase
@implements IModalDialog

@if (this.Display)
{
    <CascadingValue Value="(IModalDialog)this">
        <div class="base-modal-background" @onclick="OnBackClick">
            <div class="base-modal-content" style="@this.Width" 
                 @onclick:stopPropagation="true">
                <DynamicComponent Type=this.ModalContentType 
                 Parameters=this.Options?.ControlParameters />
            </div>
        </div>
    </CascadingValue>
}

@code {
    private VanillaModalOptions modalOptions => 
            this.Options as VanillaModalOptions ?? new();

    protected string Width
        => string.IsNullOrWhiteSpace(modalOptions.ModalWidth) ? 
           string.Empty : $"width:{modalOptions.ModalWidth}";

    private void OnBackClick()
    {
        if (modalOptions.ExitOnBackgroundClick)
            this.Close(ModalResult.Exit());
    }
}

VanillaModalDialog.razor.css:

div.base-modal-background {
    display: block;
    position: fixed;
    z-index: 101; /* Sit on top */
    left: 0;
    top: 0;
    width: 100%; /* Full width */
    height: 100%; /* Full height */
    overflow: auto; /* Enable scroll if needed */
    background-color: rgb(0,0,0); /* Fallback color */
    background-color: rgba(0,0,0,0.4); /* Black w/ opacity */
}

div.base-modal-content {
    background-color: #fefefe;
    margin: 10% auto;
    padding: 10px;
    border: 2px solid #888;
    width: 90%;
}

BsModelDialog

BsModalDialog提供了一个Bootstrap样式的模态对话框组件包装器。

它有一个自定义的IModalOptions,您可以在其中设置模态框的大小。

public sealed class BsModalOptions: IModalOptions
{
    public string ModalSize { get; set; } = "modal-xl";
    public Dictionary<string, object> ControlParameters { get; } = 
           new Dictionary<string, object>();
    public Dictionary<string, object> OptionsList { get; } = 
           new Dictionary<string, object>();
    public object Data { get; set; } = new();
}

BsModalDialog.razor

@namespace Blazr.ModalDialog.Components
@inherits ModalDialogBase

@if (this.Context.Display)
{
    <CascadingValue Value="(IModalDialogContext)this.Context">
        <div class="modal show-modal" tabindex="-1">
            <div class="modal-dialog @this.Size">
                <div class="modal-content">
                    <div class="modal-body">
                        <DynamicComponent Type=this.Context.ModalContentType 
                         Parameters=this.Context.Options?.ControlParameters />
                    </div>
                </div>
            </div>
        </div>
    </CascadingValue>
}

@code {
    private BsModalOptions modalOptions => 
            this.Context.Options as BsModalOptions ?? new();
    protected string Size => modalOptions.ModalSize;
}

以及BsModalDialog.razor.css

.modal-body {
    padding: 0;
}

.show-modal {
    display: block;
    background-color: rgb(0,0,0,0.6);
}

演示

演示使用了FetchData页面,并为天气预报添加了一个模态对话框编辑器。您可以在仓库中查看所有代码,包括更新的WeatherForecastService

WeatherEditForm

WeatherEditFormWeatherForecast记录的编辑表单。

  1. 捕获级联的IModalDialogContext
  2. 如果不存在级联的IModalDialogContext,则抛出异常:该表单设计为在模态对话框上下文中运行。
  3. 使用EditStateTracker。这会跟踪编辑状态,详细信息请参见Blazr.EditStateTracker
  4. SaveClose中与模态框上下文进行交互。
// WeatherEditForm.razor

@inject WeatherForecastService DataService

<div class="p-3">

    <div class="mb-3 display-6 border-bottom">
        Weather Forecast Editor
    </div>

    <EditForm Model=this.model OnSubmit=this.SaveAsync>

        <DataAnnotationsValidator/>
        <EditStateTracker LockNavigation EditStateChanged=this.OnEditStateChanged />

        <div class="mb-3">
            <label class="form-label">Date</label>
            <InputDate class="form-control" @bind-Value=this.model.Date />
        </div>

        <div class="mb-3">
            <label class="form-label">Temperature &deg;C</label>
            <InputNumber class="form-control" @bind-Value=this.model.TemperatureC />
        </div>

        <div class="mb-3">
            <label class="form-label">Summary</label>
            <InputSelect class="form-select" @bind-Value=this.model.Summary>
                @if (model.Summary is null)
                {
                    <option disbabled selected value="null"> 
                     -- Select a Summary -- </option>
                }
                @foreach (var summary in this.DataService.Summaries)
                {
                    <option value="@summary">@summary</option>
                }
            </InputSelect>
        </div>

        <div class="mb-3 text-end">
            <button disabled="@(!_isDirty)" type="submit" 
             class="btn btn-primary" @onclick=SaveAsync>Save</button>
            <button disabled="@_isDirty" type="button" 
             class="btn btn-dark" @onclick=Close>Exit</button>
        </div>

    </EditForm>

</div>

<div class="bg-dark text-white m-4 p-2">
    <pre>Date : @this.model.Date</pre>
    <pre>Temperature &deg;C : @this.model.TemperatureC</pre>
    <pre>Summary: @this.model.Summary</pre>
    <pre>State: @(_isDirty ? "Dirty" : "Clean")</pre>
</div>

@code {
    [Parameter] public Guid Uid { get; set; }
    [CascadingParameter] private IModalDialogContext? Modal { get; set; }

    private WeatherForecast model = new();
    private bool _isDirty;

    protected override async Task OnInitializedAsync()
    {
        ArgumentNullException.ThrowIfNull(Modal);
        model = await this.DataService.GetForecastAsync(this.Uid) ?? new() 
                { Date = DateOnly.FromDateTime(DateTime.Now), TemperatureC = 10 };
    }
    
    private void OnEditStateChanged(bool isDirty)
        => _isDirty = isDirty;

    private async Task SaveAsync()
    {
        await this.DataService.SaveForecastAsync(model);
        this.Modal?.Close(ModalResult.OK());
    }

    private void Close()
        =>  this.Modal?.Close(ModalResult.OK());
}

以及FetchData

  1. 为每行添加一个Edit按钮。
  2. BsModalDialog组件添加到页面。
  3. 调用模态框组件的ShowAsync方法以打开模态对话框和Edit表单。
@page "/fetchdata"
@using Blazr.ModalDialog.Data
@inject WeatherForecastService ForecastService

<PageTitle>Weather forecast</PageTitle>

<h1>Weather forecast</h1>

<p>This component demonstrates fetching data from a service.</p>

<table class="table">
    <thead>
        <tr>
            <th>Date</th>
            <th>Temp. (C)</th>
            <th>Temp. (F)</th>
            <th>Summary</th>
            <th>Actions</th>
        </tr>
    </thead>
    <tbody>
        @foreach (var forecast in forecasts)
        {
            <tr>
                <td>@forecast.Date.ToShortDateString()</td>
                <td>@forecast.TemperatureC</td>
                <td>@forecast.TemperatureF</td>
                <td>@forecast.Summary</td>
                <td><button class="btn btn-sm btn-primary" 
                 @onclick="() => EditAsync(forecast.Uid)">Edit</button></td>
            </tr>
        }
    </tbody>
</table>

<BsModalDialog @ref=_modal />

@code {
    private IEnumerable<WeatherForecast> forecasts = 
                        Enumerable.Empty<WeatherForecast>();
    private BsModalDialog? _modal;

    protected override async Task OnInitializedAsync()
    {
        forecasts = await ForecastService.GetForecastAsync();
    }

    private async Task EditAsync(Guid uid)
    {
        if (_modal is not null)
        {
            var options = new BsModalOptions();
            options.ControlParameters.Add("Uid", uid);

            var result = await _modal.Context.ShowAsync<WeatherEditForm>(options);
        }
    }
}

总结

此实现演示了开发Blazor组件的多种技术和实践。

  1. 如何使用TaskCompletionSource来管理对话框的显示和隐藏。
  2. 将组件状态分离到上下文类中,以便您可以级联状态上下文而不是组件。
  3. 示例代码同时演示了编辑状态跟踪和导航锁定。

历史

  • 2020年11月19日:初始版本
  • 2023年3月25日:修订版2
© . All rights reserved.