从 ViewModel 弹出对话框:IMdDialogService

English | 简体中文

目的是让 ViewModel 不持有任何控件引用:它只说“我要显示这个对话框模型”,然后 await 一个返回值。视图由 MdDialogHost 的 DataTemplates 解析。

可运行的参考实现在 gallery/Md3.Avalonia.Gallery/Pages/DialogGalleryPage.axaml(.cs),下面的代码就是从那里来的。

1. 组合根:一个实例,两个身份

MdDialogHost.Service 要的是具体类型 MdDialogService,ViewModel 注入的是接口 IMdDialogService。必须是同一个实例。

var dialogs = new MdDialogService();

// 宿主(视图侧)
shell.DialogHost.Service = dialogs;

// 注入(ViewModel 侧)
services.AddSingleton<IMdDialogService>(dialogs);

不用 DI 容器也完全可以,构造函数直接传即可:

ServiceDialogHost.Service = dialogs;
ServiceDemoRoot.DataContext = new DialogServiceDemoViewModel(dialogs);

MdDialogService 对宿主持的是弱引用,所以不会让窗口泄漏;但也意味着宿主必须由可视树自己保活。整个 shell 放一个就够。

2. 宿主:放在 shell 顶层,并登记模板

MdDialogHost 是一个带遮罩层的容器控件,它的内容就是被它遮住的那一层。所以要把它放在应用外壳的最外层,而不是某个页面内部。

<md:MdDialogHost x:Name="DialogHost" ZIndex="100">

  <md:MdDialogHost.DataTemplates>
    <!-- 仓库开启了 AvaloniaUseCompiledBindingsByDefault,所以 DataType 和 x:DataType 两个都要写。 -->
    <DataTemplate DataType="{x:Type vm:ConfirmDeleteDialogModel}"
                  x:DataType="vm:ConfirmDeleteDialogModel">
      <md:MdDialog Headline="{Binding Headline}" Variant="Basic">
        <TextBlock MaxWidth="340" TextWrapping="Wrap" Text="{Binding Message}" />
        <md:MdDialog.Actions>
          <StackPanel Orientation="Horizontal" Spacing="8">
            <md:MdButton Content="Keep"   Variant="Text" Command="{Binding CancelCommand}" />
            <md:MdButton Content="Delete" Variant="Text" Command="{Binding ConfirmCommand}" />
          </StackPanel>
        </md:MdDialog.Actions>
      </md:MdDialog>
    </DataTemplate>

    <!-- 每种对话框模型一个模板,宿主按运行时类型选。 -->
    <DataTemplate DataType="{x:Type vm:RenameDialogModel}" x:DataType="vm:RenameDialogModel">
      ...
    </DataTemplate>
  </md:MdDialogHost.DataTemplates>

  <views:Shell />   <!-- 被遮罩的内容 -->
</md:MdDialogHost>

模板也可以放在 App.axaml 的 DataTemplates 里,继承作用域一样能解析到;放在宿主上只是便于就近阅读。

3. 调用方 ViewModel:只认服务

public sealed class DialogServiceDemoViewModel
{
    private readonly IMdDialogService _dialogs;

    public DialogServiceDemoViewModel(IMdDialogService dialogs)
    {
        _dialogs = dialogs;
        DeleteCommand = new AsyncRelayCommand(DeleteAsync);
    }

    public AsyncRelayCommand DeleteCommand { get; }

    private async Task DeleteAsync()
    {
        // 遮罩关闭、Escape、Android 返回手势或 token 取消时返回 default(此处即 false)。
        var confirmed = await _dialogs.ShowAsync<bool>(
            new ConfirmDeleteDialogModel(_dialogs, "Quarterly report"));

        Status = confirmed ? "Deleted." : "Kept.";
    }
}

4. 对话框自己怎么给出答案

对话框模型同样注入服务,用 Close(result) 回填 ShowAsync 的返回值:

public sealed class ConfirmDeleteDialogModel
{
    private readonly IMdDialogService _dialogs;

    public ConfirmDeleteDialogModel(IMdDialogService dialogs, string item)
    {
        _dialogs = dialogs;
        Headline = $"Delete {item}?";
        ConfirmCommand = new RelayCommand(() => _dialogs.Close(true));
        CancelCommand  = new RelayCommand(() => _dialogs.Close(false));
    }

    public string Headline { get; }
    public string Message => "This cannot be undone.";
    public RelayCommand ConfirmCommand { get; }
    public RelayCommand CancelCommand { get; }
}

5. 需要知道的几条行为

情况 行为
已有对话框显示时再 ShowAsync 排队,不抢占(Material 规定同时只有一个模态)。
宿主还没挂上就 ShowAsync 同样排队,不抛异常 —— 启动时序不用小心翼翼。
遮罩点击、Escape、Android 返回或 token 取消 挂起的 ShowAsync 以 null 完成;ShowAsync<T> 得到 default。
同一流程内换一步(about → 许可证、向导翻页) 用 ReplaceAsync,它插队到最前;用 ShowAsync 会排在当前这个后面,用户关掉才看得见。
从后台线程调用 服务内部会 marshal 回 UI 线程,ShowAsync<T> 的续体也回到 UI 线程。
不关心结果 使用 Show(dialog) 或 Replace(dialog) 即发即忘。

IsOpen 可读,用于“有对话框时禁用某些命令”这类判断。

6. 三条路线可以混用

MdDialogHost 同时支持三种驱动方式,按调用点选,不必全应用统一:

来源 写法 结果
视图 code-behind await DialogHost.ShowAsync(model) 返回值
ViewModel 注入 IMdDialogService,再 await _dialogs.ShowAsync(model) 返回值
纯绑定 双向 IsOpen 配合 Dialog ViewModel 状态,不 await