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

C#/WPF/Silverlight 完整数独游戏

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.85/5 (35投票s)

2015年1月1日

GPL3

47分钟阅读

viewsIcon

56530

downloadIcon

5203

本文档讲述了如何使用 WPF 和 Silverlight 将一个 VB.NET WinForms 应用程序转换为 C#。

引言

在用 VB.NET 和 WinForms 编写了一个数独游戏后,我决定将其转换为 C#。为了增加趣味性,我决定使用 WPF 而不是 WinForms 来创建 UI 层。由于 Silverlight 是 WPF 的一个子集,我也想看看将完成的 WPF 代码转换为 Silverlight 有多容易。

本文档涵盖了我遇到的问题以及我找到的解决方案和变通方法。关于游戏的背景信息以及我在游戏玩法和使用的编程概念(如单例、事件等)方面做出的一些决定,请阅读原始文章:VB.Net 2013 完整 Windows 数独游戏

本文档可作为 WPF 和 Silverlight 编程的入门。我假设读者对 C# 和 WinForms 有一定的了解。对于已经了解 WPF 和 Silverlight 的人来说,本文档会相当枯燥。但请随意阅读,下载代码并随意使用。对改进或替代做法的任何建议都非常欢迎。

背景

Windows Presentation Foundation(简称 WPF)于 2006 年 11 月随 .NET 3.0 发布。WPF 本质上是一个渲染引擎,具有许多强大的功能,可让开发人员为独立的、浏览器托管的和手机应用程序创建视觉效果惊艳的客户端应用程序。在构建 WPF 前端时,我们使用 XAML(Extensible Application Mark-up Language)来描述 UI。然后,WPF 引擎会解释 XAML 并根据项目输出 UI 到 Windows 应用程序、浏览器或 Windows 应用商店/手机应用。

实际上,Silverlight 和 Windows 应用商店应用是 WPF 的子集。因此,适用于 Windows 应用程序的同一 XAML 在输出到 Silverlight 应用的浏览器或 Windows Phone 时可能不起作用。

在 WPF 中构建 UI 时,我们不是在设计器窗口的属性页面上设置控件属性,而是在 XAML 代码窗口中直接设置属性。这与编写 VB.NET 或 C# 的 WinForms 应用程序完全不同。如果输入 XAML 代码令人望而生畏,IDE 还提供了一个属性工具窗口,可以在其中修改属性。刚开始编写 WPF 应用程序时,最好使用属性工具窗口,以便熟悉所有可用的属性。

网上有很多教程、示例代码和帮助资源可以帮助您开始使用 WPF。这是微软官方网站上关于 WPF 的链接:WPF 简介。在本文中,我将重点介绍一些与编写游戏相关的 WPF 要点。

对于这个项目,我尝试遵循 MVVM(Model-View-View Model)模式,这是编写 WPF 应用程序推荐的模式,而不是像在其他项目中那样使用 MVC 编程模式。这是从微软网站截取的图表,详细说明了 MVVM 模式各部分之间的流程。请注意,与 MVC 模式相比,View 和 View Model 之间多了一个“数据绑定”链接。

在 MVC 模式中,业务逻辑通常保留在 Controller 中。在 MVVM 模式中,业务逻辑被推送到 Model 层。由于原始代码是使用 MVC 模式编写的,我将大部分业务逻辑保留在 VM 层。网上有很多文章更详细地介绍了这种编程模式,以及 MVC 和 MVVM 编程模式之间的区别。

代码也按照 MVVM 模式组织。我创建了三个文件夹,分别命名为 Model、View 和 View Model,并将代码相应地进行了拆分。

WPF 编程和 MVVM 编程模式非常适合将 UI 设计与业务逻辑分开,并允许专家团队构建复杂应用程序的不同部分。图形设计师可以使用 Expression Blend 或 Visual Studio 等工具构建视觉效果丰富的 UI,而无需了解如何编写任何 C# 或 VB 代码。开发人员则可以专注于编写业务逻辑和数据层,而无需考虑数据将如何呈现。显然,两个团队之间应就所需数据的类型达成一致。

我认为掌握 WPF 的关键之一是理解数据绑定。WPF 数据绑定允许 UI 元素(如文本框)绑定到 VM 层中的属性。如果做得正确,当属性更改时,UI 也会自动更改。这里有一个微软网站上的链接,解释了数据绑定以及示例。当然,我们可以像过去 WinForms 时代那样在代码隐藏中直接寻址控件。但这与 WPF 的理念背道而驰,并且需要比数据绑定更多的代码隐藏。

Using the Code

这些程序是使用 VS 2013 和 4.5 .NET 框架/Silverlight 5 编写的。它们是完整的代码。我包含了 WPF 和 Silverlight 项目。您应该能够分别下载和编译这两个项目。

通常,当一个人在两个或多个项目之间共享代码时,会从一个项目将文件复制到另一个项目,或者将文件从第一个项目添加到第二个项目。这使得代码维护更加困难,因为存在同一文件的多个副本。

从 VS 2010 开始,可以通过单击“添加现有项”对话框上的向下箭头来添加指向原始代码文件的链接。

但是,对于这两个项目,我没有使用此功能。因此,每个 ZIP 文件都包含所有必需的文件,您可以下载您感兴趣的任何项目,而无需下载其他项目。

从 VB.NET 转换为 C#

除了语法差异,将代码从 VB.NET 移植到 C# 相当直接,因为它们都运行在相同的 CLR 之上。

除了将代码从 VB.NET 移植到 C# 之外,我还对数组寻址方式做了一些更改。在我的 VB 代码中,数组使用从 1 到 9 的索引进行寻址。我这样做是因为游戏的可接受答案是数字 1 到 9。然而,在 C# 代码中,由于 C 是一种零基语言,我决定与之保持一致,并将所有内容都更改为零基。可接受答案仍然是 1 到 9。这花了一点时间,但并不难。

将 UI 从 WinForms 转换为 WPF

将代码移植到 C# 后,下一步是将 UI 从 WinForms 转换为 WPF。这需要更多的努力。要做的第一个决定是使用哪种类型的 Panel 对象作为基础。在 WinForms 中,只有一个空白表单,您可以开始在表单上放置控件来构建 UI。但在 WPF 中,有几种不同类型的背景或 Panel 对象可以用来构建 UI,每种对象都有不同的特性。因此,为项目选择正确的对象对整体成功至关重要。这里有一个网站的链接,介绍了这些差异。默认是 Grid

由于我可能想在后台实现烟花展示,我决定使用 Canvas 作为主窗口的背景。Canvas Panel 允许我直接在控件上绘图。然后,我添加了所有必需的控件,使其看起来像原始的 VB 版本。

WPF 和 WinForms 都有按钮、组合框、标签和复选框。所以很容易复制。

另一方面,游戏网格是一个挑战,因为 WPF 没有类似的 TableLayoutPanel 控件。另一方面,WPF 有两种网格:DataGridGrid 网格。我认为 DataGrid 更适合以表格格式显示可滚动的项目列表。由于我对滚动不感兴趣,并且数据大小是固定的,所以我选择了使用 Grid 控件。

我将一个 Grid 控件添加到我的窗体,然后将其拆分为 3 行 3 列。在每个单元格中,我添加了另一个 Grid 控件,并将其拆分为 3 行 3 列。然后我添加了边框和矩形,并更改了背景颜色来构建游戏网格。由于没有线条对象,我使用了高度(或宽度)为一像素的矩形来表示游戏网格上的线条。

下一个问题是底部的状态栏。WPF 也有一个 StatusBar 控件,但它的工作方式与 WinForms StatusBar 不同。不是使用 Items Collection Editor 向 Status Bar 添加项,而是在 XAML 代码中直接添加项。

这是 WPF 主窗口的最终效果图

与原始 VB 版本相比,您会发现一些不同之处是,提示清除按钮已删除。我将这些按钮移到了 Input Pad 对话框。这样做的原因是,提示清除是更偏向于单元格的操作,而不是游戏本身的操作。因此,它们实际上属于包含单元格特定操作的 Input Pad 窗口。对于“输入备注”复选框,也可以提出同样的论点。但有时用户希望为多个单元格输入备注,而不仅仅是单个单元格。因此,我将其保留在主窗口上。

下一步是为按钮连接事件操作。在按钮的 XAML 部分,我只是将 Click 事件指定为 XML 属性

    <Button Content="Close" 
            Style="{StaticResource ButtonBaseStyle}"
            Margin="430,411,0,0" 
            Click="btnClose_Click"/>

代码隐藏看起来是这样的,与 WinForms 事件类似

    private void btnClose_Click(object sender, RoutedEventArgs e)
    {

    }

我现在将代码隐藏的详细信息留空。一旦我准备好处理 View Model,我将添加代码来将它们连接起来。

WPF 和数据绑定

在为每个按钮连接了操作或事件代码之后,下一步是将 UI 连接到数据。请注意,我根本没有提到复选框。这是因为我们可以使用 WPF 数据绑定来设置复选框并执行其他很酷的操作。稍后我将详细介绍。

我可以轻松地遵循 WinForms 的约定,直接在窗体的代码隐藏中将数据分配给控件,如下所示

    this.ElapsedTime.Content = "00:00:00";

既然 WPF 拥有强大的工具来连接 UI 和数据,我将使用它们。

它始于 ViewModel 类并添加 INotifyPropertyChanged 接口,这是 System.ComponentModel 命名空间的一部分。

using System.ComponentModel;

internal class ViewModelClass : INotifyPropertyChanged

其他相关的声明如下。我声明了事件,然后编写代码来引发事件。

    public event PropertyChangedEventHandler PropertyChanged;

    protected void OnPropertyChanged([CallerMemberName] string propertyName = "")
    {
        if (PropertyChanged != null)
            PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
    }

用法如下

    public string GameCountVeryEasy
    {
        get
        {
            return GetGameCount(DifficultyLevels.VeryEasy);
        }
        private set
        {
            OnPropertyChanged();
        }
    }

set 访问器只有一行代码,因为我们不需要在 ViewModel 类中保存实际值。当我们需要它时,get 访问器调用 GetGameCount 来获取存储的值。

然后在 XAML 中,调用方式如下

    <Label Content="{Binding Path=GameCountVeryEasy, Mode=OneWay}"/>

很简单,对吧?实际上,我还有一些东西没讲。首先,在 XAML 标头声明中,我需要指定属性 GameCountVeryEasy 所在的命名空间。为此,我需要将以下行添加到顶部的 <Window 标记中

    xmlns:srcVM="clr-namespace:SudokuWPF.ViewModel"

然后,在窗体的代码隐藏中,我需要添加以下行

    this.DataContext = _viewModel;

通常,它在 View Model 类初始化时添加,无论是窗体的构造函数中,在 InitializeComponent() 之后,还是接近该位置。这告诉 UI 哪个 View Model 类的实例包含实际数据。有趣的是,我无需在 XAML 代码中指定属性所在的类的实际名称。这就是 DataContext 的作用。它告诉 WPF 在哪里查找绑定到 UI 上控件的属性。

让我更详细地解释一下上面代码中一些更有趣的点。首先是 OnPropertyChanged 方法参数中的 [CallerMemberName] 属性。

    protected void OnPropertyChanged([CallerMemberName] string propertyName = "")

此属性允许 OnPropertyChanged 方法获取调用该方法的调用者的名称或属性名称。这是 .NET 4.5 的新功能,属于 System.Runtime.CompilerServices 命名空间。这是微软网站上的链接,更详细地描述了此属性。

C# 中的另一个新概念是创建可选参数的能力。这是 VB 世界多年来一直拥有的功能。因此,对于那些已经熟悉 VB 的可选参数的人来说,上面的语法已经很熟悉了。唯一的区别是没有 Optional 限定符。简单来说,当我们向参数声明的末尾添加 = "" 时,它会将该参数转换为可选参数。空字符串仅仅意味着如果参数在调用中被省略,这就是默认值。任何有效的 string 都可以用作默认值。有关可选参数的更多信息,请访问微软网站

通常,当我们调用 OnPropertyChanged 时,我们必须传递属性的名称,以便绑定到该属性的相应 WPF 元素知道它已更改。例如

    OnPropertyChanged("GameCountVeryEasy");

通过使用 [CallerMemberName] 属性和可选参数,如果调用来自实际属性 GameCountVeryEasy,我们可以从调用中省略 propertyName 参数,因为 [CallerMemberName] 会自动为其填充。

在 C# 中,命名空间使用代码页面开头的 using 关键字声明。XAML 使用 xmlns:。在以下 XAML 命名空间声明中

    xmlns:srcVM="clr-namespace:SudokuWPF.ViewModel"

这一切的含义如下。xmlns: 只是允许我们指定命名空间的 XML 属性。srcVM 是一个快捷名称,如果我们需要引用该命名空间中的类、属性或方法,可以在 XAML 代码中使用它。我可以将其称为 viewModel, vm, 或任何有效名称。最后,string 的内容是实际命名空间的名称。XAML 编辑器在输入最后一个双引号时会自动添加 "clr-namespace:"。输入第一个引号时,IntelliSense 会弹出一个下拉列表,其中包含当前项目中的所有有效命名空间。

WinForms 和 WPF 之间又一个巨大的区别。在 WPF 中,不一定需要为窗体上的所有元素命名。所以我们的标签控件只有一个 Content 属性。绑定代码是将此元素与数据源关联起来,我们将通过代码更新此元素。

    <Label Content="{Binding Path=GameCountVeryEasy, Mode=OneWay}"/>

总的来说,如果不需要从代码寻址控件,那么就没有必要给它命名。数据绑定就足够了。Binding 是一个关键字,告诉 XAML 解释器后面是绑定代码。Path= 指定由 DataContext 指定的属性。Mode=OneWay 指定绑定从 View Model 到 View,换句话说,是一个只读属性。如果未指定,则绑定默认为 TwoWay。这是微软网站上关于 WPF 数据绑定的内容。

因此,通过上面的所有连接,当我们想要用新值更新标签 GameCountVeryEasy 时,我们只需要在 ViewModel 类中调用以下代码。

    GameCountVeryEasy = "4";

接下来发生的是,在属性的 set 访问器中,调用 OnPropertyChanged 方法,并引发 PropertyChanged 事件。正在监听所有 PropertyChanged 事件的 WPF 引擎被触发。然后它查找相应的数据绑定。一旦找到,WPF 就会调用属性的 get 访问器,并将标签分配给 4string 值。

要绑定的属性可以位于项目中的几乎任何类中。它不必局限于 ViewModel 类。事实上,我还将游戏单元格绑定到 Model 类中的属性。您只需要添加 INotifyPropertyChanged 接口和相关代码。然后只需在 XAML 中绑定它。

这可能看起来不那么令人印象深刻,因为下面的内容是 WinForms 等效代码。在 WinForm 的代码隐藏中,我们会编写以下代码

    private delegate void SetGameCountCallback(Int32 value);

    internal void SetLabel(Int32 value)
    {
        if (label1.InvokeRequired)
        {
            SetGameCountCallback callback = new SetGameCountCallback(SetLabel);
            this.Invoke(callback, new object[] { value });
        }
        else
            label1.Text = value.ToString();
    }

GameCountVeryEasy 属性的 set 访问器中的代码将是

   Form1.SetLabel(value);

我们不需要 get 访问器,因为我们已经将值分配给了窗体上的标签。

表面上看,WinForms 看起来比 WPF 更简单,但 WPF 数据绑定可以做的不仅仅是设置标签的内容。此外,我们必须为我们想要更新的每一个控件复制这段代码。当然,我们可以编写这个方法的通用版本,其中我们传入需要更新的控件。

在 WinForms 中,我们需要调用标签的 InvokeRequired 方法来确保我们正在进行线程安全的调用。在 WPF 中,我们不需要检查调用是否线程安全。WPF 会为我们处理这一切。所以我们只需要引发 PropertyChanged 事件,WPF 就会处理其余的事情。我还应该提到,OnPropertyChanged 方法可以从类中的任何位置调用。只要将正确的属性名称传递给方法,它就会被更新。我在我的项目中使用了这种技术,稍后我会谈到它。

数据绑定和复选框

这是 WPF 数据绑定的真正魔力。对于复选框,我们在 ViewModel 类中创建属性来存储复选框状态。例如,对于 EnterNotes 复选框,我们在 ViewModel 类中具有以下属性定义

    public bool IsEnterNotes
    {
        get
        {
            return _isEnterNotes;
        }
        set
        {
            _isEnterNotes = value;
            OnPropertyChanged();
        }
    }

该属性很容易理解。我们返回或保存复选框的状态。当我们保存状态时,我们还调用 OnPropertyChanged 方法来引发 PropertyChanged 事件。

复选框 XAML 声明如下

    <CheckBox Style="{StaticResource CheckboxBaseStyle}"
              IsChecked="{Binding Path=IsEnterNotes}"
              Content="Enter Notes" 
              Margin="23,420,0,0" />

IsChecked 属性绑定到 ViewModelIsEnterNotes 属性。由于我们希望在用户单击复选框时知道,所以我们希望绑定是双向的。因此,绑定模式使用默认的 TwoWay。因为复选框的 IsChecked 属性已绑定,所以我们无需在代码隐藏中连接 Click 事件。这样就少了一件需要担心的事情。但是,我们在 WinForms 版本中使用的任何 Click 事件代码都将转到 IsEnterNotes 属性的 set 访问器中。

WPF 样式

WPF 的一个强大功能是能够为一组控件(如按钮)创建样式。因此,我们可以创建这样的 Style

    <Style x:Key="ButtonBaseStyle"
           TargetType="Button">
        <Setter Property="HorizontalAlignment" Value="Left" />
        <Setter Property="VerticalAlignment" Value="Top" />
        <Setter Property="Width" Value="140" />
        <Setter Property="Height" Value="30" />
    </Style>

并将其应用于所有按钮,使它们看起来都一样

    <Button Content="New Game" 
            Style="{StaticResource ButtonBaseStyle}"
            Margin="430,70,0,0" 
            Click="btnNew_Click"/>
    <Button Content="About Sudoku WPF"
            Style="{StaticResource ButtonBaseStyle}"
            Margin="430,376,0,0" 
            Click="btnAbout_Click"/>
    <Button Content="Close" 
            Style="{StaticResource ButtonBaseStyle}"
            Margin="430,411,0,0" 
            Click="btnClose_Click"/>

<Style></Style> 声明可以放在项目中的几个位置。它可以位于单独的资源文件中,以便用户可以选择不同的主题。它可以放在容器的资源部分,或者在我的情况下,放在容器正上方的资源部分。因此,在我 <Canvas></Canvas> 标签上方,我添加了 <Window.Resources></Windows.Resources> 标签并将样式插入其中。如果样式放在 <Application.Resources></Application.Resources> 部分或单独的资源文件中,则作用域将扩展到整个应用程序。但由于我将其放在我正在使用的窗口的 <Window.Resources></Windows.Resources> 部分,因此作用域仅限于该窗口。

再次注意,这些按钮都没有命名。

能够创建样式的好处是,我可以在窗口中使用相同的样式应用到所有按钮。如果需要更改某些内容,只需更改样式,所有按钮都会反映这些更改。在 WinForms 中,如果需要更改某项内容,我需要选择所有按钮,然后更改属性。

如果您注意到我的 Style XAML,我向 Style 添加了一个 x:key 属性。如果我省略此属性,此样式将应用于所有按钮,并且我可以从按钮声明中删除以下行。

    Style="{StaticResource ButtonBaseStyle}"

但是,由于我需要为重置开始按钮应用不同的样式,所以我添加了 x:Key 属性。原因是,这两个按钮在游戏进行的不同阶段都有一些特殊状态。例如,对于开始按钮,如果用户刚刚加载了一个新游戏,按钮将显示“开始游戏”。游戏进行中时,它将更改为“暂停”等。重置按钮也是如此。加载新游戏或暂停游戏时,重置按钮将处于禁用状态,依此类推。

要编写这些状态的代码,我们可以向窗体的代码隐藏添加代码。但是,这将绕过 WPF 的另一个很棒的功能。

WPF 样式触发器

在前一节中,我提到开始重置按钮的状态会根据用户在游戏中的位置而变化,并且我们可以使用 WPF 管理按钮的状态。为此,我们使用 Style Triggers。这是一个例子

<Style x:Key="EnableGameButtonStyle"
       TargetType="Button"
       BasedOn="{StaticResource ButtonBaseStyle}">
    <Style.Triggers>
        <DataTrigger Binding="{Binding Path=IsEnableGameControls, Mode=OneWay}" Value="True">
           <Setter Property="IsEnabled" Value="True" />
        </DataTrigger>
        <DataTrigger Binding="{Binding Path=IsEnableGameControls, Mode=OneWay}" Value="False">
            <Setter Property="IsEnabled" Value="False" />
        </DataTrigger>
    </Style.Triggers>
</Style>

我向 ViewModel 类添加了以下布尔属性

    public bool IsEnableGameControls
    {
        get
        {
            return _isEnableGameControls;
        }
        private set
        {
            _isEnableGameControls = value;
            OnPropertyChanged();
        }
    }

请注意,在 set 访问器中,我调用了 OnPropertyChanged

然后,我创建了一个新的 Style,它基于上面的 ButtonBaseStyle,因为我想让这些按钮保持相同的外观。然后我添加了两个触发器,分别基于该属性的可能值:TrueFalse。基本上,如果属性为 true,则按钮已启用。如果为 false,则禁用按钮。

让我们逐行分析这个样式。我添加了一个 x:Key 属性到样式,以便我可以从重置按钮引用它。由于此样式将用于按钮,因此我添加了 TargetType 属性。然后我添加了 BasedOn 属性,因为我希望重置按钮看起来像窗体上的所有其他按钮。然后我添加了 Triggers。语法非常直接。我需要将触发器绑定到 ViewModel 类中的一个属性,然后设置触发器要查找的值。在触发器标签内,我设置了当属性值为 TrueFalse 时我想要更改的按钮的属性。对于重置按钮,我只想启用或禁用该按钮。

在绑定声明中,我添加了 Mode=OneWay 属性,因为更新仅从 View Model 流向 UI。UI 永远不会更新该特定属性。因此,该属性在 View Model 类中声明为只读。

要使用 Style重置按钮声明如下

    <Button Content="Reset Game"
            Style="{StaticResource EnableGameButtonStyle}"
            Margin="430,190,0,0"
            Click="btnReset_Click"/>

游戏网格下方的 CheckBoxes 也使用了 IsEnableGameControls 属性,样式和触发器语法也类似。同样,我添加了另一个名为 IsShowGameGrid 的属性,该属性控制是否显示游戏网格。

要启用或禁用游戏控件,我只需在 ViewModel 中调用 IsEnableGameControls 属性。

    IsEnableGameControls = true;

所有绑定到该属性的控件都将受到影响。要在 WinForms 中实现相同的功能,我将不得不编写类似以下的代码。我还必须用 InvokeRequired 包装它以确保调用是线程安全的。

    private void EnableGameButtons(bool bEnable)
    {
        this.EnterNotesCheckbox.IsEnabled = bEnable;
        this.ShowNotesCheckbox.IsEnabled = bEnable;
        this.ShowSolutionCheckbox.IsEnabled = bEnable;
        this.ResetButton.IsEnabled = bEnable;
    }

由于我只是在更改受影响控件的 IsEnabled 属性,并且 IsEnabledIsEnableGameControls 都是布尔属性,所以我可以直接将每个控件的 IsEnabled 属性绑定到 ViewModelIsEnableGameControls 属性,这样效果与使用 Style Triggers 相同。声明如下

    IsEnabled="{Binding Path=IsEnableGameControls, Mode=OneWay}"

开始按钮的 Style 声明更复杂,因为它有多个状态。但语法类似。

    <Style x:Key="StartButtonStyle"
           TargetType="Button"               
           BasedOn="{StaticResource ButtonBaseStyle}">
        <Style.Triggers>
            <DataTrigger Binding="{Binding Path=StartButtonState, Mode=OneWay}" Value="Start">
                <Setter Property="Content" Value="Start Game"/>
                <Setter Property="IsEnabled" Value="True"/>
            </DataTrigger>
            <DataTrigger Binding="{Binding Path=StartButtonState, Mode=OneWay}" Value="Pause">
                <Setter Property="Content" Value="Pause Game"/>
                <Setter Property="IsEnabled" Value="True"/>
            </DataTrigger>
            <DataTrigger Binding="{Binding Path=StartButtonState, Mode=OneWay}" Value="Resume">
                <Setter Property="Content" Value="Resume Game"/>
                <Setter Property="IsEnabled" Value="True"/>
            </DataTrigger>
            <DataTrigger Binding="{Binding Path=StartButtonState, Mode=OneWay}" Value="Disable">
                <Setter Property="Content" Value="Start Game"/>
                <Setter Property="IsEnabled" Value="False"/>
            </DataTrigger>
        </Style.Triggers>
    </Style>

每个触发器都绑定到 View Model 类中的 StartButtonState 属性。由于 StartButtonState 属性的状态比简单的布尔值多,因此我们为每个状态包含一个触发器。在每个触发器标签内,我们设置我们想要为每个状态更改的属性。我没有包含以下内容...

    <Setter Property="IsEnabled" Value="True"/>

...在前三个触发器标签中,因为此属性的默认值已经是 True 并且我没有更改任何内容。但我添加了它们以便于阅读。

同样,我们可以在 ViewModel 中创建返回内容 string 和每个 StartButtonState 的启用状态的属性,并将开始按钮的 ContentIsEnabled 属性绑定到这些属性。然后,我们可以绕过 Style Triggers 的使用。

这是 ViewModel 中的修改后的 StartButtonState 属性以及 StartButtonContentIsEnableStartButton 属性

    private StartButtonStateEnum StartButtonState
    {
        get
        {
            return _startButtonState;
        }
        set
        {
            _startButtonState = value;
            OnPropertyChanged("StartButtonContent");
            OnPropertyChanged("IsEnableStartButton");
        }
    }

    public string StartButtonContent
    {
        get
        {
            switch (StartButtonState)
            {
                case StartButtonStateEnum.Pause:
                    return "Pause Game";

                case StartButtonStateEnum.Resume:
                    return "Resume Game";
                        
                default:
                    return "Start Game";
            }
        }
    }

    public bool IsEnableStartButton
    {
        get
        {
            return (StartButtonState != StartButtonStateEnum.Disable);
        }
    }

开始按钮的 XAML 代码如下

    <Button Style="{StaticResource ButtonBaseStyle}"
            Content="{Binding Path=StartButtonContent, Mode=OneWay}"
            IsEnabled="{Binding Path=IsEnableStartButton, Mode=OneWay}"
            Margin="430,105,0,0"
            Click="btnStart_Click"/>

我们将 Style 改为指向 ButtonBaseStyle,然后添加 ContentIsEnabled 属性,并将它们绑定到 ViewModel 类中的相应属性。

这里最重要的是 StartButtonState 属性的 set 访问器中,我们调用了 StartButtonContentIsEnableStartButton 属性上的 OnPropertyChanged 方法。

个人认为,这两种方法都可以,而且我认为一种并不比另一种更好。但是,使用 Style Triggers 的代码量会少一些。然而,这会将部分业务逻辑放在 UI 中,而不是 ViewModel 中。因此,如果我们严格遵循 MVVM 编程模式,那么第二种方法可能是首选方法。这样,UI 层就不会包含任何业务逻辑。

WPF 和组合框

接下来是如何用 Enum 的内容填充 ComboBox。如果您在 Google 上搜索,有很多关于如何做到这一点的示例。让我们使用微软官方网站上的这个示例。首先,我们需要声明 enum 所在的命名空间。在此项目中,Enum 位于以下命名空间

    xmlns:srcME="clr-namespace:SudokuWPF.Model.Enums"

然后,在 Windows 资源部分,我们声明以下内容

    <ObjectDataProvider x:Key="GameLevels"
                        ObjectType="{x:Type sys:Enum}"
                        MethodName="GetValues">
        <ObjectDataProvider.MethodParameters>
            <x:Type TypeName="srcME:DifficultyLevels" />
        </ObjectDataProvider.MethodParameters>
    </ObjectDataProvider>

这个 XAML 对象实际上是在 Enum 类的 GetValues 方法周围创建了一个包装器。在 ComboBox 声明中,我们将 ItemsSource 属性指向此对象,如下所示

    <ComboBox HorizontalAlignment="Left" 
              VerticalAlignment="Top"
              Width="140"
              ItemsSource="{Binding Source={StaticResource GameLevels}}"
              SelectedItem="{Binding GameLevel}"
              IsEditable="False"
              IsReadOnly="True"
              Canvas.Left="430"
              Canvas.Top="32"/>

很简单,对吧?嗯,不完全是。我们有一个 enum 是“VeryEasy”,它将显示为“VeryEasy”,而不是“Very Easy”,中间有一个空格。为了解决这个问题,网上有很多关于如何解决这个问题以及如何创建工具提示和本地化 enum 值的示例。但这对于这个项目来说有点太多了,所以我们还是坚持使用这种方法,因为它易于阅读,而且缺失的空格并不是很明显。

SelectedItem 属性绑定到 ViewModel 类中的另一个属性。在这种情况下,我们使用默认的 TwoWay 模式,因为我们希望更改是双向的。也就是说,如果用户更改了选择,我们希望 View Model 能够感知并对其做出反应。当游戏首次加载时,我们希望能够将关卡设置为上次选择的关卡。

    public DifficultyLevels GameLevel
    {
        get
        {
            return _gameLevel;
        }
        set
        {
            bool bLoadNewGame = (_gameLevel != value);
            _gameLevel = value;
            Properties.Settings.Default.Level = _gameLevel.GetHashCode();
            if (bLoadNewGame)
                LoadNewGame();
            OnPropertyChanged();
        }
    }

由于我们绑定了 SelectedItem 属性,所以我们无需在组合框中添加 SelectionChanged 属性和相应的代码隐藏事件处理程序。相反,通常的 SelectionChanged 事件代码可以在此属性的 set 访问器中找到。

您在查看代码时会注意到的一点是,所有绑定的属性都声明为 public,而不是我偏好的访问修饰符 internal。这是因为 WPF 要求该级别的访问。如果声明为 internal,则 XAML 数据绑定将失败。对于此属性,甚至 DifficultyLevels enum 也必须声明为 public

游戏单元格

现在我们或多或少地处理了外围控件,我们如何管理游戏单元格的状态?我们是使用样式触发器吗?或者是否有更强大的 WPF 工具可供我们使用?事实证明,有。WPF 有一个 DataTemplate 类。我们使用它而不是简单的样式触发器,是因为每个游戏单元格可以显示两种主要类型的数据。一个单独的值表示答案或用户的答案。另一种状态是显示备注。但是,我们仍然可能可以使用样式触发器来实现相同的效果。

首先,每个游戏单元格都声明为一个 ContentControl 控件。ContentControl 是 WPF 中几乎所有控件的基础类。它表示一个具有任何类型单个内容的控件。使用 ContentControl,我们可以构建自己的自定义控件来表示游戏单元格。

Windows.Resources 部分,我们创建游戏单元格的 DataTemplate。游戏单元格的基础是一个 3 x 3 的 Grid,以便我们可以显示单元格的备注。在每个网格中,我们放置一个 TextBlock 元素来显示实际的备注。如果显示的是备注以外的内容,我们就使用另一个跨越整个网格区域的 TextBlock

高亮显示单元格

网格的每个单元格都有一个 IsMouseDirectlyOver 属性,我们可以使用它来添加样式触发器,以便在用户将鼠标悬停在单元格上时对其进行高亮显示。以下是我使用的样式触发器。我排除了其余的样式,因为它并不那么有趣。

    <Style.Triggers>
        <Trigger Property="IsMouseDirectlyOver" Value="True">
            <Setter Property="Background" Value="LightBlue" />
        </Trigger>
    </Style.Triggers>

有了这个触发器,每当用户将鼠标移到游戏单元格上时,background 就会从 LightBlue 变为 CadetBlue。这适用于 backgroundAliceBlue 还是 White

WPF 和 DataTemplates

那么,我们如何处理不同的游戏单元格状态呢?在原始 VB 版本中,我使用了两个属性来控制单元格状态

  • CellState
  • IsCorrect

虽然有 MultiDataTrigger,但我认为结合这两个属性并相应地修改 CellStateEnum 更容易。此外,我还需要更改备注的工作方式。我添加了另一个类来表示每个单元格备注的状态,以便每个备注单元格都可以绑定到显示备注的属性。

请查看 MainWindow XAML 代码中的 CellDataTemplate,看看我做了什么。

我还必须将 INotifyPropertyChanged 接口添加到 CellClassNoteState,因为这两个类都有绑定到 CellDataTemplate 中控件的属性。

NoteState 类中,我有以下属性声明

    public bool State
    {
        get
        {
            return _state;
        }
        set
        {
            _state = value;
            OnPropertyChanged("Value");
        }
    }

这段代码的作用是,在 set 访问器中,当备注的状态改变时,我们为 Value 属性引发 PropertyChanged 事件。这是因为 Value 属性绑定到 MainWindow 上的一个控件,而不是 State 属性。这是一个引发与绑定属性相关的属性的 PropertyChanged 事件的示例。另外,在 ViewModel 类中,我们有以下函数

    private void UpdateAllCells()
    {
        if (IsValidGame())
            foreach (CellClass item in _model.CellList)
                OnPropertyChanged(item.CellName);
    }

每当我们想更新游戏网格中的所有单元格时,我们都会调用此方法。这是另一个在绑定到 WPF 控件的实际属性外部引发 PropertyChanged 事件的示例。

输入面板

对于用户用来在空白单元格中输入值的输入面板,WPF 版本相当直接。在 IDE 设计器窗口中是这样的

如前所述,我将提示清除按钮从主窗体移到了输入面板窗体。我用于此窗体的基本 PanelStackPanel。然后我添加了一个 3 x 3 的网格用于数字键盘,然后是两个按钮:一个用于提示,另一个用于清除

为了在三个元素之间创建间距,我们将 Margin 属性添加到数字网格和底部的清除按钮。

当用户单击游戏网格中的某个单元格时,我们希望将此窗口定位在用户刚刚单击的位置旁边。为此,我们在单元格的单击事件中使用以下几行代码。

    System.Drawing.Point point = System.Windows.Forms.Control.MousePosition;
    inputPad.Left = point.X + 20;
    inputPad.Top = point.Y - (inputPad.Height / 2);

基本上,我们查询系统以获取鼠标的绝对位置。然后,我们调整 Input 窗口的 LeftTop 属性,并将其定位在鼠标单击位置右侧 20 像素处。创建和显示窗口的完整代码如下

    inputPad = new InputPad();
    inputPad.Owner = this;
    System.Drawing.Point point = System.Windows.Forms.Control.MousePosition;
    inputPad.Left = point.X + 20;
    inputPad.Top = point.Y - (inputPad.Height / 2);
    inputPad.ShowDialog();

与 WinForms 等效项一样,我们需要在 Window 上设置几个属性

    ResizeMode="NoResize"
    ShowInTaskbar="False"
    SizeToContent="Height"
    WindowStartupLocation="Manual"
    WindowStyle="ToolWindow"

为了将此窗口定位在我们想要的位置,就像 WinForms 版本一样,我们需要将 WindowStartupLocation 属性设置为 Manual。然后,我们可以控制 Window 的 TopLeft 属性。否则,Windows 将忽略我们重新定位窗口的尝试,并将其放置在它想要的位置。

此窗口的代码隐藏也相当直接。一旦用户单击了某个内容,我们就将其保存为状态,然后关闭窗口。

关于游戏完成窗口类似,只是我们不需要保存任何状态。我们只需将子窗口打开为模态对话框,然后在用户单击“确定”后,关闭它并返回主窗口。

总结 WPF

起初,我将 MainWindow 的所有单击事件都留空在代码隐藏中。但现在 ViewModelClass 已基本完成,下一步是将它们连接起来。基本上,MainWindow 中的 click 事件调用 ViewModelClass 中的一个函数,类似于 WinForms 版本中的做法。因此,“新游戏”按钮的代码隐藏如下

        private void btnNew_Click(object sender, RoutedEventArgs e)
        {
            if (ViewModel != null)
                ViewModel.NewClicked();
        }

为了总结 WPF 版本,我清理了 ViewModelClass 中的游戏玩法代码,然后设置了应用程序启动代码。WPF 应用程序中的 Startup 对象是 App.XAML 对象。打开它时,<Application/> 标签中有一个 StartupUri 属性。默认情况下,它指向 MainWindow.XAML。但由于我们希望在 View 之外实例化 ViewModelClass,因此我们将其重定向到 Application 类的 ApplicationStartup 方法。此外,我们将其从 StartupURI 重命名为 Startup。然后我们打开代码隐藏,并将以下方法添加到 App.xaml 类的代码隐藏中。

    public void ApplicationStartup(object sender, StartupEventArgs args)
    {
        MainWindow mainWindow = new MainWindow();
        mainWindow.ViewModel = ViewModelClass.GetInstance(mainWindow);
        mainWindow.Show();
    }

所以,基本上,我们实例化主窗口,为主窗口设置 ViewModel 属性,然后显示窗口。

如果您查看 MainWindow 的代码隐藏,与 WinForms 应用程序的代码隐藏相比,它相当稀疏。

WPF 应用程序运行时看起来是这样的

如果您玩过这个游戏的 VB 版本,您会注意到在这个版本中,我实现了镜像图像,其中缺失的单元格在垂直轴上镜像。在下一版游戏中,我将添加另一个维度,即游戏生成器随机选择镜像为垂直、水平或对角线。

WPF 和 Silverlight

现在我们已经完成了从 VB.NET/WinForms 到 C#/WPF 的端口,我们将尝试将代码移植到 Silverlight。我创建了一个新的 C# Silverlight 应用程序项目,添加了所有子文件夹(ViewModelViewModel),然后将除 WPF UI 文件之外的所有文件从一个项目复制到另一个项目。

我以为会是一个相当直接的端口,但在过程中遇到了一些障碍。第一个区别是 Silverlight 应用程序有两个项目而不是一个。它们是

  • SilverlightApplication1
  • SilverlightApplication1.Web

第二个项目只是主项目的包装器,以便我们可以在浏览器中运行它进行测试/调试。所以我们不必担心第二个项目。

下一个区别是,在 MainWindow 中没有 <Windows/> 标签,而是在 MainPage 中有一个 <UserControl/>。以下部分详细介绍了我在尝试将代码从 WPF 移植到 Silverlight 时遇到的其他障碍。

应用程序设置

第一个障碍是应用程序设置未移植到 Silverlight。在 WPF 中,应用程序设置在“应用程序属性”窗口的选项卡中找到。

从代码寻址属性与 VB.NET Windows 应用程序类似。

    Properties.Settings.Default.Level = (int)GameLevel;
    Properties.Settings.Default.Save();

但在 Silverlight 中,Properties 命名空间不存在。不过,有一个 IsolatedStorageSettings 类。要使用它,需要先获取该类的实例,然后像这样使用它

   private IsolatedStorageSettings _appSettings = IsolatedStorageSettings.ApplicationSettings;

在使用任何设置之前,我们首先检查设置是否已存在

    if (!_appSettings.Contains("Level"))
        _appSettings.Add("Level", "");

因此,如果不存在,我们添加它。然后检索数据,我们使用以下代码

    _level = (Int32)_appSettings["Level"];

要保存设置,我们使用以下代码

    _appSettings["Level"] = _level.ToString();
    _appSettings.Save();

由于所有设置都保存为 string,因此在使用它时,我们需要在整数值与 string 之间进行转换。另外,要将数据提交到磁盘,我们需要调用 Save() 方法。

由于需要该类的实例以及大量支持代码,我认为最好将所有设置合并到一个类中并将其包装成一个 Singleton 对象。

计时器类

下一个障碍是 Timer 类。显然,System.Timers 不是 Silverlight 应用程序命名空间的一部分。幸运的是,System.Threading 命名空间中还有另一个 Timer。这两个类之间有几个区别,我不得不重写 Timer 类的一部分以适应这些区别。

第一个区别是如何实例化 Timer 类。下一个区别是,一旦实例化,就没有 Enabled 属性来启动或停止计时器。但是,有一个 Change 方法可用于暂停/重新启动/停止计时器。另外,一旦计时器实例化并运行,就没有办法知道它是否正在运行,所以我创建了自己的 TimerEnabled 属性来跟踪计时器的状态。

UI XAML 代码

在修复上述问题后,我将 WPF XAML 代码从一个项目复制到另一个项目。我没有复制实际文件,而是打开了原始 WPF 代码,复制了 MainWindow<Window></Window> 标签内的 XAML 代码,并将其粘贴到 MainPage.XAML 窗口中。我对其他三个子窗口也做了同样的操作。

当我将代码粘贴到 MainPage.XAML 窗口时,有几个波浪线表明存在错误。在错误中,有

  • DataTriggers
  • StatusBar
  • Label
  • ObjectDataProvider

这些是相当严重的问题,因为我使用了 DataTrigger 类来帮助控制和管理游戏控件和游戏网格。所以我不得不寻找变通方法。

状态栏和标签

这是一个很容易修复的问题。由于 Silverlight 没有 StatusBarLabel 控件,我只使用了 <Textblock/> 控件。问题是 Labels 被放在了 StatusBar 控件里面。

由于 Silverlight 项目是 UserControl 而不是 Window,我将背景面板对象从 Canvas 切换到了 Grid。部分原因是由于这个 UserControl 在浏览器窗口中运行,并且没有定义边框,所以更容易将所有控件定位在网格中。因此,我添加了一些行和列,然后将每个控件放入特定的网格列和行中,并以此方式对齐所有控件,而不是使用绝对坐标。UI 在设计器窗口中的样子如下。

这是另一个视图,显示了实际的网格行和列。

可以看到,我创建了一个四列十行的网格。然后我将每个控件分配到一个特定的行和列,并以此方式对齐它们。游戏网格本身跨越三列八行。然后我将其居中。状态栏 TextBlock 在第十行,跨越前三列。我添加了一个灰色边框对象来模拟底部的状态栏。

对于包含多个控件的单元格,我使用 StackPanel 对象作为基础,然后在该 StackPanel 中对齐控件。例如,这是 ComboBox 和其上方标签的代码

    <StackPanel Grid.Column="3"
                Grid.Row="0">
        <TextBlock Text="Difficulty Level:"
                   Height="20" 
                   Margin="0,12,0,0"
                   HorizontalAlignment="Left" 
                   VerticalAlignment="Bottom" />
        <ComboBox Name="GameLevel"
                  HorizontalAlignment="Left" 
                  VerticalAlignment="Top"
                  ItemsSource="{Binding}"
                  SelectedIndex="0"
                  Width="140"
                  SelectionChanged="DifficultyLevel_SelectionChanged"/>
    </StackPanel>

我定义了一个 StackPanel 对象并将其分配给第三列和零行。然后我在该 StackPanel 中定义了一个 TextBlock 对象,然后是 ComboBox 对象。

MainPage 的右下角,我还使用了一个 StackPanel,其中元素是水平方向的,而不是默认的垂直方向。这是一个示例,其中我定义了一个仅限于该 StackPanel 对象的样式。

    <StackPanel.Resources>
        <Style TargetType="TextBlock">
            <Setter Property="FontSize" Value="12" />
            <Setter Property="VerticalAlignment" Value="Center" />
            <Setter Property="Margin" Value="5" />
        </Style>
    </StackPanel.Resources>

由于该 StackPanel 中只有 TextBlock 对象,所以我不在乎给样式命名,因为我想让这个 Style 应用于该 StackPanel 内的所有 TextBlock 对象。换句话说,这个 Style 的作用域仅限于定义它的 StackPanel 对象。

ComboBox ItemsSource

如果您注意到上面的 ComboBox 的 XAML,ItemsSource 属性的绑定与 WPF 版本不同。这是因为 ObjectDataProvider 类在 Silverlight 中不可用。作为一种变通方法,我不得不在代码隐藏中创建一个 stringObservableCollection,然后将其绑定到 ComboBoxItemsSource 属性。代码如下

    private ObservableCollection<string> _levels;

    private void InitComboBox()
    {
        _levels = new ObservableCollection<string>();
        foreach (string item in Enum.GetNames(typeof(DifficultyLevels)))
            _levels.Add(item);
        this.GameLevel.DataContext = _levels;
    }

首先,我创建一个模块级变量,然后在 InitComboBox 方法中(从 MainPage_Loaded 事件调用),我用 Enum 中的名称填充集合。最后,由于此组合框的 DataContextMainPage 不同,我直接将组合框的 DataContext 绑定到集合。这是我需要给 ComboBox 控件命名的一个案例。这样,我就能从代码隐藏中访问该控件。

由于我直接将组合框的 DataContext 绑定到 ObservableCollection,因此 ItemsSource 绑定定义如下

    ItemsSource="{Binding}"

我们不必在绑定声明中指定路径或属性。

我也可以在 ViewModel 类中创建一个 ObservableCollection 属性,并将组合框的 ItemsSource 属性绑定到该属性,而不是在代码隐藏中。

您可能注意到的另一个区别是,ComboBoxSelectedIndex 属性未绑定到 ViewModel 类中的属性,并且我也定义了 SelectionChanged 点击事件。这有一个原因,我将在稍后继续介绍 WPF 和 Silverlight 之间的区别时进行解释。

Silverlight 和 DataTriggers

我有点震惊 Silverlight 没有 DataTrigger。那么我将如何使用为 WPF 项目编写的所有花哨的触发器呢?嗯,Silverlight 有一种叫做 DataTemplates 的东西。为了在高亮显示鼠标悬停在单元格上的单元格,我使用了 VisualStateManager 类。

我基本上不得不重做模型,并创建了一个 abstract BaseCell 类。然后,对于 CellStateEnum 中的每个状态,我添加了一个 AnswerCellBlankCellHintCell 等,它们都继承自 BaseCell 类。由于 BaseCell 是完整的,派生类不需要任何额外的属性或方法。

这是具有基于 CellClass 的单个 public 属性的 BaseCell abstract

    public abstract class BaseCell
    {
        public CellClass Cell { get; set; }
    }

然后其他状态类都继承自这个 abstract 类。它们看起来都一样,所以我只包含 AnswerCell 类的定义。

    public class AnswerCell : BaseCell
    { }

正如您所见,每个类都继承自 abstract 类,并且类定义是空的。

然后,对于这些状态类中的每一个,我都创建了一个数据模板。因此,对于 Answer 类,相应的 DataTemplate 如下。但首先,我需要创建基础单元格样式。

    <Style x:Key="CellOutputStyle"
           TargetType="TextBlock">
        <Setter Property="Height" Value="39"/>
        <Setter Property="Width" Value="34" />
        <Setter Property="TextAlignment" Value="Center" />
        <Setter Property="HorizontalAlignment" Value="Left" />
        <Setter Property="VerticalAlignment" Value="Top" />
        <Setter Property="Foreground" Value="Black" />
        <Setter Property="FontSize" Value="24" />
    </Style>

然后,AnswerCellDataTemplate 如下

    <DataTemplate DataType="model:AnswerCell">
        <Grid>
            <TextBlock Style="{StaticResource CellOutputStyle}"
                       Text="{Binding Path=Cell.Answer}"/>
        </Grid>
    </DataTemplate>

TextBlockText 属性绑定到底层单元格的 Answer 属性。

然后我为每个单元格状态数据类型创建了一个 DataTemplate。基本上,每个 DataTemplate 都以一个基础 Grid 开始,在网格内部,我添加一个 TextBlock 控件来显示数据。我还根据显示的是答案(黑色,默认)、提示(紫色)还是用户的正确(绿色)或错误答案(红色),将 Foreground 颜色属性设置为相应的值。

我还需要将 Model.Structures 命名空间添加到 XAML 代码的顶部,以便它知道这些类 resides。

    xmlns:model="clr-namespace:Sudoku_Silverlight.Model.Structures"

然后,游戏网格中每个单元格的 Content 属性都绑定到 ViewModel 中的一个 Cell 属性,该属性返回 BaseCell 类。所以,在这个例子中,第一个单元格(零行,零列)的 Content 属性绑定到 ViewModel 类的 Cell00 属性。

    <Button Style="{StaticResource CellStyleBlue}" 
            Content="{Binding Path=Cell00, Mode=OneWay}"
            Grid.Column="0" 
            Grid.Row="0"
            Click="A0_CellClick" />

ViewModel 类中 Cell00 的定义如下

    public BaseCell Cell00
    {
        get
        {
            return GetCell(0, 0);
        }
    }

然后我在模型和 ViewModel 之间添加了一个额外的数据层,它基本上是一个 BaseCell 对象的列表。

    private List<BaseCell> Cells { get; set; }

然后,当我们想显示不同的状态时,我们只需将列表中某个索引处的单元格对象替换为相应的类。例如,如果我想在第一个单元格中显示答案,我只需将列表中的第一个元素分配给 AnswerCell 类,如下所示。

    Cells[0] = new AnswerCell() { Cell = item };

其中 item 是第一个单元格的实际 CellClass 对象,来自 Model。然后我们为该单元格引发 PropertyChanged 事件,以便 UI 使用 AnswerCellDataTemplate 更新单元格内容。

    OnPropertyChanged(item.CellName);

如果您注意到,在单元格的 XAML 代码中,没有指向我们之前创建的 DataTemplate 的指针。但是,由于单元格绑定到抽象的 BaseCell,而后者又指向 AnswerCell 类,Silverlight 会使用 AnswerCell DataTemplate 来格式化该单元格。是不是很酷?

如果我做得更规范,我会修改 Model 层来输出这个列表,而不是在 ViewModel 层创建它。另外,我将使用索引器,这样我就不必为每个单元格定义一个属性。但这有效,所以就这样吧。

在 Silverlight 中高亮显示单元格

我需要翻译的最后一个触发器是在用户将鼠标悬停在每个单元格上时高亮显示单元格。我们通过使用 VisualStateManager 类来做到这一点。这是实现此目的的 XAML 代码

    <Style x:Key="CellStyleBlue"               
           TargetType="ContentControl"
           BasedOn="{StaticResource BaseCellStyle}">
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="ContentControl">
                    <Grid HorizontalAlignment="Center"
                          VerticalAlignment="Center"
                          Margin="3">
                        <VisualStateManager.VisualStateGroups>
                            <VisualStateGroup x:Name="CommonStates">
                                <VisualStateGroup.Transitions>

                                    <!--Take one half second 
                                        to transition to the MouseOver state.-->
                                    <VisualTransition To="MouseOver" 
                                                      GeneratedDuration="0:0:0.5"/>
                                </VisualStateGroup.Transitions>

                                <VisualState x:Name="Normal" />

                                <!--Change the SolidColorBrush (ButtonBrush) 
                                    to CadetBlue when the
                                    mouse is over the button.-->
                                <VisualState x:Name="MouseOver">
                                    <Storyboard>
                                        <ColorAnimation Storyboard.TargetName="ButtonBrush" 
                                                        Storyboard.TargetProperty="Color" 
                                                        To="CadetBlue" />
                                    </Storyboard>
                                </VisualState>

                            </VisualStateGroup>
                        </VisualStateManager.VisualStateGroups>
                        <Grid.Background>
                            <SolidColorBrush x:Name="ButtonBrush" Color="AliceBlue" />
                        </Grid.Background>
                        <ContentPresenter
                            HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
                            VerticalAlignment="{TemplateBinding VerticalContentAlignment}" />
                    </Grid>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>

这看起来相当复杂,但实际上很简单。我们创建一个 Style,并在该 Style 中添加一个 Template 属性来定义我们关心的视觉状态。基本上,我们关心 NormalMouseOver 状态。当用户将鼠标悬停在单元格上时,我们将 Grid 对象的 Background 属性从 AliceBlue 更改为 CadetBlue。此外,过渡需要 0.5 秒。

即使我们在 Normal 状态下什么也没做,如果我们删除该行,单元格在鼠标离开单元格后仍会保持高亮状态。

有趣的是,即使所有的 Style 都以 ContentControl 为目标,我实际上是将样式应用于 Button 控件。当我将其更改为 ContentControl 时,高亮显示不起作用,但其他一切都正常。我不确定为什么高亮显示不起作用。也许在写完这篇文章后,我会花些时间尝试弄清楚。

WPF 应用程序中的高亮显示与 Silverlight 应用程序中的高亮显示之间的一个区别是,Silverlight 版本中的高亮显示会出现在所有单元格上,而不管底层数据如何。在 WPF 版本中,如果底层数据是答案,则高亮显示被禁用。这是因为在 Silverlight 中,TextBlock 控件缺少 IsEnabled 属性。

无效的跨线程访问错误

在对 Silverlight 项目进行必要的更改以进行编译后,我尝试运行它。它没有运行多久就遇到了“无效的跨线程访问错误”,出现在其中一个 OnPropertyChanged 事件中。显然,Silverlight 处理多线程应用程序不如 WPF。我搜索了 Google,解决方案是使用 Dispatcher.CheckAccess() 方法来检查跨线程访问。用法如下

    private void SetTextBlockContent(TextBlock target, string value)
    {
        if (target.Dispatcher.CheckAccess())
            target.Text = value;
        else
            target.Dispatcher.BeginInvoke(() => { target.Text = value; });
    }

这与 WinForms 应用程序中的问题相同,其中不允许后台线程直接访问 UI 控件。相反,需要检查 InvokeRequired。这是 WinForms 的等效代码

    delegate void SetTextCallback(string text);

    private void SetText(string text)
    {
        if (this.textBox1.InvokeRequired)
        {
            SetTextCallback d = new SetTextCallback(SetText);
            this.Invoke(d, new object[] { text });
        }
        else
            this.textBox1.Text = text;
    }

我本可以花更多时间来弄清楚哪些需要 Dispatcher,哪些不需要。但是,我决定几乎所有地方都使用 Dispatcher。由于使用 Dispatcher 就像在 WinForms 中使用 InvokeRequired 一样,所以我决定使用自定义事件来让 ViewModelView 进行通信,反之亦然。虽然我仍然为某些事情引发 PropertyChanged 事件,并且仍然将 MainPageDataContext 属性设置为 ViewModel,但我几乎为两者之间的任何通信都触发了一个自定义事件,以使事情正常工作。

早些时候,我提到组合框 SelectedIndex 属性不指向 ViewModel 中的属性。而且我还定义了 SelectionChanged 事件的处理程序。这样做的原因是这个 Dispatcher 问题。因此,当用户更改组合框中的选择时,它会触发一个带有新选择的事件,ViewModel 类正在监听此更改。

在做出所有这些更改之后,Silverlight 项目的代码隐藏看起来像是 WPF 和 WinForms 的混合体。

无效 XML

在我输入 MainPage 的 XAML 代码时,又出现了一个问题。XAML 编辑器窗口会在我输入的新代码下方显示一个波浪线。当我将鼠标悬停在波浪线上时,编辑器会告诉我底层代码是“无效 XML”。它会抱怨像下面这样简单的事情

    <DataTemplate DataType="model:AnswerCell">
            
    </DataTemplate>

在搜索 Google 后,答案与我输入的 XAML 代码无关,而是应用程序的名称。当我创建项目时,我将其命名为“Sudoku Silverlight”,中间有一个空格。显然,这会在各地引起问题,这是其中一个症状。在转到项目属性页面并删除空格后,“无效 XML”错误就消失了。那是一个非常奇怪的错误。

所以总而言之,命名项目时要小心,并且不要在名称中使用空格。

子窗口

由于 Silverlight 在浏览器中运行,因此打开子窗口与 WPF 或一般的 Windows 应用程序不同。首先,它无法访问鼠标位置。其次,它无法控制窗口的位置。Silverlight 总是将其置于浏览器窗口的中央。Silverlight 中另一个缺失的是 ShowDialog 方法。它只有一个 Show。由于 Show 是非模态的,它会在打开子窗口后返回,并且不会等待子窗口关闭。

考虑到这些差异,我必须以不同于 WinForms 或 WPF 应用程序的方式来处理子窗口,特别是输入面板窗口。幸运的是,有一个 Closed 事件,可以为其连接事件处理程序。

这是打开输入面板窗口的代码

        private void UIShowNumberPad(CellIndex callingCell)
        {
            InputPad inputPad = new InputPad(callingCell);
            inputPad.Closed += NumberPadClosed;
            inputPad.Show();
        }

我创建一个子窗口实例,连接事件处理程序,然后打开它。这是处理 Closed 事件的代码。

        private void NumberPadClosed(object sender, EventArgs e)
        {
            InputPad inputPad = (InputPad)sender;
            if (inputPad.InputPadState != InputPadStateEnum.InvalidState)
                RaiseEvent(inputPad.CallingCell, inputPad.InputPadState);
        }

当用户通过单击“X”或窗口上的按钮关闭输入面板窗口时,我会引发一个带有数据的事件,以便 ViewModel 知道最后单击的单元格以及用户在输入面板上单击的按钮。

Silverlight 中的消息框

与 WinForms 应用程序中的 MessageBox 不同,Silverlight 中的 MessageBox 有几个限制。我决定不使用它,因为我无法控制按钮中的文本。默认情况下,它有一个“确定”按钮。方法调用中有一个参数,我也可以添加一个“取消”按钮。但我无法更改按钮文本以显示“”和“”,所以我决定创建自己的消息框。

这并不难。我使用 Grid 作为基础,并添加了三列两行。对于第二行(按钮所在的位置),Height 设置为“Auto”。

        <Grid.RowDefinitions>
            <RowDefinition />
            <RowDefinition Height="Auto" />
        </Grid.RowDefinitions>

而不是将高度平均分配给两行,Auto 会将行高设置为该行控件的高度。其余部分则分配给第一行。至于这个窗口如何工作,它与输入面板窗口的工作方式类似。我连接了一个 Closed 事件处理程序,它返回有关用户单击还是的信息。

总结 Silverlight 项目

最终,MainPage 的代码隐藏看起来像是 WPF 和 WinForms 的混合体。发布本文后,我可能会花些时间看看是否可以减少 Dispatcher 调用以及 ViewViewModel 之间引发的自定义事件的数量。

在经历了 WPF 和 Silverlight 之间的差异并清理了游戏玩法代码后,最后一步是包装应用程序的启动/停止代码。由于项目在浏览器中运行,我们无法从内部关闭应用程序。所以不需要关闭按钮。但是,我们知道应用程序何时关闭,因为 Application 类中有一个 Exit 事件。

我打开 App.xaml.cs 文件并连接 Application_Exit 处理程序,以便当用户关闭运行此应用程序的选项卡或浏览器时,它将调用一个方法来停止所有后台线程并将当前设置保存到 IsolatedStorageSettings

Silverlight 应用程序运行时看起来是这样的

它看起来非常接近 WPF 版本。主要区别在于缺少标题栏以及右下角数字之间的分隔线。我还向用户控件添加了一个 border 元素,以便在浏览器中运行时,用户知道游戏窗口的边界。

结论

这是一个有趣的挑战,尝试将 VB.NET WinForms 应用程序转换为 C#/WPF 和 Silverlight。我学到了很多关于 WPF 和 Silverlight 的知识。即使 Silverlight 是 WPF 的一个子集,两者之间仍然存在足够的差异,以至于共享 XAML 代码将是一项艰巨的任务。对我来说最大的区别是 Silverlight 中缺少触发器。

但是,如果一个项目确实需要在两个之间共享相同的代码库,通过仔细的规划和大量的编译器指令,这是可以做到的。网上有几篇文章讨论了 WPF、Silverlight 和 Windows Phone/Store App 之间的代码重用和代码共享。基本上,采用最低公分母并编写代码来使用该子集。如果做得好,代码将可以在所有三个平台之间移植。

对于这个项目,如果我一开始就从 Silverlight 开始,那么将代码移植到 WPF 可能会比反过来容易得多。也许我以后会处理这个问题。也就是说,尝试将 Silverlight 项目移植到 WPF,看看有多少实际上是可重用的。

我希望通过详细说明我遇到的所有问题,能够帮助其他人完成他们的项目。

您会注意到的一件事是,我为游戏网格中的每个单元格创建了一个属性以及一个匹配的单击事件处理程序。这样就产生了 81 个属性和 81 个单击事件。我确定有一种方法可以通过 WPF XAML 传递单元格的行和列参数来简化这一点。这将是我以后要研究的另一件事。

如果您对本文中的任何内容有疑问或问题,或者想让我更详细地介绍我上面涵盖的或未能涵盖的某个特定问题或主题,请随时与我联系。

历史

  • 2015-01-01:代码/文章首次发布
© . All rights reserved.